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:
authorEric Eastwood <contact@ericeastwood.com>2018-03-27 20:39:18 +0300
committerEric Eastwood <contact@ericeastwood.com>2018-03-27 20:39:18 +0300
commitde0c4730bb83b94195678e5ad4865ea648e6585f (patch)
tree3ea0fd48c5736b0f5b328f117c3db0508a371d22 /app
parentdc5b5130d573a48fc34fa62862a1ad6da776b0d0 (diff)
parenta1cde68d208437a470267e28ccff66fe8be88c2e (diff)
Merge branch 'master' into add-canary-favicon
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/signin_with_google.pngbin0 -> 8001 bytes
-rw-r--r--app/assets/images/emoji.pngbin1218558 -> 1219696 bytes
-rw-r--r--app/assets/images/emoji/gay_pride_flag.pngbin0 -> 2340 bytes
-rw-r--r--app/assets/images/emoji/mrs_claus.pngbin2206 -> 3338 bytes
-rw-r--r--app/assets/images/emoji/speech_left.pngbin0 -> 390 bytes
-rw-r--r--app/assets/images/emoji@2x.pngbin2976505 -> 2977099 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/favicon-blue.icobin5430 -> 5430 bytes
-rw-r--r--app/assets/images/file_icons.svg1
-rw-r--r--app/assets/images/multi-editor-off.pngbin0 -> 4884 bytes
-rw-r--r--app/assets/images/multi-editor-on.pngbin0 -> 3971 bytes
-rw-r--r--app/assets/images/new_nav.pngbin23771 -> 0 bytes
-rw-r--r--app/assets/images/new_repo.pngbin0 -> 19292 bytes
-rw-r--r--app/assets/images/old_nav.pngbin25617 -> 0 bytes
-rw-r--r--app/assets/images/old_repo.pngbin0 -> 20668 bytes
-rw-r--r--app/assets/javascripts/abuse_reports.js37
-rw-r--r--app/assets/javascripts/activities.js11
-rw-r--r--app/assets/javascripts/admin.js62
-rw-r--r--app/assets/javascripts/ajax_loading_spinner.js9
-rw-r--r--app/assets/javascripts/api.js181
-rw-r--r--app/assets/javascripts/aside.js24
-rw-r--r--app/assets/javascripts/autosave.js57
-rw-r--r--app/assets/javascripts/awards_handler.js243
-rw-r--r--app/assets/javascripts/behaviors/autosize.js25
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js74
-rw-r--r--app/assets/javascripts/behaviors/details_behavior.js1
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js94
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js11
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js120
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js172
-rw-r--r--app/assets/javascripts/behaviors/index.js8
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js501
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js17
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js57
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js13
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js12
-rw-r--r--app/assets/javascripts/behaviors/secret_values.js47
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js8
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js9
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js40
-rw-r--r--app/assets/javascripts/blob/blob_fork_suggestion.js2
-rw-r--r--app/assets/javascripts/blob/blob_line_permalink_updater.js4
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js5
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js2
-rw-r--r--app/assets/javascripts/blob/notebook/index.js83
-rw-r--r--app/assets/javascripts/blob/notebook_viewer.js2
-rw-r--r--app/assets/javascripts/blob/pdf/index.js10
-rw-r--r--app/assets/javascripts/blob/pdf_viewer.js2
-rw-r--r--app/assets/javascripts/blob/sketch_viewer.js4
-rw-r--r--app/assets/javascripts/blob/stl_viewer.js4
-rw-r--r--app/assets/javascripts/blob/template_selector.js4
-rw-r--r--app/assets/javascripts/blob/viewer/index.js62
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js20
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js16
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js177
-rw-r--r--app/assets/javascripts/boards/components/board.js6
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js6
-rw-r--r--app/assets/javascripts/boards/components/board_card.js67
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue100
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js1
-rw-r--r--app/assets/javascripts/boards/components/board_list.js209
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue219
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js97
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue144
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js33
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js31
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js12
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js20
-rw-r--r--app/assets/javascripts/boards/components/modal/list.js8
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js32
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue129
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.js38
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js17
-rw-r--r--app/assets/javascripts/boards/index.js245
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js14
-rw-r--r--app/assets/javascripts/boards/models/issue.js34
-rw-r--r--app/assets/javascripts/boards/models/label.js14
-rw-r--r--app/assets/javascripts/boards/models/list.js41
-rw-r--r--app/assets/javascripts/boards/models/project.js6
-rw-r--r--app/assets/javascripts/boards/services/board_service.js108
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js13
-rw-r--r--app/assets/javascripts/boards/utils/query_data.js2
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js10
-rw-r--r--app/assets/javascripts/breadcrumb.js30
-rw-r--r--app/assets/javascripts/breakpoints.js77
-rw-r--r--app/assets/javascripts/broadcast_message.js33
-rw-r--r--app/assets/javascripts/build.js317
-rw-r--r--app/assets/javascripts/build_artifacts.js52
-rw-r--r--app/assets/javascripts/build_variables.js16
-rw-r--r--app/assets/javascripts/ci_lint_editor.js17
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js117
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js222
-rw-r--r--app/assets/javascripts/ci_variable_list/native_form_variable_list.js27
-rw-r--r--app/assets/javascripts/close_reopen_report_toggle.js97
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js237
-rw-r--r--app/assets/javascripts/clusters/clusters_index.js20
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue207
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue280
-rw-r--r--app/assets/javascripts/clusters/constants.js13
-rw-r--r--app/assets/javascripts/clusters/event_hub.js3
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js25
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js89
-rw-r--r--app/assets/javascripts/comment_type_toggle.js5
-rw-r--r--app/assets/javascripts/commit.js12
-rw-r--r--app/assets/javascripts/commit/file.js14
-rw-r--r--app/assets/javascripts/commit/image_file.js391
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js36
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue79
-rw-r--r--app/assets/javascripts/commit_merge_requests.js74
-rw-r--r--app/assets/javascripts/commits.js115
-rw-r--r--app/assets/javascripts/commons/bootstrap.js6
-rw-r--r--app/assets/javascripts/commons/index.js3
-rw-r--r--app/assets/javascripts/commons/jquery.js3
-rw-r--r--app/assets/javascripts/commons/polyfills.js6
-rw-r--r--app/assets/javascripts/commons/polyfills/custom_event.js7
-rw-r--r--app/assets/javascripts/commons/polyfills/element.js19
-rw-r--r--app/assets/javascripts/commons/polyfills/event.js18
-rw-r--r--app/assets/javascripts/commons/polyfills/nodelist.js7
-rw-r--r--app/assets/javascripts/commons/vue.js6
-rw-r--r--app/assets/javascripts/compare.js84
-rw-r--r--app/assets/javascripts/compare_autocomplete.js120
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js58
-rw-r--r--app/assets/javascripts/contextual_sidebar.js82
-rw-r--r--app/assets/javascripts/copy_as_gfm.js458
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js72
-rw-r--r--app/assets/javascripts/create_item_dropdown.js119
-rw-r--r--app/assets/javascripts/create_label.js32
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js477
-rw-r--r--app/assets/javascripts/cycle_analytics/components/banner.vue61
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.js17
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue35
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js51
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.vue73
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_component.vue76
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js52
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js53
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue77
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js52
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js62
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.vue96
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js53
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue98
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.js49
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.vue101
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.js25
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.vue49
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js119
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js35
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js9
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue33
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue54
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue43
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue12
-rw-r--r--app/assets/javascripts/deploy_keys/index.js10
-rw-r--r--app/assets/javascripts/diff.js64
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js1
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js17
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js24
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js26
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_count.js2
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js31
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js4
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js35
-rw-r--r--app/assets/javascripts/dispatcher.js620
-rw-r--r--app/assets/javascripts/docs/docs_bundle.js10
-rw-r--r--app/assets/javascripts/droplab/drop_down.js41
-rw-r--r--app/assets/javascripts/droplab/hook.js2
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax.js13
-rw-r--r--app/assets/javascripts/droplab/plugins/filter.js2
-rw-r--r--app/assets/javascripts/droplab/utils.js2
-rw-r--r--app/assets/javascripts/dropzone_input.js563
-rw-r--r--app/assets/javascripts/due_date_select.js102
-rw-r--r--app/assets/javascripts/emoji/index.js100
-rw-r--r--app/assets/javascripts/emoji/support/index.js10
-rw-r--r--app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js134
-rw-r--r--app/assets/javascripts/emoji/support/unicode_support_map.js178
-rw-r--r--app/assets/javascripts/environments/components/container.vue70
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue44
-rw-r--r--app/assets/javascripts/environments/components/environment.vue279
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue95
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue45
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue952
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue43
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue73
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue85
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue55
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue131
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue91
-rw-r--r--app/assets/javascripts/environments/environments_bundle.js10
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js35
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue258
-rw-r--r--app/assets/javascripts/environments/index.js39
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js173
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js33
-rw-r--r--app/assets/javascripts/experimental_flags.js4
-rw-r--r--app/assets/javascripts/extensions/array.js11
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js66
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js60
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_options.js12
-rw-r--r--app/assets/javascripts/files_comment_button.js198
-rw-r--r--app/assets/javascripts/filterable_list.js41
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js101
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue104
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js79
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js23
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js27
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js35
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js95
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js10
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js17
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js113
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js221
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js25
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js139
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js8
-rw-r--r--app/assets/javascripts/flash.js158
-rw-r--r--app/assets/javascripts/fly_out_nav.js189
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js121
-rw-r--r--app/assets/javascripts/gl_dropdown.js183
-rw-r--r--app/assets/javascripts/gl_field_error.js7
-rw-r--r--app/assets/javascripts/gl_field_errors.js37
-rw-r--r--app/assets/javascripts/gl_form.js172
-rw-r--r--app/assets/javascripts/gpg_badges.js23
-rw-r--r--app/assets/javascripts/graphs/graphs_bundle.js6
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors.js111
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_graph.js271
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_util.js137
-rw-r--r--app/assets/javascripts/group.js2
-rw-r--r--app/assets/javascripts/group_avatar.js31
-rw-r--r--app/assets/javascripts/group_label_subscription.js33
-rw-r--r--app/assets/javascripts/group_name.js64
-rw-r--r--app/assets/javascripts/groups/components/app.vue241
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue43
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue268
-rw-r--r--app/assets/javascripts/groups/components/groups.vue62
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue67
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue30
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue89
-rw-r--r--app/assets/javascripts/groups/components/item_stats_value.vue68
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue35
-rw-r--r--app/assets/javascripts/groups/constants.js35
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js77
-rw-r--r--app/assets/javascripts/groups/index.js196
-rw-r--r--app/assets/javascripts/groups/new_group_child.js63
-rw-r--r--app/assets/javascripts/groups/service/groups_service.js40
-rw-r--r--app/assets/javascripts/groups/services/groups_service.js38
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js106
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js166
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js36
-rw-r--r--app/assets/javascripts/groups_select.js194
-rw-r--r--app/assets/javascripts/header.js24
-rw-r--r--app/assets/javascripts/help/help.js11
-rw-r--r--app/assets/javascripts/helpers/issuables_helper.js27
-rw-r--r--app/assets/javascripts/how_to_merge.js15
-rw-r--r--app/assets/javascripts/ide/components/changed_file_icon.vue31
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue65
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue66
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue35
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue60
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue94
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue91
-rw-r--r--app/assets/javascripts/ide/components/ide.vue111
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue84
-rw-r--r--app/assets/javascripts/ide/components/ide_external_links.vue43
-rw-r--r--app/assets/javascripts/ide/components/ide_project_branches_tree.vue47
-rw-r--r--app/assets/javascripts/ide/components/ide_project_tree.vue65
-rw-r--r--app/assets/javascripts/ide/components/ide_repo_tree.vue41
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue51
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue60
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue111
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue99
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue75
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue172
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue161
-rw-r--r--app/assets/javascripts/ide/components/repo_file.vue128
-rw-r--r--app/assets/javascripts/ide/components/repo_file_buttons.vue61
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue39
-rw-r--r--app/assets/javascripts/ide/components/repo_loading_file.vue42
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue98
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue61
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue88
-rw-r--r--app/assets/javascripts/ide/eventhub.js3
-rw-r--r--app/assets/javascripts/ide/ide_router.js117
-rw-r--r--app/assets/javascripts/ide/index.js33
-rw-r--r--app/assets/javascripts/ide/lib/common/disposable.js14
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js90
-rw-r--r--app/assets/javascripts/ide/lib/common/model_manager.js51
-rw-r--r--app/assets/javascripts/ide/lib/decorations/controller.js45
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js72
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js30
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff_worker.js10
-rw-r--r--app/assets/javascripts/ide/lib/editor.js168
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js15
-rw-r--r--app/assets/javascripts/ide/lib/themes/gl_theme.js14
-rw-r--r--app/assets/javascripts/ide/monaco_loader.js16
-rw-r--r--app/assets/javascripts/ide/services/index.js55
-rw-r--r--app/assets/javascripts/ide/stores/actions.js121
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js146
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js49
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js93
-rw-r--r--app/assets/javascripts/ide/stores/getters.js30
-rw-r--r--app/assets/javascripts/ide/stores/index.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js218
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/constants.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js43
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js106
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js26
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js83
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js23
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js38
-rw-r--r--app/assets/javascripts/ide/stores/state.js19
-rw-r--r--app/assets/javascripts/ide/stores/utils.js125
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js101
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js35
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js58
-rw-r--r--app/assets/javascripts/image_diff/helpers/dom_helper.js44
-rw-r--r--app/assets/javascripts/image_diff/helpers/index.js25
-rw-r--r--app/assets/javascripts/image_diff/helpers/utils_helper.js95
-rw-r--r--app/assets/javascripts/image_diff/image_badge.js23
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js144
-rw-r--r--app/assets/javascripts/image_diff/init_discussion_tab.js12
-rw-r--r--app/assets/javascripts/image_diff/replaced_image_diff.js92
-rw-r--r--app/assets/javascripts/image_diff/view_types.js9
-rw-r--r--app/assets/javascripts/importer_status.js189
-rw-r--r--app/assets/javascripts/init_changes_dropdown.js11
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js20
-rw-r--r--app/assets/javascripts/init_labels.js19
-rw-r--r--app/assets/javascripts/init_legacy_filters.js14
-rw-r--r--app/assets/javascripts/init_notes.js16
-rw-r--r--app/assets/javascripts/integrations/index.js7
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js53
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js14
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js19
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js49
-rw-r--r--app/assets/javascripts/issuable_context.js99
-rw-r--r--app/assets/javascripts/issuable_form.js292
-rw-r--r--app/assets/javascripts/issuable_index.js194
-rw-r--r--app/assets/javascripts/issue.js177
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue519
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue61
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue10
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue54
-rw-r--r--app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue23
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue30
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue5
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue83
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue8
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue69
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue70
-rw-r--r--app/assets/javascripts/issue_show/index.js37
-rw-r--r--app/assets/javascripts/issue_show/services/index.js21
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js2
-rw-r--r--app/assets/javascripts/issue_status_select.js57
-rw-r--r--app/assets/javascripts/job.js293
-rw-r--r--app/assets/javascripts/jobs/components/header.vue47
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue7
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue73
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js26
-rw-r--r--app/assets/javascripts/jobs/job_details_mediator.js15
-rw-r--r--app/assets/javascripts/jobs/services/job_service.js9
-rw-r--r--app/assets/javascripts/label_manager.js207
-rw-r--r--app/assets/javascripts/labels.js75
-rw-r--r--app/assets/javascripts/labels_select.js755
-rw-r--r--app/assets/javascripts/layout_nav.js74
-rw-r--r--app/assets/javascripts/lazy_loader.js75
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js36
-rw-r--r--app/assets/javascripts/lib/utils/animate.js49
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js36
-rw-r--r--app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js2
-rw-r--r--app/assets/javascripts/lib/utils/cache.js4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js884
-rw-r--r--app/assets/javascripts/lib/utils/constants.js2
-rw-r--r--app/assets/javascripts/lib/utils/csrf.js60
-rw-r--r--app/assets/javascripts/lib/utils/datefix.js29
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js383
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js7
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js3
-rw-r--r--app/assets/javascripts/lib/utils/image_utility.js5
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js30
-rw-r--r--app/assets/javascripts/lib/utils/poll.js18
-rw-r--r--app/assets/javascripts/lib/utils/pretty_time.js108
-rw-r--r--app/assets/javascripts/lib/utils/sticky.js39
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js136
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js253
-rw-r--r--app/assets/javascripts/lib/utils/tick_formats.js39
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js134
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js8
-rw-r--r--app/assets/javascripts/line_highlighter.js287
-rw-r--r--app/assets/javascripts/locale/bg/app.js1
-rw-r--r--app/assets/javascripts/locale/de/app.js1
-rw-r--r--app/assets/javascripts/locale/en/app.js1
-rw-r--r--app/assets/javascripts/locale/eo/app.js1
-rw-r--r--app/assets/javascripts/locale/es/app.js1
-rw-r--r--app/assets/javascripts/locale/fr/app.js1
-rw-r--r--app/assets/javascripts/locale/index.js38
-rw-r--r--app/assets/javascripts/locale/pt_BR/app.js1
-rw-r--r--app/assets/javascripts/locale/sprintf.js26
-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/logo.js8
-rw-r--r--app/assets/javascripts/main.js420
-rw-r--r--app/assets/javascripts/member_expiration_date.js95
-rw-r--r--app/assets/javascripts/members.js121
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.js21
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_service.js14
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js3
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js34
-rw-r--r--app/assets/javascripts/merge_request.js234
-rw-r--r--app/assets/javascripts/merge_request_tabs.js655
-rw-r--r--app/assets/javascripts/milestone.js93
-rw-r--r--app/assets/javascripts/milestone_select.js395
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js28
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue198
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue127
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue317
-rw-r--r--app/assets/javascripts/monitoring/components/graph/deployment.vue75
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue201
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue212
-rw-r--r--app/assets/javascripts/monitoring/components/graph/path.vue53
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue35
-rw-r--r--app/assets/javascripts/monitoring/constants.js4
-rw-r--r--app/assets/javascripts/monitoring/deployments.js211
-rw-r--r--app/assets/javascripts/monitoring/event_hub.js3
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js73
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js26
-rw-r--r--app/assets/javascripts/monitoring/prometheus_graph.js433
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js55
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js43
-rw-r--r--app/assets/javascripts/monitoring/utils/date_time_formatters.js42
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js42
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js114
-rw-r--r--app/assets/javascripts/mr_notes/index.js44
-rw-r--r--app/assets/javascripts/namespace_select.js136
-rw-r--r--app/assets/javascripts/network/branch_graph.js17
-rw-r--r--app/assets/javascripts/network/network.js19
-rw-r--r--app/assets/javascripts/network/network_bundle.js17
-rw-r--r--app/assets/javascripts/new_branch_form.js170
-rw-r--r--app/assets/javascripts/new_commit_form.js57
-rw-r--r--app/assets/javascripts/notebook/cells/code.vue30
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue28
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue41
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue47
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue43
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue146
-rw-r--r--app/assets/javascripts/notebook/cells/prompt.vue33
-rw-r--r--app/assets/javascripts/notebook/index.vue32
-rw-r--r--app/assets/javascripts/notes.js1049
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue459
-rw-r--r--app/assets/javascripts/notes/components/diff_file_header.vue94
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue101
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue121
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue26
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue231
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue39
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue240
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue121
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue48
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue207
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue114
-rw-r--r--app/assets/javascripts/notes/components/note_signed_out_widget.vue25
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue346
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue203
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue180
-rw-r--r--app/assets/javascripts/notes/constants.js15
-rw-r--r--app/assets/javascripts/notes/event_hub.js3
-rw-r--r--app/assets/javascripts/notes/index.js43
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js21
-rw-r--r--app/assets/javascripts/notes/mixins/issuable_state.js15
-rw-r--r--app/assets/javascripts/notes/mixins/noteable.js22
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js55
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js48
-rw-r--r--app/assets/javascripts/notes/stores/actions.js317
-rw-r--r--app/assets/javascripts/notes/stores/getters.js70
-rw-r--r--app/assets/javascripts/notes/stores/index.js26
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js20
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js214
-rw-r--r--app/assets/javascripts/notes/stores/utils.js33
-rw-r--r--app/assets/javascripts/notifications_dropdown.js51
-rw-r--r--app/assets/javascripts/notifications_form.js96
-rw-r--r--app/assets/javascripts/pager.js134
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js37
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/admin.js60
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js36
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/cohorts/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/cohorts/usage_ping.js13
-rw-r--r--app/assets/javascripts/pages/admin/conversational_development_index/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js9
-rw-r--r--app/assets/javascripts/pages/admin/groups/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue49
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js28
-rw-r--r--app/assets/javascripts/pages/admin/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js9
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue125
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/index.js38
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue174
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js44
-rw-r--r--app/assets/javascripts/pages/ci/lints/ci_lint_editor.js19
-rw-r--r--app/assets/javascripts/pages/ci/lints/new/index.js3
-rw-r--r--app/assets/javascripts/pages/ci/lints/show/index.js3
-rw-r--r--app/assets/javascripts/pages/constants.js6
-rw-r--r--app/assets/javascripts/pages/dashboard/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/groups/index/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js7
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js7
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/index/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js9
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js151
-rw-r--r--app/assets/javascripts/pages/explore/groups/index.js16
-rw-r--r--app/assets/javascripts/pages/explore/projects/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/boards/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js10
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/labels/index/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js10
-rw-r--r--app/assets/javascripts/pages/groups/milestones/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/milestones/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js12
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js22
-rw-r--r--app/assets/javascripts/pages/help/index/index.js8
-rw-r--r--app/assets/javascripts/pages/help/show/index.js3
-rw-r--r--app/assets/javascripts/pages/help/ui/index.js3
-rw-r--r--app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js3
-rw-r--r--app/assets/javascripts/pages/import/gitlab_projects/new/index.js3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue110
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue64
-rw-r--r--app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js84
-rw-r--r--app/assets/javascripts/pages/milestones/shared/event_hub.js3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/index.js7
-rw-r--r--app/assets/javascripts/pages/milestones/shared/init_milestones_show.js11
-rw-r--r--app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js82
-rw-r--r--app/assets/javascripts/pages/omniauth_callbacks/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/accounts/show/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/index.js17
-rw-r--r--app/assets/javascripts/pages/profiles/index/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/notifications/show/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js15
-rw-r--r--app/assets/javascripts/pages/projects/activity/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/browse/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/file/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/blame/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js29
-rw-r--r--app/assets/javascripts/pages/projects/boards/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/branches/new/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/clusters/destroy/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/update/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js26
-rw-r--r--app/assets/javascripts/pages/projects/commits/show/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/compare/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/constants.js6
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js16
-rw-r--r--app/assets/javascripts/pages/projects/environments/folder/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/environments/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/environments/terminal/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/find_file/show/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js62
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/index.js24
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js126
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js296
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js138
-rw-r--r--app/assets/javascripts/pages/projects/imports/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js36
-rw-r--r--app/assets/javascripts/pages/projects/init_form.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js18
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js13
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/jobs/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue79
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/event_hub.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js91
-rw-r--r--app/assets/javascripts/pages/projects/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js20
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js15
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js20
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js32
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/milestones/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/milestones/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/milestones/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/milestones/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/network/network.js20
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js17
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue160
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue67
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js54
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js68
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg (renamed from app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg)0
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js50
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/builds/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/charts/index.js57
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/failures/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js40
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/init_pipelines.js16
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/new/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/project.js135
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/registry/repositories/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/releases/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/services/edit/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js25
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js17
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue111
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue51
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue328
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js11
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/external.js18
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_avatar.js15
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_new.js152
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js41
-rw-r--r--app/assets/javascripts/pages/projects/snippets/edit/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/snippets/new/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/tags/new/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js39
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js60
-rw-r--r--app/assets/javascripts/pages/search/init_filtered_search.js23
-rw-r--r--app/assets/javascripts/pages/search/show/index.js3
-rw-r--r--app/assets/javascripts/pages/search/show/search.js116
-rw-r--r--app/assets/javascripts/pages/sessions/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js12
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js34
-rw-r--r--app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js55
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js134
-rw-r--r--app/assets/javascripts/pages/snippets/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/snippets/form.js8
-rw-r--r--app/assets/javascripts/pages/snippets/new/index.js7
-rw-r--r--app/assets/javascripts/pages/snippets/show/index.js11
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js243
-rw-r--r--app/assets/javascripts/pages/users/index.js28
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js200
-rw-r--r--app/assets/javascripts/pdf/index.vue40
-rw-r--r--app/assets/javascripts/pdf/page/index.vue44
-rw-r--r--app/assets/javascripts/peek.js16
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue93
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue191
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue52
-rw-r--r--app/assets/javascripts/performance_bar/components/simple_metric.vue30
-rw-r--r--app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue20
-rw-r--r--app/assets/javascripts/performance_bar/index.js37
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js45
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js39
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js148
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js50
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js52
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js66
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js21
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js12
-rw-r--r--app/assets/javascripts/pipelines.js3
-rw-r--r--app/assets/javascripts/pipelines/components/async_button.vue125
-rw-r--r--app/assets/javascripts/pipelines/components/blank_state.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/empty_state.vue69
-rw-r--r--app/assets/javascripts/pipelines/components/error_state.vue21
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue24
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue52
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue20
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue79
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue82
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue133
-rw-r--r--app/assets/javascripts/pipelines/components/nav_controls.vue81
-rw-r--r--app/assets/javascripts/pipelines/components/navigation_tabs.vue76
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue80
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue415
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue98
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue444
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue245
-rw-r--r--app/assets/javascripts/pipelines/components/time_ago.vue33
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js34
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js27
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediatior.js59
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js59
-rw-r--r--app/assets/javascripts/pipelines/pipelines_bundle.js24
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js1
-rw-r--r--app/assets/javascripts/pipelines/stores/pipelines_store.js6
-rw-r--r--app/assets/javascripts/preview_markdown.js363
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue135
-rw-r--r--app/assets/javascripts/profile/account/index.js29
-rw-r--r--app/assets/javascripts/profile/gl_crop.js7
-rw-r--r--app/assets/javascripts/profile/profile.js156
-rw-r--r--app/assets/javascripts/profile/profile_bundle.js2
-rw-r--r--app/assets/javascripts/project.js131
-rw-r--r--app/assets/javascripts/project_avatar.js20
-rw-r--r--app/assets/javascripts/project_edit.js4
-rw-r--r--app/assets/javascripts/project_find_file.js292
-rw-r--r--app/assets/javascripts/project_fork.js20
-rw-r--r--app/assets/javascripts/project_import.js17
-rw-r--r--app/assets/javascripts/project_label_subscription.js79
-rw-r--r--app/assets/javascripts/project_new.js157
-rw-r--r--app/assets/javascripts/project_select.js171
-rw-r--r--app/assets/javascripts/project_select_combo_button.js99
-rw-r--r--app/assets/javascripts/project_show.js11
-rw-r--r--app/assets/javascripts/project_variables.js43
-rw-r--r--app/assets/javascripts/project_visibility.js43
-rw-r--r--app/assets/javascripts/projects/project_import_gitlab_project.js9
-rw-r--r--app/assets/javascripts/projects/project_new.js138
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue120
-rw-r--r--app/assets/javascripts/projects/tree/services/commit_pipeline_service.js11
-rw-r--r--app/assets/javascripts/projects_dropdown/components/app.vue157
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue57
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_item.vue116
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_search.vue63
-rw-r--r--app/assets/javascripts/projects_dropdown/components/search.vue65
-rw-r--r--app/assets/javascripts/projects_dropdown/constants.js10
-rw-r--r--app/assets/javascripts/projects_dropdown/event_hub.js3
-rw-r--r--app/assets/javascripts/projects_dropdown/index.js66
-rw-r--r--app/assets/javascripts/projects_dropdown/service/projects_service.js133
-rw-r--r--app/assets/javascripts/projects_dropdown/store/projects_store.js33
-rw-r--r--app/assets/javascripts/prometheus_metrics/index.js6
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js78
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js53
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js140
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js83
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js131
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit_list.js29
-rw-r--r--app/assets/javascripts/protected_branches/protected_branches_bundle.js5
-rw-r--r--app/assets/javascripts/protected_tags/index.js2
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js14
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js86
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js32
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js1
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js9
-rw-r--r--app/assets/javascripts/registry/components/app.vue62
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue135
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue145
-rw-r--r--app/assets/javascripts/registry/constants.js15
-rw-r--r--app/assets/javascripts/registry/index.js25
-rw-r--r--app/assets/javascripts/registry/stores/actions.js37
-rw-r--r--app/assets/javascripts/registry/stores/getters.js2
-rw-r--r--app/assets/javascripts/registry/stores/index.js39
-rw-r--r--app/assets/javascripts/registry/stores/mutation_types.js7
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js54
-rw-r--r--app/assets/javascripts/render_gfm.js17
-rw-r--r--app/assets/javascripts/render_math.js56
-rw-r--r--app/assets/javascripts/right_sidebar.js445
-rw-r--r--app/assets/javascripts/search.js118
-rw-r--r--app/assets/javascripts/search_autocomplete.js784
-rw-r--r--app/assets/javascripts/settings_panels.js47
-rw-r--r--app/assets/javascripts/shared/milestones/form.js10
-rw-r--r--app/assets/javascripts/shared/sessions/u2f.js17
-rw-r--r--app/assets/javascripts/shortcuts.js235
-rw-r--r--app/assets/javascripts/shortcuts_blob.js11
-rw-r--r--app/assets/javascripts/shortcuts_find_file.js57
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js157
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js54
-rw-r--r--app/assets/javascripts/shortcuts_network.js39
-rw-r--r--app/assets/javascripts/shortcuts_wiki.js10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js59
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue232
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js87
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue95
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue102
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue54
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue45
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form.vue62
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue50
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue121
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue134
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue31
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue47
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue85
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js97
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue102
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js21
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js34
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js11
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue168
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js85
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js27
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js146
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js25
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js25
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js74
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js72
-rw-r--r--app/assets/javascripts/signin_tabs_memoizer.js57
-rw-r--r--app/assets/javascripts/single_file_diff.js160
-rw-r--r--app/assets/javascripts/smart_interval.js270
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js15
-rw-r--r--app/assets/javascripts/snippets_list.js13
-rw-r--r--app/assets/javascripts/sortable/sortable_config.js7
-rw-r--r--app/assets/javascripts/star.js52
-rw-r--r--app/assets/javascripts/subscription.js47
-rw-r--r--app/assets/javascripts/subscription_select.js58
-rw-r--r--app/assets/javascripts/syntax_highlight.js27
-rw-r--r--app/assets/javascripts/task_list.js27
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js103
-rw-r--r--app/assets/javascripts/templates/issuable_template_selectors.js51
-rw-r--r--app/assets/javascripts/terminal/index.js9
-rw-r--r--app/assets/javascripts/terminal/terminal.js12
-rw-r--r--app/assets/javascripts/terminal/terminal_bundle.js9
-rw-r--r--app/assets/javascripts/test.js1
-rw-r--r--app/assets/javascripts/test_utils/index.js4
-rw-r--r--app/assets/javascripts/test_utils/simulate_input.js23
-rw-r--r--app/assets/javascripts/todos.js164
-rw-r--r--app/assets/javascripts/toggle_buttons.js61
-rw-r--r--app/assets/javascripts/tree.js123
-rw-r--r--app/assets/javascripts/u2f/authenticate.js191
-rw-r--r--app/assets/javascripts/u2f/error.js43
-rw-r--r--app/assets/javascripts/u2f/register.js155
-rw-r--r--app/assets/javascripts/u2f/util.js53
-rw-r--r--app/assets/javascripts/ui_development_kit.js23
-rw-r--r--app/assets/javascripts/usage_ping.js15
-rw-r--r--app/assets/javascripts/user.js35
-rw-r--r--app/assets/javascripts/user_callout.js13
-rw-r--r--app/assets/javascripts/user_tabs.js175
-rw-r--r--app/assets/javascripts/username_validator.js135
-rw-r--r--app/assets/javascripts/users/calendar.js231
-rw-r--r--app/assets/javascripts/users/users_bundle.js1
-rw-r--r--app/assets/javascripts/users_select.js217
-rw-r--r--app/assets/javascripts/version_check_image.js3
-rw-r--r--app/assets/javascripts/visibility_select.js40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue144
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue170
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js116
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js106
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue146
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js147
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js95
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue110
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue43
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue55
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue61
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js76
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue105
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js116
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue147
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js130
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue192
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue35
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue62
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js269
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue138
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue47
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js132
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js82
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js27
-rw-r--r--app/assets/javascripts/vue_shared/ci_action_icons.js21
-rw-r--r--app/assets/javascripts/vue_shared/ci_status_icons.js43
-rw-r--r--app/assets/javascripts/vue_shared/common_vue.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue94
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js589
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue187
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue85
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_icon.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue178
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue175
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue122
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.js115
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue77
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue73
-rw-r--r--app/assets/javascripts/vue_shared/components/panel_resizer.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue82
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar/image.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue86
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue111
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue174
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue78
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue93
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue63
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue127
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue255
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue89
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue4
-rw-r--r--app/assets/javascripts/vue_shared/directives/popover.js22
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js2
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js42
-rw-r--r--app/assets/javascripts/vue_shared/mixins/issuable.js14
-rw-r--r--app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js36
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js6
-rw-r--r--app/assets/javascripts/vue_shared/models/label.js13
-rw-r--r--app/assets/javascripts/vue_shared/translate.js2
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js20
-rw-r--r--app/assets/javascripts/webpack.js9
-rw-r--r--app/assets/javascripts/wikis.js69
-rw-r--r--app/assets/javascripts/zen_mode.js123
-rw-r--r--app/assets/stylesheets/application.scss2
-rw-r--r--app/assets/stylesheets/framework.scss102
-rw-r--r--app/assets/stylesheets/framework/animations.scss90
-rw-r--r--app/assets/stylesheets/framework/avatar.scss25
-rw-r--r--app/assets/stylesheets/framework/awards.scss20
-rw-r--r--app/assets/stylesheets/framework/badges.scss2
-rw-r--r--app/assets/stylesheets/framework/banner.scss25
-rw-r--r--app/assets/stylesheets/framework/blank.scss110
-rw-r--r--app/assets/stylesheets/framework/blocks.scss56
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss (renamed from app/assets/stylesheets/framework/broadcast-messages.scss)0
-rw-r--r--app/assets/stylesheets/framework/buttons.scss161
-rw-r--r--app/assets/stylesheets/framework/calendar.scss13
-rw-r--r--app/assets/stylesheets/framework/callout.scss14
-rw-r--r--app/assets/stylesheets/framework/ci_variable_list.scss99
-rw-r--r--app/assets/stylesheets/framework/common.scss188
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss457
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss510
-rw-r--r--app/assets/stylesheets/framework/emoji-sprites.scss1811
-rw-r--r--app/assets/stylesheets/framework/emoji_sprites.scss1813
-rw-r--r--app/assets/stylesheets/framework/emojis.scss1
-rw-r--r--app/assets/stylesheets/framework/feature_highlight.scss103
-rw-r--r--app/assets/stylesheets/framework/files.scss99
-rw-r--r--app/assets/stylesheets/framework/filters.scss109
-rw-r--r--app/assets/stylesheets/framework/flash.scss20
-rw-r--r--app/assets/stylesheets/framework/forms.scss9
-rw-r--r--app/assets/stylesheets/framework/gfm.scss38
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss274
-rw-r--r--app/assets/stylesheets/framework/header.scss630
-rw-r--r--app/assets/stylesheets/framework/highlight.scss8
-rw-r--r--app/assets/stylesheets/framework/icons.scss20
-rw-r--r--app/assets/stylesheets/framework/images.scss62
-rw-r--r--app/assets/stylesheets/framework/issue_box.scss6
-rw-r--r--app/assets/stylesheets/framework/layout.scss67
-rw-r--r--app/assets/stylesheets/framework/lists.scss190
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss113
-rw-r--r--app/assets/stylesheets/framework/media_object.scss12
-rw-r--r--app/assets/stylesheets/framework/mixins.scss86
-rw-r--r--app/assets/stylesheets/framework/mobile.scss21
-rw-r--r--app/assets/stylesheets/framework/modal.scss85
-rw-r--r--app/assets/stylesheets/framework/nav.scss528
-rw-r--r--app/assets/stylesheets/framework/page-header.scss66
-rw-r--r--app/assets/stylesheets/framework/page_header.scss66
-rw-r--r--app/assets/stylesheets/framework/popup.scss15
-rw-r--r--app/assets/stylesheets/framework/responsive-tables.scss137
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss165
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss379
-rw-r--r--app/assets/stylesheets/framework/selects.scss168
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss56
-rw-r--r--app/assets/stylesheets/framework/snippets.scss2
-rw-r--r--app/assets/stylesheets/framework/stacked_progress_bar.scss54
-rw-r--r--app/assets/stylesheets/framework/tables.scss4
-rw-r--r--app/assets/stylesheets/framework/tabs.scss35
-rw-r--r--app/assets/stylesheets/framework/timeline.scss8
-rw-r--r--app/assets/stylesheets/framework/toggle.scss142
-rw-r--r--app/assets/stylesheets/framework/tooltips.scss7
-rw-r--r--app/assets/stylesheets/framework/tw_bootstrap.scss31
-rw-r--r--app/assets/stylesheets/framework/tw_bootstrap_variables.scss37
-rw-r--r--app/assets/stylesheets/framework/typography.scss50
-rw-r--r--app/assets/stylesheets/framework/variables.scss338
-rw-r--r--app/assets/stylesheets/framework/vue_transitions.scss9
-rw-r--r--app/assets/stylesheets/framework/wells.scss56
-rw-r--r--app/assets/stylesheets/framework/zen.scss2
-rw-r--r--app/assets/stylesheets/highlight/dark.scss8
-rw-r--r--app/assets/stylesheets/highlight/monokai.scss2
-rw-r--r--app/assets/stylesheets/highlight/solarized_dark.scss2
-rw-r--r--app/assets/stylesheets/highlight/solarized_light.scss2
-rw-r--r--app/assets/stylesheets/highlight/white.scss66
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss58
-rw-r--r--app/assets/stylesheets/new_nav.scss267
-rw-r--r--app/assets/stylesheets/pages/admin.scss6
-rw-r--r--app/assets/stylesheets/pages/boards.scss144
-rw-r--r--app/assets/stylesheets/pages/branches.scss21
-rw-r--r--app/assets/stylesheets/pages/builds.scss191
-rw-r--r--app/assets/stylesheets/pages/ci_projects.scss4
-rw-r--r--app/assets/stylesheets/pages/clusters.scss28
-rw-r--r--app/assets/stylesheets/pages/commits.scss108
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss8
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss12
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss75
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss34
-rw-r--r--app/assets/stylesheets/pages/diff.scss341
-rw-r--r--app/assets/stylesheets/pages/editor.scss8
-rw-r--r--app/assets/stylesheets/pages/environments.scss248
-rw-r--r--app/assets/stylesheets/pages/events.scss25
-rw-r--r--app/assets/stylesheets/pages/groups.scss140
-rw-r--r--app/assets/stylesheets/pages/help.scss20
-rw-r--r--app/assets/stylesheets/pages/issuable.scss357
-rw-r--r--app/assets/stylesheets/pages/issues.scss105
-rw-r--r--app/assets/stylesheets/pages/labels.scss66
-rw-r--r--app/assets/stylesheets/pages/login.scss116
-rw-r--r--app/assets/stylesheets/pages/members.scss159
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss4
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss392
-rw-r--r--app/assets/stylesheets/pages/milestone.scss44
-rw-r--r--app/assets/stylesheets/pages/note_form.scss167
-rw-r--r--app/assets/stylesheets/pages/notes.scss365
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss8
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss473
-rw-r--r--app/assets/stylesheets/pages/profile.scss58
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss80
-rw-r--r--app/assets/stylesheets/pages/projects.scss624
-rw-r--r--app/assets/stylesheets/pages/repo.scss786
-rw-r--r--app/assets/stylesheets/pages/runners.scss26
-rw-r--r--app/assets/stylesheets/pages/search.scss110
-rw-r--r--app/assets/stylesheets/pages/settings.scss119
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss4
-rw-r--r--app/assets/stylesheets/pages/sherlock.scss16
-rw-r--r--app/assets/stylesheets/pages/stat_graph.scss19
-rw-r--r--app/assets/stylesheets/pages/status.scss31
-rw-r--r--app/assets/stylesheets/pages/todos.scss11
-rw-r--r--app/assets/stylesheets/pages/tree.scss45
-rw-r--r--app/assets/stylesheets/pages/ui_dev_kit.scss10
-rw-r--r--app/assets/stylesheets/pages/wiki.scss43
-rw-r--r--app/assets/stylesheets/pages/xterm.scss31
-rw-r--r--app/assets/stylesheets/performance_bar.scss132
-rw-r--r--app/assets/stylesheets/print.scss4
-rw-r--r--app/assets/stylesheets/test.scss11
-rw-r--r--app/controllers/abuse_reports_controller.rb14
-rw-r--r--app/controllers/admin/appearances_controller.rb8
-rw-r--r--app/controllers/admin/application_settings_controller.rb84
-rw-r--r--app/controllers/admin/applications_controller.rb7
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb7
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/dashboard_controller.rb6
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb9
-rw-r--r--app/controllers/admin/gitaly_servers_controller.rb5
-rw-r--r--app/controllers/admin/groups_controller.rb10
-rw-r--r--app/controllers/admin/health_check_controller.rb7
-rw-r--r--app/controllers/admin/hook_logs_controller.rb4
-rw-r--r--app/controllers/admin/hooks_controller.rb21
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb3
-rw-r--r--app/controllers/admin/jobs_controller.rb2
-rw-r--r--app/controllers/admin/labels_controller.rb2
-rw-r--r--app/controllers/admin/logs_controller.rb16
-rw-r--r--app/controllers/admin/projects_controller.rb28
-rw-r--r--app/controllers/admin/runners_controller.rb4
-rw-r--r--app/controllers/admin/services_controller.rb5
-rw-r--r--app/controllers/admin/users_controller.rb24
-rw-r--r--app/controllers/application_controller.rb140
-rw-r--r--app/controllers/autocomplete_controller.rb75
-rw-r--r--app/controllers/boards/application_controller.rb21
-rw-r--r--app/controllers/boards/issues_controller.rb113
-rw-r--r--app/controllers/boards/lists_controller.rb75
-rw-r--r--app/controllers/ci/lints_controller.rb9
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb5
-rw-r--r--app/controllers/concerns/boards_responses.rb82
-rw-r--r--app/controllers/concerns/controller_with_cross_project_access_check.rb24
-rw-r--r--app/controllers/concerns/creates_commit.rb37
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb9
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb6
-rw-r--r--app/controllers/concerns/group_tree.rb31
-rw-r--r--app/controllers/concerns/hooks_execution.rb13
-rw-r--r--app/controllers/concerns/issuable_actions.rb104
-rw-r--r--app/controllers/concerns/issuable_collections.rb164
-rw-r--r--app/controllers/concerns/issues_action.rb16
-rw-r--r--app/controllers/concerns/lfs_request.rb20
-rw-r--r--app/controllers/concerns/members_presentation.rb11
-rw-r--r--app/controllers/concerns/membership_actions.rb66
-rw-r--r--app/controllers/concerns/merge_requests_action.rb15
-rw-r--r--app/controllers/concerns/milestone_actions.rb10
-rw-r--r--app/controllers/concerns/notes_actions.rb116
-rw-r--r--app/controllers/concerns/oauth_applications.rb2
-rw-r--r--app/controllers/concerns/preview_markdown.rb25
-rw-r--r--app/controllers/concerns/renders_blob.rb14
-rw-r--r--app/controllers/concerns/renders_commits.rb7
-rw-r--r--app/controllers/concerns/renders_member_access.rb23
-rw-r--r--app/controllers/concerns/renders_notes.rb13
-rw-r--r--app/controllers/concerns/repository_settings_redirect.rb2
-rw-r--r--app/controllers/concerns/requires_health_token.rb25
-rw-r--r--app/controllers/concerns/requires_whitelisted_monitoring_client.rb34
-rw-r--r--app/controllers/concerns/routable_actions.rb9
-rw-r--r--app/controllers/concerns/service_params.rb3
-rw-r--r--app/controllers/concerns/snippets_actions.rb2
-rw-r--r--app/controllers/concerns/spammable_actions.rb34
-rw-r--r--app/controllers/concerns/toggle_subscription_action.rb2
-rw-r--r--app/controllers/concerns/uploads_actions.rb73
-rw-r--r--app/controllers/concerns/with_performance_bar.rb27
-rw-r--r--app/controllers/confirmations_controller.rb12
-rw-r--r--app/controllers/dashboard/application_controller.rb4
-rw-r--r--app/controllers/dashboard/groups_controller.rb31
-rw-r--r--app/controllers/dashboard/labels_controller.rb9
-rw-r--r--app/controllers/dashboard/projects_controller.rb20
-rw-r--r--app/controllers/dashboard/snippets_controller.rb2
-rw-r--r--app/controllers/dashboard/todos_controller.rb44
-rw-r--r--app/controllers/dashboard_controller.rb8
-rw-r--r--app/controllers/explore/groups_controller.rb16
-rw-r--r--app/controllers/explore/projects_controller.rb16
-rw-r--r--app/controllers/google_api/authorizations_controller.rb29
-rw-r--r--app/controllers/groups/application_controller.rb8
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb27
-rw-r--r--app/controllers/groups/children_controller.rb40
-rw-r--r--app/controllers/groups/group_members_controller.rb38
-rw-r--r--app/controllers/groups/labels_controller.rb27
-rw-r--r--app/controllers/groups/milestones_controller.rb83
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb25
-rw-r--r--app/controllers/groups/uploads_controller.rb29
-rw-r--r--app/controllers/groups/variables_controller.rb49
-rw-r--r--app/controllers/groups_controller.rb91
-rw-r--r--app/controllers/health_check_controller.rb2
-rw-r--r--app/controllers/health_controller.rb21
-rw-r--r--app/controllers/help_controller.rb14
-rw-r--r--app/controllers/ide_controller.rb6
-rw-r--r--app/controllers/import/base_controller.rb24
-rw-r--r--app/controllers/import/bitbucket_controller.rb24
-rw-r--r--app/controllers/import/fogbugz_controller.rb16
-rw-r--r--app/controllers/import/github_controller.rb41
-rw-r--r--app/controllers/import/gitlab_controller.rb20
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb16
-rw-r--r--app/controllers/import/google_code_controller.rb16
-rw-r--r--app/controllers/invites_controller.rb6
-rw-r--r--app/controllers/jwt_controller.rb6
-rw-r--r--app/controllers/koding_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb19
-rw-r--r--app/controllers/oauth/applications_controller.rb19
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb97
-rw-r--r--app/controllers/passwords_controller.rb16
-rw-r--r--app/controllers/profiles/avatars_controller.rb2
-rw-r--r--app/controllers/profiles/emails_controller.rb31
-rw-r--r--app/controllers/profiles/gpg_keys_controller.rb47
-rw-r--r--app/controllers/profiles/keys_controller.rb6
-rw-r--r--app/controllers/profiles/notifications_controller.rb2
-rw-r--r--app/controllers/profiles/passwords_controller.rb9
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb8
-rw-r--r--app/controllers/profiles/preferences_controller.rb5
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb6
-rw-r--r--app/controllers/profiles_controller.rb24
-rw-r--r--app/controllers/projects/application_controller.rb33
-rw-r--r--app/controllers/projects/artifacts_controller.rb32
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb20
-rw-r--r--app/controllers/projects/badges_controller.rb6
-rw-r--r--app/controllers/projects/blob_controller.rb79
-rw-r--r--app/controllers/projects/boards/application_controller.rb15
-rw-r--r--app/controllers/projects/boards/issues_controller.rb93
-rw-r--r--app/controllers/projects/boards/lists_controller.rb86
-rw-r--r--app/controllers/projects/boards_controller.rb28
-rw-r--r--app/controllers/projects/branches_controller.rb74
-rw-r--r--app/controllers/projects/build_artifacts_controller.rb10
-rw-r--r--app/controllers/projects/builds_controller.rb6
-rw-r--r--app/controllers/projects/clusters/applications_controller.rb25
-rw-r--r--app/controllers/projects/clusters/gcp_controller.rb102
-rw-r--r--app/controllers/projects/clusters/user_controller.rb40
-rw-r--r--app/controllers/projects/clusters_controller.rb122
-rw-r--r--app/controllers/projects/commit_controller.rb66
-rw-r--r--app/controllers/projects/commits_controller.rb53
-rw-r--r--app/controllers/projects/compare_controller.rb11
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb24
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb5
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb12
-rw-r--r--app/controllers/projects/deployments_controller.rb3
-rw-r--r--app/controllers/projects/discussions_controller.rb42
-rw-r--r--app/controllers/projects/environments_controller.rb9
-rw-r--r--app/controllers/projects/forks_controller.rb17
-rw-r--r--app/controllers/projects/git_http_client_controller.rb7
-rw-r--r--app/controllers/projects/git_http_controller.rb15
-rw-r--r--app/controllers/projects/graphs_controller.rb20
-rw-r--r--app/controllers/projects/group_links_controller.rb18
-rw-r--r--app/controllers/projects/hook_logs_controller.rb6
-rw-r--r--app/controllers/projects/hooks_controller.rb29
-rw-r--r--app/controllers/projects/imports_controller.rb12
-rw-r--r--app/controllers/projects/issues_controller.rb163
-rw-r--r--app/controllers/projects/jobs_controller.rb19
-rw-r--r--app/controllers/projects/labels_controller.rb24
-rw-r--r--app/controllers/projects/lfs_api_controller.rb18
-rw-r--r--app/controllers/projects/lfs_locks_api_controller.rb70
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb3
-rw-r--r--app/controllers/projects/mattermosts_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb41
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb66
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb133
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb80
-rw-r--r--app/controllers/projects/merge_requests_controller.rb559
-rw-r--r--app/controllers/projects/milestones_controller.rb59
-rw-r--r--app/controllers/projects/network_controller.rb11
-rw-r--r--app/controllers/projects/notes_controller.rb47
-rw-r--r--app/controllers/projects/pages_controller.rb24
-rw-r--r--app/controllers/projects/pages_domains_controller.rb47
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb51
-rw-r--r--app/controllers/projects/pipelines_controller.rb22
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb35
-rw-r--r--app/controllers/projects/project_members_controller.rb44
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb32
-rw-r--r--app/controllers/projects/prometheus_controller.rb24
-rw-r--r--app/controllers/projects/refs_controller.rb43
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb21
-rw-r--r--app/controllers/projects/registry/tags_controller.rb27
-rw-r--r--app/controllers/projects/releases_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb12
-rw-r--r--app/controllers/projects/services_controller.rb42
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb24
-rw-r--r--app/controllers/projects/settings/members_controller.rb27
-rw-r--r--app/controllers/projects/settings/repository_controller.rb10
-rw-r--r--app/controllers/projects/snippets_controller.rb10
-rw-r--r--app/controllers/projects/tags_controller.rb6
-rw-r--r--app/controllers/projects/tree_controller.rb29
-rw-r--r--app/controllers/projects/triggers_controller.rb13
-rw-r--r--app/controllers/projects/uploads_controller.rb33
-rw-r--r--app/controllers/projects/variables_controller.rb53
-rw-r--r--app/controllers/projects/wikis_controller.rb51
-rw-r--r--app/controllers/projects_controller.rb125
-rw-r--r--app/controllers/registrations_controller.rb39
-rw-r--r--app/controllers/root_controller.rb11
-rw-r--r--app/controllers/search_controller.rb18
-rw-r--r--app/controllers/sessions_controller.rb67
-rw-r--r--app/controllers/snippets/notes_controller.rb1
-rw-r--r--app/controllers/snippets_controller.rb18
-rw-r--r--app/controllers/unicorn_test_controller.rb12
-rw-r--r--app/controllers/uploads_controller.rb75
-rw-r--r--app/controllers/user_callouts_controller.rb23
-rw-r--r--app/controllers/users_controller.rb34
-rw-r--r--app/finders/admin/projects_finder.rb67
-rw-r--r--app/finders/autocomplete_users_finder.rb68
-rw-r--r--app/finders/branches_finder.rb4
-rw-r--r--app/finders/clusters_finder.rb29
-rw-r--r--app/finders/concerns/created_at_filter.rb8
-rw-r--r--app/finders/concerns/custom_attributes_filter.rb20
-rw-r--r--app/finders/concerns/finder_methods.rb51
-rw-r--r--app/finders/concerns/finder_with_cross_project_access.rb70
-rw-r--r--app/finders/events_finder.rb4
-rw-r--r--app/finders/fork_projects_finder.rb6
-rw-r--r--app/finders/group_descendants_finder.rb171
-rw-r--r--app/finders/group_projects_finder.rb11
-rw-r--r--app/finders/groups_finder.rb44
-rw-r--r--app/finders/issuable_finder.rb163
-rw-r--r--app/finders/issues_finder.rb112
-rw-r--r--app/finders/labels_finder.rb47
-rw-r--r--app/finders/members_finder.rb57
-rw-r--r--app/finders/merge_request_target_project_finder.rb20
-rw-r--r--app/finders/merge_requests_finder.rb34
-rw-r--r--app/finders/milestones_finder.rb58
-rw-r--r--app/finders/move_to_project_finder.rb1
-rw-r--r--app/finders/notes_finder.rb17
-rw-r--r--app/finders/personal_access_tokens_finder.rb1
-rw-r--r--app/finders/projects_finder.rb31
-rw-r--r--app/finders/runner_jobs_finder.rb22
-rw-r--r--app/finders/snippets_finder.rb101
-rw-r--r--app/finders/todos_finder.rb29
-rw-r--r--app/finders/user_recent_events_finder.rb64
-rw-r--r--app/finders/users_finder.rb11
-rw-r--r--app/helpers/appearances_helper.rb29
-rw-r--r--app/helpers/application_helper.rb70
-rw-r--r--app/helpers/application_settings_helper.rb198
-rw-r--r--app/helpers/auth_helper.rb8
-rw-r--r--app/helpers/auto_devops_helper.rb36
-rw-r--r--app/helpers/avatars_helper.rb42
-rw-r--r--app/helpers/award_emoji_helper.rb2
-rw-r--r--app/helpers/blame_helper.rb16
-rw-r--r--app/helpers/blob_helper.rb145
-rw-r--r--app/helpers/boards_helper.rb84
-rw-r--r--app/helpers/branches_helper.rb25
-rw-r--r--app/helpers/breadcrumbs_helper.rb33
-rw-r--r--app/helpers/builds_helper.rb7
-rw-r--r--app/helpers/button_helper.rb76
-rw-r--r--app/helpers/ci_status_helper.rb41
-rw-r--r--app/helpers/clusters_helper.rb5
-rw-r--r--app/helpers/commits_helper.rb71
-rw-r--r--app/helpers/compare_helper.rb7
-rw-r--r--app/helpers/dashboard_helper.rb24
-rw-r--r--app/helpers/defer_script_tag_helper.rb6
-rw-r--r--app/helpers/diff_helper.rb79
-rw-r--r--app/helpers/dropdowns_helper.rb25
-rw-r--r--app/helpers/emails_helper.rb21
-rw-r--r--app/helpers/environment_helper.rb2
-rw-r--r--app/helpers/environments_helper.rb2
-rw-r--r--app/helpers/events_helper.rb62
-rw-r--r--app/helpers/explore_helper.rb16
-rw-r--r--app/helpers/external_wiki_helper.rb2
-rw-r--r--app/helpers/form_helper.rb22
-rw-r--r--app/helpers/gitlab_routing_helper.rb160
-rw-r--r--app/helpers/graph_helper.rb11
-rw-r--r--app/helpers/groups_helper.rb159
-rw-r--r--app/helpers/hooks_helper.rb17
-rw-r--r--app/helpers/icons_helper.rb27
-rw-r--r--app/helpers/import_helper.rb85
-rw-r--r--app/helpers/instance_configuration_helper.rb18
-rw-r--r--app/helpers/issuables_helper.rb225
-rw-r--r--app/helpers/issues_helper.rb70
-rw-r--r--app/helpers/javascript_helper.rb5
-rw-r--r--app/helpers/labels_helper.rb62
-rw-r--r--app/helpers/lazy_image_tag_helper.rb25
-rw-r--r--app/helpers/markup_helper.rb66
-rw-r--r--app/helpers/members_helper.rb7
-rw-r--r--app/helpers/merge_requests_helper.rb53
-rw-r--r--app/helpers/milestones_helper.rb47
-rw-r--r--app/helpers/milestones_routing_helper.rb17
-rw-r--r--app/helpers/namespaces_helper.rb44
-rw-r--r--app/helpers/nav_helper.rb81
-rw-r--r--app/helpers/notes_helper.rb90
-rw-r--r--app/helpers/notifications_helper.rb1
-rw-r--r--app/helpers/numbers_helper.rb11
-rw-r--r--app/helpers/page_layout_helper.rb10
-rw-r--r--app/helpers/pagination_helper.rb21
-rw-r--r--app/helpers/performance_bar_helper.rb7
-rw-r--r--app/helpers/pipeline_schedules_helper.rb10
-rw-r--r--app/helpers/preferences_helper.rb33
-rw-r--r--app/helpers/profiles_helper.rb13
-rw-r--r--app/helpers/projects_helper.rb341
-rw-r--r--app/helpers/search_helper.rb66
-rw-r--r--app/helpers/selects_helper.rb1
-rw-r--r--app/helpers/services_helper.rb34
-rw-r--r--app/helpers/sidekiq_helper.rb6
-rw-r--r--app/helpers/snippets_helper.rb8
-rw-r--r--app/helpers/sorting_helper.rb323
-rw-r--r--app/helpers/storage_health_helper.rb32
-rw-r--r--app/helpers/submodule_helper.rb28
-rw-r--r--app/helpers/system_note_helper.rb51
-rw-r--r--app/helpers/tab_helper.rb3
-rw-r--r--app/helpers/tags_helper.rb2
-rw-r--r--app/helpers/todos_helper.rb15
-rw-r--r--app/helpers/tree_helper.rb45
-rw-r--r--app/helpers/triggers_helper.rb2
-rw-r--r--app/helpers/u2f_helper.rb5
-rw-r--r--app/helpers/user_callouts_helper.rb14
-rw-r--r--app/helpers/users_helper.rb14
-rw-r--r--app/helpers/version_check_helper.rb2
-rw-r--r--app/helpers/visibility_level_helper.rb83
-rw-r--r--app/helpers/webpack_helper.rb58
-rw-r--r--app/helpers/wiki_helper.rb29
-rw-r--r--app/mailers/abuse_report_mailer.rb6
-rw-r--r--app/mailers/emails/issues.rb37
-rw-r--r--app/mailers/emails/members.rb11
-rw-r--r--app/mailers/emails/merge_requests.rb41
-rw-r--r--app/mailers/emails/notes.rb20
-rw-r--r--app/mailers/emails/pages_domains.rb43
-rw-r--r--app/mailers/emails/profile.rb18
-rw-r--r--app/mailers/emails/projects.rb2
-rw-r--r--app/mailers/notify.rb24
-rw-r--r--app/models/ability.rb94
-rw-r--r--app/models/appearance.rb26
-rw-r--r--app/models/application_setting.rb175
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/award_emoji.rb7
-rw-r--r--app/models/badge.rb51
-rw-r--r--app/models/badges/group_badge.rb5
-rw-r--r--app/models/badges/project_badge.rb15
-rw-r--r--app/models/blob.rb29
-rw-r--r--app/models/blob_viewer/base.rb2
-rw-r--r--app/models/blob_viewer/composer_json.rb2
-rw-r--r--app/models/blob_viewer/dependency_manager.rb13
-rw-r--r--app/models/blob_viewer/gitlab_ci_yml.rb2
-rw-r--r--app/models/blob_viewer/notebook.rb2
-rw-r--r--app/models/blob_viewer/package_json.rb18
-rw-r--r--app/models/blob_viewer/readme.rb6
-rw-r--r--app/models/blob_viewer/server_side.rb2
-rw-r--r--app/models/board.rb18
-rw-r--r--app/models/broadcast_message.rb38
-rw-r--r--app/models/chat_name.rb21
-rw-r--r--app/models/chat_team.rb9
-rw-r--r--app/models/ci/artifact_blob.rb31
-rw-r--r--app/models/ci/build.rb287
-rw-r--r--app/models/ci/build_trace_section.rb11
-rw-r--r--app/models/ci/build_trace_section_name.rb11
-rw-r--r--app/models/ci/group_variable.rb18
-rw-r--r--app/models/ci/job_artifact.rb39
-rw-r--r--app/models/ci/pipeline.rb203
-rw-r--r--app/models/ci/pipeline_schedule.rb17
-rw-r--r--app/models/ci/pipeline_schedule_variable.rb12
-rw-r--r--app/models/ci/pipeline_variable.rb10
-rw-r--r--app/models/ci/runner.rb74
-rw-r--r--app/models/ci/runner_project.rb2
-rw-r--r--app/models/ci/stage.rb65
-rw-r--r--app/models/ci/trigger.rb5
-rw-r--r--app/models/ci/trigger_request.rb8
-rw-r--r--app/models/ci/variable.rb27
-rw-r--r--app/models/clusters/applications/helm.rb22
-rw-r--r--app/models/clusters/applications/ingress.rb49
-rw-r--r--app/models/clusters/applications/prometheus.rb61
-rw-r--r--app/models/clusters/applications/runner.rb69
-rw-r--r--app/models/clusters/cluster.rb109
-rw-r--r--app/models/clusters/concerns/application_core.rb34
-rw-r--r--app/models/clusters/concerns/application_data.rb23
-rw-r--r--app/models/clusters/concerns/application_status.rb43
-rw-r--r--app/models/clusters/platforms/kubernetes.rb191
-rw-r--r--app/models/clusters/project.rb8
-rw-r--r--app/models/clusters/providers/gcp.rb80
-rw-r--r--app/models/commit.rb186
-rw-r--r--app/models/commit_collection.rb44
-rw-r--r--app/models/commit_range.rb8
-rw-r--r--app/models/commit_status.rb49
-rw-r--r--app/models/compare.rb39
-rw-r--r--app/models/concerns/access_requestable.rb2
-rw-r--r--app/models/concerns/artifact_migratable.rb44
-rw-r--r--app/models/concerns/atomic_internal_id.rb46
-rw-r--r--app/models/concerns/avatarable.rb49
-rw-r--r--app/models/concerns/awardable.rb18
-rw-r--r--app/models/concerns/blocks_json_serialization.rb16
-rw-r--r--app/models/concerns/bulk_member_access_load.rb46
-rw-r--r--app/models/concerns/cache_markdown_field.rb26
-rw-r--r--app/models/concerns/created_at_filterable.rb12
-rw-r--r--app/models/concerns/deployment_platform.rb49
-rw-r--r--app/models/concerns/discussion_on_diff.rb20
-rw-r--r--app/models/concerns/each_batch.rb81
-rw-r--r--app/models/concerns/editable.rb6
-rw-r--r--app/models/concerns/feature_gate.rb7
-rw-r--r--app/models/concerns/group_descendant.rb56
-rw-r--r--app/models/concerns/has_status.rb3
-rw-r--r--app/models/concerns/has_variable.rb27
-rw-r--r--app/models/concerns/ignorable_column.rb4
-rw-r--r--app/models/concerns/internal_id.rb22
-rw-r--r--app/models/concerns/issuable.rb124
-rw-r--r--app/models/concerns/loaded_in_group_list.rb73
-rw-r--r--app/models/concerns/manual_inverse_association.rb17
-rw-r--r--app/models/concerns/mentionable.rb18
-rw-r--r--app/models/concerns/mentionable/reference_regexes.rb2
-rw-r--r--app/models/concerns/milestoneish.rb40
-rw-r--r--app/models/concerns/nonatomic_internal_id.rb22
-rw-r--r--app/models/concerns/note_on_diff.rb4
-rw-r--r--app/models/concerns/noteable.rb12
-rw-r--r--app/models/concerns/participable.rb14
-rw-r--r--app/models/concerns/project_features_compatibility.rb3
-rw-r--r--app/models/concerns/prometheus_adapter.rb48
-rw-r--r--app/models/concerns/protected_branch_access.rb16
-rw-r--r--app/models/concerns/protected_ref.rb17
-rw-r--r--app/models/concerns/protected_ref_access.rb27
-rw-r--r--app/models/concerns/redis_cacheable.rb41
-rw-r--r--app/models/concerns/referable.rb21
-rw-r--r--app/models/concerns/relative_positioning.rb26
-rw-r--r--app/models/concerns/repository_mirroring.rb17
-rw-r--r--app/models/concerns/resolvable_discussion.rb32
-rw-r--r--app/models/concerns/resolvable_note.rb28
-rw-r--r--app/models/concerns/routable.rb38
-rw-r--r--app/models/concerns/sha_attribute.rb21
-rw-r--r--app/models/concerns/sortable.rb19
-rw-r--r--app/models/concerns/spammable.rb15
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb115
-rw-r--r--app/models/concerns/storage/legacy_project_wiki.rb9
-rw-r--r--app/models/concerns/storage/legacy_repository.rb7
-rw-r--r--app/models/concerns/subscribable.rb4
-rw-r--r--app/models/concerns/taskable.rb6
-rw-r--r--app/models/concerns/throttled_touch.rb10
-rw-r--r--app/models/concerns/time_trackable.rb31
-rw-r--r--app/models/concerns/token_authenticatable.rb7
-rw-r--r--app/models/concerns/triggerable_hooks.rb40
-rw-r--r--app/models/concerns/updated_at_filterable.rb12
-rw-r--r--app/models/conversational_development_index/metric.rb4
-rw-r--r--app/models/cycle_analytics.rb6
-rw-r--r--app/models/cycle_analytics/summary.rb0
-rw-r--r--app/models/dashboard_milestone.rb4
-rw-r--r--app/models/deploy_key.rb20
-rw-r--r--app/models/deploy_keys_project.rb12
-rw-r--r--app/models/deployment.rb32
-rw-r--r--app/models/diff_discussion.rb20
-rw-r--r--app/models/diff_note.rb36
-rw-r--r--app/models/discussion.rb9
-rw-r--r--app/models/email.rb15
-rw-r--r--app/models/environment.rb63
-rw-r--r--app/models/epic.rb11
-rw-r--r--app/models/event.rb188
-rw-r--r--app/models/event_collection.rb98
-rw-r--r--app/models/external_issue.rb15
-rw-r--r--app/models/fork_network.rb19
-rw-r--r--app/models/fork_network_member.rb17
-rw-r--r--app/models/forked_project_link.rb4
-rw-r--r--app/models/global_label.rb2
-rw-r--r--app/models/global_milestone.rb47
-rw-r--r--app/models/gpg_key.rb127
-rw-r--r--app/models/gpg_key_subkey.rb22
-rw-r--r--app/models/gpg_signature.rb67
-rw-r--r--app/models/group.rb170
-rw-r--r--app/models/group_custom_attribute.rb6
-rw-r--r--app/models/group_milestone.rb4
-rw-r--r--app/models/hooks/project_hook.rb23
-rw-r--r--app/models/hooks/service_hook.rb1
-rw-r--r--app/models/hooks/system_hook.rb10
-rw-r--r--app/models/hooks/web_hook.rb17
-rw-r--r--app/models/hooks/web_hook_log.rb6
-rw-r--r--app/models/identity.rb37
-rw-r--r--app/models/instance_configuration.rb71
-rw-r--r--app/models/internal_id.rb125
-rw-r--r--app/models/issue.rb86
-rw-r--r--app/models/issue_assignee.rb2
-rw-r--r--app/models/key.rb56
-rw-r--r--app/models/label.rb23
-rw-r--r--app/models/legacy_diff_discussion.rb8
-rw-r--r--app/models/legacy_diff_note.rb8
-rw-r--r--app/models/lfs_file_lock.rb12
-rw-r--r--app/models/lfs_object.rb16
-rw-r--r--app/models/member.rb105
-rw-r--r--app/models/members/group_member.rb4
-rw-r--r--app/models/members/project_member.rb10
-rw-r--r--app/models/merge_request.rb464
-rw-r--r--app/models/merge_request/metrics.rb10
-rw-r--r--app/models/merge_request_diff.rb188
-rw-r--r--app/models/merge_request_diff_commit.rb40
-rw-r--r--app/models/merge_request_diff_file.rb10
-rw-r--r--app/models/milestone.rb105
-rw-r--r--app/models/namespace.rb210
-rw-r--r--app/models/network/commit.rb9
-rw-r--r--app/models/network/graph.rb9
-rw-r--r--app/models/note.rb203
-rw-r--r--app/models/notification_reason.rb19
-rw-r--r--app/models/notification_recipient.rb142
-rw-r--r--app/models/notification_setting.rb36
-rw-r--r--app/models/oauth_access_token.rb10
-rw-r--r--app/models/pages_domain.rb92
-rw-r--r--app/models/personal_access_token.rb31
-rw-r--r--app/models/project.rb1187
-rw-r--r--app/models/project_auto_devops.rb25
-rw-r--r--app/models/project_custom_attribute.rb6
-rw-r--r--app/models/project_feature.rb11
-rw-r--r--app/models/project_import_data.rb4
-rw-r--r--app/models/project_services/asana_service.rb4
-rw-r--r--app/models/project_services/assembla_service.rb4
-rw-r--r--app/models/project_services/bamboo_service.rb12
-rw-r--r--app/models/project_services/buildkite_service.rb2
-rw-r--r--app/models/project_services/campfire_service.rb9
-rw-r--r--app/models/project_services/chat_message/base_message.rb10
-rw-r--r--app/models/project_services/chat_message/issue_message.rb8
-rw-r--r--app/models/project_services/chat_message/merge_message.rb4
-rw-r--r--app/models/project_services/chat_message/note_message.rb4
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb6
-rw-r--r--app/models/project_services/chat_message/push_message.rb8
-rw-r--r--app/models/project_services/chat_message/wiki_page_message.rb4
-rw-r--r--app/models/project_services/chat_notification_service.rb22
-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.rb4
-rw-r--r--app/models/project_services/flowdock_service.rb6
-rw-r--r--app/models/project_services/gitlab_issue_tracker_service.rb16
-rw-r--r--app/models/project_services/hipchat_service.rb15
-rw-r--r--app/models/project_services/irker_service.rb2
-rw-r--r--app/models/project_services/issue_tracker_service.rb17
-rw-r--r--app/models/project_services/jira_service.rb78
-rw-r--r--app/models/project_services/kubernetes_service.rb108
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb2
-rw-r--r--app/models/project_services/mock_ci_service.rb2
-rw-r--r--app/models/project_services/monitoring_service.rb4
-rw-r--r--app/models/project_services/packagist_service.rb63
-rw-r--r--app/models/project_services/pipelines_email_service.rb8
-rw-r--r--app/models/project_services/pivotaltracker_service.rb4
-rw-r--r--app/models/project_services/prometheus_service.rb84
-rw-r--r--app/models/project_services/pushover_service.rb9
-rw-r--r--app/models/project_services/slash_commands_service.rb8
-rw-r--r--app/models/project_services/teamcity_service.rb12
-rw-r--r--app/models/project_statistics.rb6
-rw-r--r--app/models/project_team.rb78
-rw-r--r--app/models/project_wiki.rb99
-rw-r--r--app/models/protectable_dropdown.rb8
-rw-r--r--app/models/protected_branch.rb8
-rw-r--r--app/models/protected_tag.rb4
-rw-r--r--app/models/protected_tag/create_access_level.rb12
-rw-r--r--app/models/push_event.rb114
-rw-r--r--app/models/push_event_payload.rb22
-rw-r--r--app/models/redirect_route.rb38
-rw-r--r--app/models/repository.rb763
-rw-r--r--app/models/route.rb38
-rw-r--r--app/models/sent_notification.rb8
-rw-r--r--app/models/service.rb77
-rw-r--r--app/models/snippet.rb46
-rw-r--r--app/models/storage/hashed_project.rb42
-rw-r--r--app/models/storage/legacy_project.rb51
-rw-r--r--app/models/system_note_metadata.rb13
-rw-r--r--app/models/todo.rb15
-rw-r--r--app/models/tree.rb20
-rw-r--r--app/models/upload.rb66
-rw-r--r--app/models/user.rb571
-rw-r--r--app/models/user_callout.rb13
-rw-r--r--app/models/user_custom_attribute.rb6
-rw-r--r--app/models/user_interacted_project.rb59
-rw-r--r--app/models/user_synced_attributes_metadata.rb31
-rw-r--r--app/models/wiki_page.rb159
-rw-r--r--app/policies/base_policy.rb134
-rw-r--r--app/policies/ci/build_policy.rb33
-rw-r--r--app/policies/ci/pipeline_policy.rb16
-rw-r--r--app/policies/ci/pipeline_schedule_policy.rb28
-rw-r--r--app/policies/ci/runner_policy.rb15
-rw-r--r--app/policies/ci/trigger_policy.rb21
-rw-r--r--app/policies/clusters/cluster_policy.rb12
-rw-r--r--app/policies/commit_status_policy.rb6
-rw-r--r--app/policies/deploy_key_policy.rb14
-rw-r--r--app/policies/deployment_policy.rb4
-rw-r--r--app/policies/environment_policy.rb16
-rw-r--r--app/policies/external_issue_policy.rb4
-rw-r--r--app/policies/global_policy.rb71
-rw-r--r--app/policies/group_label_policy.rb4
-rw-r--r--app/policies/group_member_policy.rb29
-rw-r--r--app/policies/group_policy.rb126
-rw-r--r--app/policies/issuable_policy.rb42
-rw-r--r--app/policies/issue_policy.rb27
-rw-r--r--app/policies/merge_request_policy.rb2
-rw-r--r--app/policies/namespace_policy.rb17
-rw-r--r--app/policies/nil_policy.rb3
-rw-r--r--app/policies/note_policy.rb33
-rw-r--r--app/policies/personal_snippet_policy.rb41
-rw-r--r--app/policies/project_label_policy.rb4
-rw-r--r--app/policies/project_member_policy.rb26
-rw-r--r--app/policies/project_policy.rb534
-rw-r--r--app/policies/project_snippet_policy.rb64
-rw-r--r--app/policies/user_policy.rb22
-rw-r--r--app/presenters/ci/build_presenter.rb11
-rw-r--r--app/presenters/ci/group_variable_presenter.rb21
-rw-r--r--app/presenters/ci/pipeline_presenter.rb11
-rw-r--r--app/presenters/ci/variable_presenter.rb21
-rw-r--r--app/presenters/clusters/cluster_presenter.rb13
-rw-r--r--app/presenters/group_member_presenter.rb15
-rw-r--r--app/presenters/member_presenter.rb38
-rw-r--r--app/presenters/members_presenter.rb15
-rw-r--r--app/presenters/merge_request_presenter.rb65
-rw-r--r--app/presenters/project_member_presenter.rb15
-rw-r--r--app/presenters/project_presenter.rb338
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb2
-rw-r--r--app/serializers/analytics_build_entity.rb2
-rw-r--r--app/serializers/analytics_issue_entity.rb2
-rw-r--r--app/serializers/analytics_stage_entity.rb3
-rw-r--r--app/serializers/award_emoji_entity.rb4
-rw-r--r--app/serializers/base_serializer.rb7
-rw-r--r--app/serializers/blob_entity.rb13
-rw-r--r--app/serializers/build_action_entity.rb5
-rw-r--r--app/serializers/build_artifact_entity.rb15
-rw-r--r--app/serializers/build_details_entity.rb15
-rw-r--r--app/serializers/cluster_application_entity.rb6
-rw-r--r--app/serializers/cluster_entity.rb7
-rw-r--r--app/serializers/cluster_serializer.rb7
-rw-r--r--app/serializers/commit_entity.rb12
-rw-r--r--app/serializers/concerns/with_pagination.rb22
-rw-r--r--app/serializers/container_repositories_serializer.rb3
-rw-r--r--app/serializers/container_repository_entity.rb25
-rw-r--r--app/serializers/container_tag_entity.rb23
-rw-r--r--app/serializers/container_tags_serializer.rb17
-rw-r--r--app/serializers/deploy_key_entity.rb9
-rw-r--r--app/serializers/deploy_keys_project_entity.rb4
-rw-r--r--app/serializers/deployment_entity.rb5
-rw-r--r--app/serializers/diff_file_entity.rb41
-rw-r--r--app/serializers/discussion_entity.rb48
-rw-r--r--app/serializers/discussion_serializer.rb3
-rw-r--r--app/serializers/environment_entity.rb24
-rw-r--r--app/serializers/environment_serializer.rb18
-rw-r--r--app/serializers/event_entity.rb4
-rw-r--r--app/serializers/group_child_entity.rb97
-rw-r--r--app/serializers/group_child_serializer.rb51
-rw-r--r--app/serializers/group_entity.rb2
-rw-r--r--app/serializers/group_serializer.rb18
-rw-r--r--app/serializers/group_variable_entity.rb7
-rw-r--r--app/serializers/group_variable_serializer.rb3
-rw-r--r--app/serializers/issuable_entity.rb14
-rw-r--r--app/serializers/issuable_sidebar_entity.rb12
-rw-r--r--app/serializers/issue_entity.rb35
-rw-r--r--app/serializers/issue_serializer.rb15
-rw-r--r--app/serializers/issue_sidebar_entity.rb3
-rw-r--r--app/serializers/job_entity.rb4
-rw-r--r--app/serializers/label_entity.rb3
-rw-r--r--app/serializers/lfs_file_lock_entity.rb11
-rw-r--r--app/serializers/lfs_file_lock_serializer.rb3
-rw-r--r--app/serializers/merge_request_basic_entity.rb7
-rw-r--r--app/serializers/merge_request_entity.rb189
-rw-r--r--app/serializers/merge_request_metrics_entity.rb6
-rw-r--r--app/serializers/merge_request_serializer.rb11
-rw-r--r--app/serializers/merge_request_widget_entity.rb241
-rw-r--r--app/serializers/note_attachment_entity.rb5
-rw-r--r--app/serializers/note_entity.rb72
-rw-r--r--app/serializers/note_serializer.rb3
-rw-r--r--app/serializers/note_user_entity.rb3
-rw-r--r--app/serializers/pipeline_entity.rb22
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/serializers/project_entity.rb4
-rw-r--r--app/serializers/project_serializer.rb3
-rw-r--r--app/serializers/runner_entity.rb2
-rw-r--r--app/serializers/stage_entity.rb6
-rw-r--r--app/serializers/submodule_entity.rb23
-rw-r--r--app/serializers/time_trackable_entity.rb11
-rw-r--r--app/serializers/tree_entity.rb13
-rw-r--r--app/serializers/tree_root_entity.rb25
-rw-r--r--app/serializers/tree_serializer.rb3
-rw-r--r--app/serializers/user_serializer.rb3
-rw-r--r--app/serializers/variable_entity.rb7
-rw-r--r--app/serializers/variable_serializer.rb3
-rw-r--r--app/services/access_token_validation_service.rb21
-rw-r--r--app/services/akismet_service.rb8
-rw-r--r--app/services/applications/create_service.rb13
-rw-r--r--app/services/auth/container_registry_authentication_service.rb28
-rw-r--r--app/services/badges/base_service.rb11
-rw-r--r--app/services/badges/build_service.rb12
-rw-r--r--app/services/badges/create_service.rb10
-rw-r--r--app/services/badges/update_service.rb12
-rw-r--r--app/services/base_count_service.rb44
-rw-r--r--app/services/base_renderer.rb7
-rw-r--r--app/services/base_service.rb1
-rw-r--r--app/services/boards/base_service.rb10
-rw-r--r--app/services/boards/create_service.rb21
-rw-r--r--app/services/boards/issues/create_service.rb12
-rw-r--r--app/services/boards/issues/list_service.rb40
-rw-r--r--app/services/boards/issues/move_service.rb22
-rw-r--r--app/services/boards/list_service.rb8
-rw-r--r--app/services/boards/lists/create_service.rb13
-rw-r--r--app/services/boards/lists/destroy_service.rb2
-rw-r--r--app/services/boards/lists/generate_service.rb6
-rw-r--r--app/services/boards/lists/list_service.rb2
-rw-r--r--app/services/boards/lists/move_service.rb2
-rw-r--r--app/services/chat_names/authorize_user_service.rb2
-rw-r--r--app/services/chat_names/find_user_service.rb4
-rw-r--r--app/services/check_gcp_project_billing_service.rb11
-rw-r--r--app/services/ci/create_pipeline_service.rb142
-rw-r--r--app/services/ci/create_trigger_request_service.rb12
-rw-r--r--app/services/ci/ensure_stage_service.rb49
-rw-r--r--app/services/ci/extract_sections_from_build_trace_service.rb30
-rw-r--r--app/services/ci/fetch_kubernetes_token_service.rb73
-rw-r--r--app/services/ci/pipeline_trigger_service.rb46
-rw-r--r--app/services/ci/register_job_service.rb56
-rw-r--r--app/services/ci/retry_build_service.rb8
-rw-r--r--app/services/clusters/applications/base_helm_service.rb29
-rw-r--r--app/services/clusters/applications/check_ingress_ip_address_service.rb36
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb65
-rw-r--r--app/services/clusters/applications/install_service.rb21
-rw-r--r--app/services/clusters/applications/schedule_installation_service.rb22
-rw-r--r--app/services/clusters/create_service.rb35
-rw-r--r--app/services/clusters/gcp/fetch_operation_service.rb16
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb56
-rw-r--r--app/services/clusters/gcp/provision_service.rb47
-rw-r--r--app/services/clusters/gcp/verify_provision_status_service.rb48
-rw-r--r--app/services/clusters/update_service.rb7
-rw-r--r--app/services/commits/change_service.rb7
-rw-r--r--app/services/commits/create_service.rb2
-rw-r--r--app/services/compare_service.rb35
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb16
-rw-r--r--app/services/concerns/update_visibility_level.rb15
-rw-r--r--app/services/concerns/users/new_user_notifier.rb9
-rw-r--r--app/services/create_branch_service.rb2
-rw-r--r--app/services/create_deployment_service.rb1
-rw-r--r--app/services/delete_branch_service.rb2
-rw-r--r--app/services/delete_merged_branches_service.rb5
-rw-r--r--app/services/deploy_keys/create_service.rb7
-rw-r--r--app/services/discussions/update_diff_position_service.rb4
-rw-r--r--app/services/emails/base_service.rb6
-rw-r--r--app/services/emails/confirm_service.rb7
-rw-r--r--app/services/emails/create_service.rb4
-rw-r--r--app/services/emails/destroy_service.rb6
-rw-r--r--app/services/event_create_service.rb18
-rw-r--r--app/services/events/render_service.rb21
-rw-r--r--app/services/files/base_service.rb14
-rw-r--r--app/services/files/create_service.rb12
-rw-r--r--app/services/files/delete_service.rb10
-rw-r--r--app/services/files/multi_service.rb46
-rw-r--r--app/services/files/update_service.rb21
-rw-r--r--app/services/git_hooks_service.rb32
-rw-r--r--app/services/git_operation_service.rb156
-rw-r--r--app/services/git_push_service.rb78
-rw-r--r--app/services/gpg_keys/create_service.rb9
-rw-r--r--app/services/gravatar_service.rb4
-rw-r--r--app/services/groups/create_service.rb38
-rw-r--r--app/services/groups/destroy_service.rb10
-rw-r--r--app/services/groups/nested_create_service.rb53
-rw-r--r--app/services/groups/transfer_service.rb96
-rw-r--r--app/services/groups/update_service.rb27
-rw-r--r--app/services/issuable/common_system_notes_service.rb93
-rw-r--r--app/services/issuable/destroy_service.rb11
-rw-r--r--app/services/issuable_base_service.rb170
-rw-r--r--app/services/issues/base_service.rb26
-rw-r--r--app/services/issues/close_service.rb7
-rw-r--r--app/services/issues/create_service.rb10
-rw-r--r--app/services/issues/duplicate_service.rb24
-rw-r--r--app/services/issues/fetch_referenced_merge_requests_service.rb12
-rw-r--r--app/services/issues/move_service.rb56
-rw-r--r--app/services/issues/reopen_service.rb9
-rw-r--r--app/services/issues/update_service.rb56
-rw-r--r--app/services/keys/base_service.rb14
-rw-r--r--app/services/keys/create_service.rb9
-rw-r--r--app/services/keys/last_used_service.rb35
-rw-r--r--app/services/labels/find_or_create_service.rb22
-rw-r--r--app/services/labels/promote_service.rb13
-rw-r--r--app/services/labels/transfer_service.rb2
-rw-r--r--app/services/lfs/file_transformer.rb66
-rw-r--r--app/services/lfs/lock_file_service.rb39
-rw-r--r--app/services/lfs/locks_finder_service.rb17
-rw-r--r--app/services/lfs/unlock_file_service.rb43
-rw-r--r--app/services/members/approve_access_request_service.rb36
-rw-r--r--app/services/members/authorized_destroy_service.rb60
-rw-r--r--app/services/members/base_service.rb49
-rw-r--r--app/services/members/create_service.rb15
-rw-r--r--app/services/members/destroy_service.rb48
-rw-r--r--app/services/members/request_access_service.rb13
-rw-r--r--app/services/members/update_service.rb16
-rw-r--r--app/services/merge_request_metrics_service.rb19
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb57
-rw-r--r--app/services/merge_requests/build_service.rb102
-rw-r--r--app/services/merge_requests/close_service.rb16
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb12
-rw-r--r--app/services/merge_requests/conflicts/resolve_service.rb48
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb30
-rw-r--r--app/services/merge_requests/create_service.rb62
-rw-r--r--app/services/merge_requests/ff_merge_service.rb24
-rw-r--r--app/services/merge_requests/get_urls_service.rb4
-rw-r--r--app/services/merge_requests/merge_request_diff_cache_service.rb11
-rw-r--r--app/services/merge_requests/merge_service.rb76
-rw-r--r--app/services/merge_requests/merge_when_pipeline_succeeds_service.rb2
-rw-r--r--app/services/merge_requests/post_merge_service.rb14
-rw-r--r--app/services/merge_requests/rebase_service.rb32
-rw-r--r--app/services/merge_requests/refresh_service.rb39
-rw-r--r--app/services/merge_requests/reopen_service.rb18
-rw-r--r--app/services/merge_requests/update_service.rb24
-rw-r--r--app/services/merge_requests/working_copy_base_service.rb24
-rw-r--r--app/services/metrics_service.rb10
-rw-r--r--app/services/milestones/base_service.rb6
-rw-r--r--app/services/milestones/close_service.rb2
-rw-r--r--app/services/milestones/create_service.rb4
-rw-r--r--app/services/milestones/destroy_service.rb6
-rw-r--r--app/services/milestones/promote_service.rb85
-rw-r--r--app/services/milestones/reopen_service.rb2
-rw-r--r--app/services/milestones/update_service.rb4
-rw-r--r--app/services/notes/build_service.rb9
-rw-r--r--app/services/notes/create_service.rb8
-rw-r--r--app/services/notes/destroy_service.rb4
-rw-r--r--app/services/notes/post_process_service.rb2
-rw-r--r--app/services/notes/quick_actions_service.rb8
-rw-r--r--app/services/notes/render_service.rb21
-rw-r--r--app/services/notification_recipient_service.rb481
-rw-r--r--app/services/notification_service.rb198
-rw-r--r--app/services/projects/after_import_service.rb24
-rw-r--r--app/services/projects/autocomplete_service.rb42
-rw-r--r--app/services/projects/batch_count_service.rb31
-rw-r--r--app/services/projects/batch_forks_count_service.rb18
-rw-r--r--app/services/projects/batch_open_issues_count_service.rb16
-rw-r--r--app/services/projects/count_service.rb36
-rw-r--r--app/services/projects/create_from_template_service.rb19
-rw-r--r--app/services/projects/create_service.rb42
-rw-r--r--app/services/projects/destroy_service.rb87
-rw-r--r--app/services/projects/fork_service.rb66
-rw-r--r--app/services/projects/forks_count_service.rb15
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb34
-rw-r--r--app/services/projects/group_links/create_service.rb15
-rw-r--r--app/services/projects/group_links/destroy_service.rb11
-rw-r--r--app/services/projects/hashed_storage/migrate_attachments_service.rb54
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb72
-rw-r--r--app/services/projects/hashed_storage_migration_service.rb22
-rw-r--r--app/services/projects/housekeeping_service.rb15
-rw-r--r--app/services/projects/import_export/export_service.rb4
-rw-r--r--app/services/projects/import_service.rb52
-rw-r--r--app/services/projects/open_issues_count_service.rb15
-rw-r--r--app/services/projects/open_merge_requests_count_service.rb13
-rw-r--r--app/services/projects/transfer_service.rb46
-rw-r--r--app/services/projects/unlink_fork_service.rb21
-rw-r--r--app/services/projects/update_pages_configuration_service.rb16
-rw-r--r--app/services/projects/update_pages_service.rb62
-rw-r--r--app/services/projects/update_service.rb74
-rw-r--r--app/services/prometheus/adapter_service.rb36
-rw-r--r--app/services/protected_branches/access_level_params.rb33
-rw-r--r--app/services/protected_branches/api_create_service.rb29
-rw-r--r--app/services/protected_branches/api_service.rb24
-rw-r--r--app/services/protected_branches/api_update_service.rb47
-rw-r--r--app/services/protected_branches/create_service.rb4
-rw-r--r--app/services/protected_branches/legacy_api_create_service.rb29
-rw-r--r--app/services/protected_branches/legacy_api_update_service.rb47
-rw-r--r--app/services/push_event_payload_service.rb120
-rw-r--r--app/services/quick_actions/interpret_service.rb176
-rw-r--r--app/services/reset_project_cache_service.rb5
-rw-r--r--app/services/search/global_service.rb5
-rw-r--r--app/services/search/group_service.rb1
-rw-r--r--app/services/spam_check_service.rb4
-rw-r--r--app/services/spam_service.rb2
-rw-r--r--app/services/submit_usage_ping_service.rb27
-rw-r--r--app/services/system_hooks_service.rb75
-rw-r--r--app/services/system_note_service.rb141
-rw-r--r--app/services/tags/create_service.rb4
-rw-r--r--app/services/tags/destroy_service.rb2
-rw-r--r--app/services/test_hook_service.rb6
-rw-r--r--app/services/test_hooks/base_service.rb40
-rw-r--r--app/services/test_hooks/project_service.rb63
-rw-r--r--app/services/test_hooks/system_service.rb24
-rw-r--r--app/services/todo_service.rb57
-rw-r--r--app/services/upload_service.rb2
-rw-r--r--app/services/user_project_access_changed_service.rb10
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/build_service.rb4
-rw-r--r--app/services/users/create_service.rb8
-rw-r--r--app/services/users/destroy_service.rb17
-rw-r--r--app/services/users/keys_count_service.rb27
-rw-r--r--app/services/users/last_push_event_service.rb83
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb20
-rw-r--r--app/services/users/update_service.rb21
-rw-r--r--app/services/validate_new_branch_service.rb2
-rw-r--r--app/services/verify_pages_domain_service.rb107
-rw-r--r--app/services/web_hook_service.rb43
-rw-r--r--app/services/wiki_pages/base_service.rb16
-rw-r--r--app/services/wiki_pages/update_service.rb2
-rw-r--r--app/uploaders/artifact_uploader.rb39
-rw-r--r--app/uploaders/attachment_uploader.rb8
-rw-r--r--app/uploaders/avatar_uploader.rb19
-rw-r--r--app/uploaders/file_mover.rb7
-rw-r--r--app/uploaders/file_uploader.rb143
-rw-r--r--app/uploaders/gitlab_uploader.rb85
-rw-r--r--app/uploaders/job_artifact_uploader.rb34
-rw-r--r--app/uploaders/legacy_artifact_uploader.rb15
-rw-r--r--app/uploaders/lfs_object_uploader.rb21
-rw-r--r--app/uploaders/namespace_file_uploader.rb19
-rw-r--r--app/uploaders/personal_file_uploader.rb36
-rw-r--r--app/uploaders/records_uploads.rb81
-rw-r--r--app/uploaders/uploader_helper.rb9
-rw-r--r--app/uploaders/workhorse.rb7
-rw-r--r--app/validators/abstract_path_validator.rb34
-rw-r--r--app/validators/certificate_key_validator.rb1
-rw-r--r--app/validators/certificate_validator.rb1
-rw-r--r--app/validators/cluster_name_validator.rb24
-rw-r--r--app/validators/dynamic_path_validator.rb53
-rw-r--r--app/validators/importable_url_validator.rb2
-rw-r--r--app/validators/key_restriction_validator.rb29
-rw-r--r--app/validators/namespace_path_validator.rb15
-rw-r--r--app/validators/project_path_validator.rb15
-rw-r--r--app/validators/url_placeholder_validator.rb32
-rw-r--r--app/validators/variable_duplicates_validator.rb30
-rw-r--r--app/views/admin/appearances/_form.html.haml40
-rw-r--r--app/views/admin/appearances/preview_sign_in.html.haml (renamed from app/views/admin/appearances/preview.html.haml)0
-rw-r--r--app/views/admin/application_settings/_form.html.haml314
-rw-r--r--app/views/admin/applications/_form.html.haml8
-rw-r--r--app/views/admin/applications/edit.html.haml3
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/applications/new.html.haml2
-rw-r--r--app/views/admin/applications/show.html.haml6
-rw-r--r--app/views/admin/background_jobs/show.html.haml3
-rw-r--r--app/views/admin/broadcast_messages/edit.html.haml1
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml1
-rw-r--r--app/views/admin/broadcast_messages/preview.js.haml1
-rw-r--r--app/views/admin/cohorts/_usage_ping.html.haml2
-rw-r--r--app/views/admin/cohorts/index.html.haml2
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml4
-rw-r--r--app/views/admin/dashboard/_head.html.haml33
-rw-r--r--app/views/admin/dashboard/index.html.haml335
-rw-r--r--app/views/admin/deploy_keys/index.html.haml8
-rw-r--r--app/views/admin/gitaly_servers/index.html.haml31
-rw-r--r--app/views/admin/groups/_group.html.haml2
-rw-r--r--app/views/admin/groups/index.html.haml19
-rw-r--r--app/views/admin/groups/show.html.haml17
-rw-r--r--app/views/admin/health_check/_failing_storages.html.haml15
-rw-r--r--app/views/admin/health_check/show.html.haml36
-rw-r--r--app/views/admin/hook_logs/_index.html.haml2
-rw-r--r--app/views/admin/hooks/_form.html.haml47
-rw-r--r--app/views/admin/hooks/edit.html.haml3
-rw-r--r--app/views/admin/hooks/index.html.haml58
-rw-r--r--app/views/admin/identities/_form.html.haml2
-rw-r--r--app/views/admin/identities/_identity.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml14
-rw-r--r--app/views/admin/labels/edit.html.haml2
-rw-r--r--app/views/admin/logs/show.html.haml17
-rw-r--r--app/views/admin/monitoring/_head.html.haml29
-rw-r--r--app/views/admin/projects/_projects.html.haml11
-rw-r--r--app/views/admin/projects/index.html.haml5
-rw-r--r--app/views/admin/projects/show.html.haml18
-rw-r--r--app/views/admin/requests_profiles/index.html.haml1
-rw-r--r--app/views/admin/runners/_runner.html.haml19
-rw-r--r--app/views/admin/runners/index.html.haml68
-rw-r--r--app/views/admin/runners/show.html.haml14
-rw-r--r--app/views/admin/services/edit.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml7
-rw-r--r--app/views/admin/users/_user.html.haml24
-rw-r--r--app/views/admin/users/index.html.haml4
-rw-r--r--app/views/admin/users/projects.html.haml8
-rw-r--r--app/views/admin/users/show.html.haml27
-rw-r--r--app/views/ci/lints/_create.html.haml4
-rw-r--r--app/views/ci/lints/show.html.haml4
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml16
-rw-r--r--app/views/ci/runner/_how_to_setup_shared_runner.html.haml3
-rw-r--r--app/views/ci/runner/_how_to_setup_specific_runner.html.haml26
-rw-r--r--app/views/ci/status/_badge.html.haml4
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml12
-rw-r--r--app/views/ci/variables/_content.html.haml1
-rw-r--r--app/views/ci/variables/_index.html.haml20
-rw-r--r--app/views/ci/variables/_variable_row.html.haml49
-rw-r--r--app/views/dashboard/_activity_head.html.haml4
-rw-r--r--app/views/dashboard/_groups_head.html.haml9
-rw-r--r--app/views/dashboard/_projects_head.html.haml12
-rw-r--r--app/views/dashboard/_snippets_head.html.haml7
-rw-r--r--app/views/dashboard/activity.html.haml5
-rw-r--r--app/views/dashboard/groups/_empty_state.html.haml7
-rw-r--r--app/views/dashboard/groups/_groups.html.haml11
-rw-r--r--app/views/dashboard/groups/index.html.haml8
-rw-r--r--app/views/dashboard/issues.html.haml5
-rw-r--r--app/views/dashboard/merge_requests.html.haml3
-rw-r--r--app/views/dashboard/milestones/index.html.haml3
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml41
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml58
-rw-r--r--app/views/dashboard/projects/_nav.html.haml6
-rw-r--r--app/views/dashboard/projects/_projects.html.haml2
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml60
-rw-r--r--app/views/dashboard/projects/index.html.haml12
-rw-r--r--app/views/dashboard/projects/starred.html.haml8
-rw-r--r--app/views/dashboard/snippets/index.html.haml1
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml20
-rw-r--r--app/views/devise/confirmations/almost_there.haml4
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.html.haml16
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.text.erb14
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.html.haml8
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.text.erb7
-rw-r--r--app/views/devise/mailer/confirmation_instructions.html.haml16
-rw-r--r--app/views/devise/mailer/confirmation_instructions.text.erb10
-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/sessions/new.html.haml6
-rw-r--r--app/views/devise/sessions/two_factor.html.haml4
-rw-r--r--app/views/devise/shared/_links.erb12
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml7
-rw-r--r--app/views/devise/shared/_sign_in_link.html.haml2
-rw-r--r--app/views/devise/shared/_signin_box.html.haml6
-rw-r--r--app/views/devise/shared/_signup_box.html.haml4
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml6
-rw-r--r--app/views/devise/shared/_tabs_normal.html.haml2
-rw-r--r--app/views/discussions/_diff_discussion.html.haml16
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml42
-rw-r--r--app/views/discussions/_discussion.html.haml20
-rw-r--r--app/views/discussions/_headline.html.haml6
-rw-r--r--app/views/discussions/_new_issue_for_all_discussions.html.haml10
-rw-r--r--app/views/discussions/_new_issue_for_discussion.html.haml10
-rw-r--r--app/views/discussions/_notes.html.haml19
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml4
-rw-r--r--app/views/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/doorkeeper/applications/edit.html.haml1
-rw-r--r--app/views/doorkeeper/applications/index.html.haml5
-rw-r--r--app/views/doorkeeper/applications/show.html.haml4
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml88
-rw-r--r--app/views/errors/access_denied.html.haml10
-rw-r--r--app/views/errors/omniauth_error.html.haml2
-rw-r--r--app/views/events/_commit.html.haml4
-rw-r--r--app/views/events/_event.atom.builder7
-rw-r--r--app/views/events/_event_note.atom.haml2
-rw-r--r--app/views/events/_event_push.atom.haml26
-rw-r--r--app/views/events/event/_note.html.haml2
-rw-r--r--app/views/events/event/_push.html.haml21
-rw-r--r--app/views/explore/groups/_groups.html.haml8
-rw-r--r--app/views/explore/groups/index.html.haml7
-rw-r--r--app/views/explore/projects/_projects.html.haml2
-rw-r--r--app/views/explore/projects/index.html.haml1
-rw-r--r--app/views/explore/projects/starred.html.haml1
-rw-r--r--app/views/explore/projects/trending.html.haml1
-rw-r--r--app/views/explore/snippets/index.html.haml1
-rw-r--r--app/views/groups/_children.html.haml4
-rw-r--r--app/views/groups/_head.html.haml17
-rw-r--r--app/views/groups/_head_issues.html.haml19
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/_settings_head.html.haml14
-rw-r--r--app/views/groups/_shared_projects.html.haml1
-rw-r--r--app/views/groups/_show_nav.html.haml8
-rw-r--r--app/views/groups/activity.html.haml1
-rw-r--r--app/views/groups/boards/index.html.haml1
-rw-r--r--app/views/groups/boards/show.html.haml1
-rw-r--r--app/views/groups/edit.html.haml56
-rw-r--r--app/views/groups/group_members/index.html.haml2
-rw-r--r--app/views/groups/group_members/update.js.haml4
-rw-r--r--app/views/groups/issues.html.haml18
-rw-r--r--app/views/groups/labels/index.html.haml10
-rw-r--r--app/views/groups/labels/new.html.haml2
-rw-r--r--app/views/groups/merge_requests.html.haml13
-rw-r--r--app/views/groups/milestones/_form.html.haml27
-rw-r--r--app/views/groups/milestones/_header_title.html.haml3
-rw-r--r--app/views/groups/milestones/_milestone.html.haml3
-rw-r--r--app/views/groups/milestones/edit.html.haml7
-rw-r--r--app/views/groups/milestones/index.html.haml10
-rw-r--r--app/views/groups/milestones/new.html.haml40
-rw-r--r--app/views/groups/milestones/show.html.haml2
-rw-r--r--app/views/groups/new.html.haml3
-rw-r--r--app/views/groups/projects.html.haml8
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml11
-rw-r--r--app/views/groups/show.html.haml44
-rw-r--r--app/views/groups/subgroups.html.haml21
-rw-r--r--app/views/help/_shortcuts.html.haml15
-rw-r--r--app/views/help/index.html.haml35
-rw-r--r--app/views/help/instance_configuration.html.haml17
-rw-r--r--app/views/help/instance_configuration/_gitlab_ci.html.haml24
-rw-r--r--app/views/help/instance_configuration/_gitlab_pages.html.haml35
-rw-r--r--app/views/help/instance_configuration/_ssh_info.html.haml27
-rw-r--r--app/views/help/ui.html.haml29
-rw-r--r--app/views/ide/index.html.haml12
-rw-r--r--app/views/import/_githubish_status.html.haml24
-rw-r--r--app/views/import/base/create.js.haml13
-rw-r--r--app/views/import/base/unauthorized.js.haml14
-rw-r--r--app/views/import/bitbucket/status.html.haml2
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml2
-rw-r--r--app/views/import/fogbugz/status.html.haml2
-rw-r--r--app/views/import/github/new.html.haml38
-rw-r--r--app/views/import/github/status.html.haml6
-rw-r--r--app/views/import/gitlab/status.html.haml2
-rw-r--r--app/views/import/gitlab_projects/new.html.haml47
-rw-r--r--app/views/import/google_code/status.html.haml2
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/issues/_issue.atom.builder6
-rw-r--r--app/views/kaminari/gitlab/_without_count.html.haml8
-rw-r--r--app/views/koding/index.html.haml2
-rw-r--r--app/views/layouts/_bootlint.haml4
-rw-r--r--app/views/layouts/_broadcast.html.haml2
-rw-r--r--app/views/layouts/_flash.html.haml16
-rw-r--r--app/views/layouts/_google_analytics.html.haml1
-rw-r--r--app/views/layouts/_head.html.haml21
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml14
-rw-r--r--app/views/layouts/_mailer.html.haml14
-rw-r--r--app/views/layouts/_page.html.haml17
-rw-r--r--app/views/layouts/_piwik.html.haml1
-rw-r--r--app/views/layouts/_recaptcha_verification.html.haml15
-rw-r--r--app/views/layouts/_search.html.haml20
-rw-r--r--app/views/layouts/admin.html.haml3
-rw-r--r--app/views/layouts/application.html.haml9
-rw-r--r--app/views/layouts/devise.html.haml13
-rw-r--r--app/views/layouts/devise_empty.html.haml2
-rw-r--r--app/views/layouts/errors.html.haml4
-rw-r--r--app/views/layouts/group.html.haml9
-rw-r--r--app/views/layouts/header/_default.html.haml109
-rw-r--r--app/views/layouts/header/_new.html.haml93
-rw-r--r--app/views/layouts/header/_new_dropdown.haml16
-rw-r--r--app/views/layouts/header/_read_only_banner.html.haml7
-rw-r--r--app/views/layouts/help.html.haml1
-rw-r--r--app/views/layouts/nav/_admin.html.haml40
-rw-r--r--app/views/layouts/nav/_admin_settings.html.haml31
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml20
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml138
-rw-r--r--app/views/layouts/nav/_explore.html.haml39
-rw-r--r--app/views/layouts/nav/_group.html.haml31
-rw-r--r--app/views/layouts/nav/_new_dashboard.html.haml33
-rw-r--r--app/views/layouts/nav/_new_explore.html.haml19
-rw-r--r--app/views/layouts/nav/_profile.html.haml53
-rw-r--r--app/views/layouts/nav/_project.html.haml106
-rw-r--r--app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml11
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml15
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml211
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml135
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml144
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml358
-rw-r--r--app/views/layouts/nav_only.html.haml14
-rw-r--r--app/views/layouts/notify.html.haml2
-rw-r--r--app/views/layouts/notify.text.erb2
-rw-r--r--app/views/layouts/oauth_error.html.haml2
-rw-r--r--app/views/layouts/profile.html.haml1
-rw-r--r--app/views/layouts/project.html.haml14
-rw-r--r--app/views/layouts/snippets.html.haml1
-rw-r--r--app/views/notify/_note_email.html.haml13
-rw-r--r--app/views/notify/_note_email.text.erb2
-rw-r--r--app/views/notify/closed_issue_email.text.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml2
-rw-r--r--app/views/notify/issue_moved_email.html.haml2
-rw-r--r--app/views/notify/issue_moved_email.text.erb2
-rw-r--r--app/views/notify/issue_status_changed_email.text.erb2
-rw-r--r--app/views/notify/merge_request_status_email.text.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml2
-rw-r--r--app/views/notify/new_email_email.html.haml10
-rw-r--r--app/views/notify/new_email_email.text.erb7
-rw-r--r--app/views/notify/new_gpg_key_email.html.haml10
-rw-r--r--app/views/notify/new_gpg_key_email.text.erb7
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_user_email.html.haml2
-rw-r--r--app/views/notify/pages_domain_disabled_email.html.haml15
-rw-r--r--app/views/notify/pages_domain_disabled_email.text.haml13
-rw-r--r--app/views/notify/pages_domain_enabled_email.html.haml11
-rw-r--r--app/views/notify/pages_domain_enabled_email.text.haml9
-rw-r--r--app/views/notify/pages_domain_verification_failed_email.html.haml17
-rw-r--r--app/views/notify/pages_domain_verification_failed_email.text.haml14
-rw-r--r--app/views/notify/pages_domain_verification_succeeded_email.html.haml13
-rw-r--r--app/views/notify/pages_domain_verification_succeeded_email.text.haml10
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml10
-rw-r--r--app/views/notify/pipeline_success_email.html.haml12
-rw-r--r--app/views/notify/pipeline_success_email.text.erb4
-rw-r--r--app/views/notify/project_was_exported_email.html.haml4
-rw-r--r--app/views/notify/project_was_exported_email.text.erb2
-rw-r--r--app/views/notify/project_was_moved_email.html.haml4
-rw-r--r--app/views/notify/project_was_moved_email.text.erb2
-rw-r--r--app/views/notify/repository_push_email.html.haml4
-rw-r--r--app/views/notify/resolved_all_discussions_email.text.erb2
-rw-r--r--app/views/peek/_bar.html.haml12
-rw-r--r--app/views/peek/views/_gc.html.haml7
-rw-r--r--app/views/peek/views/_mysql2.html.haml4
-rw-r--r--app/views/peek/views/_pg.html.haml4
-rw-r--r--app/views/peek/views/_redis.html.haml7
-rw-r--r--app/views/peek/views/_sidekiq.html.haml7
-rw-r--r--app/views/peek/views/_sql.html.haml13
-rw-r--r--app/views/profiles/_head.html.haml2
-rw-r--r--app/views/profiles/accounts/_reset_token.html.haml11
-rw-r--r--app/views/profiles/accounts/show.html.haml82
-rw-r--r--app/views/profiles/audit_log.html.haml8
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml4
-rw-r--r--app/views/profiles/chat_names/index.html.haml6
-rw-r--r--app/views/profiles/emails/index.html.haml22
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml10
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml25
-rw-r--r--app/views/profiles/gpg_keys/_key_table.html.haml11
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml21
-rw-r--r--app/views/profiles/keys/_key.html.haml8
-rw-r--r--app/views/profiles/keys/_key_details.html.haml1
-rw-r--r--app/views/profiles/keys/index.html.haml10
-rw-r--r--app/views/profiles/keys/show.html.haml4
-rw-r--r--app/views/profiles/notifications/show.html.haml12
-rw-r--r--app/views/profiles/passwords/edit.html.haml6
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml55
-rw-r--r--app/views/profiles/preferences/show.html.haml55
-rw-r--r--app/views/profiles/preferences/update.js.erb4
-rw-r--r--app/views/profiles/show.html.haml50
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml171
-rw-r--r--app/views/projects/_activity.html.haml8
-rw-r--r--app/views/projects/_commit_button.html.haml4
-rw-r--r--app/views/projects/_deletion_failed.html.haml6
-rw-r--r--app/views/projects/_export.html.haml41
-rw-r--r--app/views/projects/_files.html.haml9
-rw-r--r--app/views/projects/_find_file_link.html.haml2
-rw-r--r--app/views/projects/_flash_messages.html.haml7
-rw-r--r--app/views/projects/_head.html.haml17
-rw-r--r--app/views/projects/_home_panel.html.haml21
-rw-r--r--app/views/projects/_issuable_by_email.html.haml37
-rw-r--r--app/views/projects/_last_push.html.haml10
-rw-r--r--app/views/projects/_md_preview.html.haml38
-rw-r--r--app/views/projects/_merge_request_fast_forward_settings.html.haml13
-rw-r--r--app/views/projects/_merge_request_merge_settings.html.haml8
-rw-r--r--app/views/projects/_merge_request_rebase_settings.html.haml13
-rw-r--r--app/views/projects/_merge_request_settings.html.haml20
-rw-r--r--app/views/projects/_new_project_fields.html.haml43
-rw-r--r--app/views/projects/_new_project_push_tip.html.haml11
-rw-r--r--app/views/projects/_project_templates.html.haml24
-rw-r--r--app/views/projects/_readme.html.haml23
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml8
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/activity.html.haml6
-rw-r--r--app/views/projects/artifacts/_tree_directory.html.haml6
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml17
-rw-r--r--app/views/projects/artifacts/browse.html.haml21
-rw-r--r--app/views/projects/artifacts/file.html.haml9
-rw-r--r--app/views/projects/blame/show.html.haml7
-rw-r--r--app/views/projects/blob/_blob.html.haml2
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml14
-rw-r--r--app/views/projects/blob/_editor.html.haml4
-rw-r--r--app/views/projects/blob/_header.html.haml5
-rw-r--r--app/views/projects/blob/_header_content.html.haml3
-rw-r--r--app/views/projects/blob/_new_dir.html.haml11
-rw-r--r--app/views/projects/blob/_remove.html.haml5
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml10
-rw-r--r--app/views/projects/blob/_upload.html.haml15
-rw-r--r--app/views/projects/blob/_viewer.html.haml4
-rw-r--r--app/views/projects/blob/diff.html.haml31
-rw-r--r--app/views/projects/blob/edit.html.haml11
-rw-r--r--app/views/projects/blob/new.html.haml6
-rw-r--r--app/views/projects/blob/show.html.haml15
-rw-r--r--app/views/projects/blob/viewers/_balsamiq.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_changelog.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_dependency_manager.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_download.html.haml6
-rw-r--r--app/views/projects/blob/viewers/_image.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_notebook.html.haml6
-rw-r--r--app/views/projects/blob/viewers/_pdf.html.haml6
-rw-r--r--app/views/projects/blob/viewers/_readme.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_sketch.html.haml6
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_video.html.haml2
-rw-r--r--app/views/projects/boards/_show.html.haml38
-rw-r--r--app/views/projects/boards/components/_board.html.haml38
-rw-r--r--app/views/projects/boards/components/_sidebar.html.haml26
-rw-r--r--app/views/projects/boards/components/sidebar/_assignee.html.haml31
-rw-r--r--app/views/projects/boards/components/sidebar/_due_date.html.haml32
-rw-r--r--app/views/projects/boards/components/sidebar/_labels.html.haml30
-rw-r--r--app/views/projects/boards/components/sidebar/_milestone.html.haml29
-rw-r--r--app/views/projects/boards/components/sidebar/_notifications.html.haml7
-rw-r--r--app/views/projects/boards/index.html.haml2
-rw-r--r--app/views/projects/boards/show.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml136
-rw-r--r--app/views/projects/branches/_commit.html.haml4
-rw-r--r--app/views/projects/branches/_delete_protected_modal.html.haml37
-rw-r--r--app/views/projects/branches/_panel.html.haml19
-rw-r--r--app/views/projects/branches/index.html.haml77
-rw-r--r--app/views/projects/branches/new.html.haml11
-rw-r--r--app/views/projects/buttons/_download.html.haml43
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml61
-rw-r--r--app/views/projects/buttons/_fork.html.haml11
-rw-r--r--app/views/projects/buttons/_koding.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml10
-rw-r--r--app/views/projects/ci/builds/_build.html.haml18
-rw-r--r--app/views/projects/clusters/_advanced_settings.html.haml15
-rw-r--r--app/views/projects/clusters/_banner.html.haml14
-rw-r--r--app/views/projects/clusters/_cluster.html.haml24
-rw-r--r--app/views/projects/clusters/_dropdown.html.haml12
-rw-r--r--app/views/projects/clusters/_empty_state.html.haml11
-rw-r--r--app/views/projects/clusters/_integration_form.html.haml38
-rw-r--r--app/views/projects/clusters/_sidebar.html.haml7
-rw-r--r--app/views/projects/clusters/gcp/_form.html.haml35
-rw-r--r--app/views/projects/clusters/gcp/_header.html.haml14
-rw-r--r--app/views/projects/clusters/gcp/_show.html.haml41
-rw-r--r--app/views/projects/clusters/gcp/login.html.haml19
-rw-r--r--app/views/projects/clusters/gcp/new.html.haml10
-rw-r--r--app/views/projects/clusters/index.html.haml22
-rw-r--r--app/views/projects/clusters/new.html.haml13
-rw-r--r--app/views/projects/clusters/show.html.haml50
-rw-r--r--app/views/projects/clusters/user/_form.html.haml28
-rw-r--r--app/views/projects/clusters/user/_header.html.haml5
-rw-r--r--app/views/projects/clusters/user/_show.html.haml29
-rw-r--r--app/views/projects/clusters/user/new.html.haml11
-rw-r--r--app/views/projects/commit/_ajax_signature.html.haml2
-rw-r--r--app/views/projects/commit/_change.html.haml9
-rw-r--r--app/views/projects/commit/_ci_menu.html.haml6
-rw-r--r--app/views/projects/commit/_commit_box.html.haml53
-rw-r--r--app/views/projects/commit/_limit_exceeded_message.html.haml8
-rw-r--r--app/views/projects/commit/_other_user_signature_badge.html.haml6
-rw-r--r--app/views/projects/commit/_pipelines_list.haml7
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/_signature.html.haml2
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml28
-rw-r--r--app/views/projects/commit/_signature_badge_user.html.haml21
-rw-r--r--app/views/projects/commit/_unknown_key_signature_badge.html.haml1
-rw-r--r--app/views/projects/commit/_unverified_key_signature_badge.html.haml1
-rw-r--r--app/views/projects/commit/_unverified_signature_badge.html.haml6
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/branches.html.haml26
-rw-r--r--app/views/projects/commit/pipelines.html.haml2
-rw-r--r--app/views/projects/commit/show.html.haml4
-rw-r--r--app/views/projects/commits/_commit.atom.builder10
-rw-r--r--app/views/projects/commits/_commit.html.haml48
-rw-r--r--app/views/projects/commits/_commits.html.haml11
-rw-r--r--app/views/projects/commits/_head.html.haml36
-rw-r--r--app/views/projects/commits/_inline_commit.html.haml4
-rw-r--r--app/views/projects/commits/show.atom.builder6
-rw-r--r--app/views/projects/commits/show.html.haml67
-rw-r--r--app/views/projects/compare/_form.html.haml32
-rw-r--r--app/views/projects/compare/index.html.haml18
-rw-r--r--app/views/projects/compare/show.html.haml15
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml23
-rw-r--r--app/views/projects/deploy_keys/_form.html.haml18
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml8
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml2
-rw-r--r--app/views/projects/deployments/_commit.html.haml6
-rw-r--r--app/views/projects/diffs/_diffs.html.haml32
-rw-r--r--app/views/projects/diffs/_file.html.haml10
-rw-r--r--app/views/projects/diffs/_file_header.html.haml3
-rw-r--r--app/views/projects/diffs/_image_diff_frame.html.haml5
-rw-r--r--app/views/projects/diffs/_line.html.haml3
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml19
-rw-r--r--app/views/projects/diffs/_replaced_image_diff.html.haml61
-rw-r--r--app/views/projects/diffs/_single_image_diff.html.haml16
-rw-r--r--app/views/projects/diffs/_stats.html.haml73
-rw-r--r--app/views/projects/diffs/_warning.html.haml13
-rw-r--r--app/views/projects/diffs/viewers/_image.html.haml72
-rw-r--r--app/views/projects/edit.html.haml390
-rw-r--r--app/views/projects/empty.html.haml61
-rw-r--r--app/views/projects/environments/_form.html.haml2
-rw-r--r--app/views/projects/environments/_stop.html.haml2
-rw-r--r--app/views/projects/environments/_terminal_button.html.haml2
-rw-r--r--app/views/projects/environments/edit.html.haml1
-rw-r--r--app/views/projects/environments/folder.html.haml9
-rw-r--r--app/views/projects/environments/index.html.haml10
-rw-r--r--app/views/projects/environments/metrics.html.haml85
-rw-r--r--app/views/projects/environments/new.html.haml2
-rw-r--r--app/views/projects/environments/show.html.haml24
-rw-r--r--app/views/projects/environments/terminal.html.haml4
-rw-r--r--app/views/projects/find_file/show.html.haml13
-rw-r--r--app/views/projects/forks/_fork_button.html.haml26
-rw-r--r--app/views/projects/forks/error.html.haml5
-rw-r--r--app/views/projects/forks/index.html.haml6
-rw-r--r--app/views/projects/forks/new.html.haml59
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml8
-rw-r--r--app/views/projects/graphs/charts.html.haml95
-rw-r--r--app/views/projects/graphs/show.html.haml41
-rw-r--r--app/views/projects/hook_logs/_index.html.haml4
-rw-r--r--app/views/projects/hook_logs/show.html.haml4
-rw-r--r--app/views/projects/hooks/edit.html.haml8
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/imports/show.html.haml15
-rw-r--r--app/views/projects/issues/_by_email_description.html.haml6
-rw-r--r--app/views/projects/issues/_discussion.html.haml8
-rw-r--r--app/views/projects/issues/_head.html.haml33
-rw-r--r--app/views/projects/issues/_issue.html.haml68
-rw-r--r--app/views/projects/issues/_issue_by_email.html.haml34
-rw-r--r--app/views/projects/issues/_issues.html.haml6
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml6
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml10
-rw-r--r--app/views/projects/issues/_new_branch.html.haml66
-rw-r--r--app/views/projects/issues/_related_branches.html.haml2
-rw-r--r--app/views/projects/issues/index.atom.builder4
-rw-r--r--app/views/projects/issues/index.html.haml25
-rw-r--r--app/views/projects/issues/new.html.haml1
-rw-r--r--app/views/projects/issues/show.html.haml64
-rw-r--r--app/views/projects/jobs/_empty_state.html.haml17
-rw-r--r--app/views/projects/jobs/_header.html.haml10
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml174
-rw-r--r--app/views/projects/jobs/_table.html.haml2
-rw-r--r--app/views/projects/jobs/_user.html.haml2
-rw-r--r--app/views/projects/jobs/index.html.haml5
-rw-r--r--app/views/projects/jobs/show.html.haml91
-rw-r--r--app/views/projects/labels/edit.html.haml3
-rw-r--r--app/views/projects/labels/index.html.haml19
-rw-r--r--app/views/projects/labels/new.html.haml4
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml2
-rw-r--r--app/views/projects/mattermosts/_team_selection.html.haml6
-rw-r--r--app/views/projects/merge_requests/_by_email_description.html.haml1
-rw-r--r--app/views/projects/merge_requests/_commits.html.haml8
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml6
-rw-r--r--app/views/projects/merge_requests/_head.html.haml21
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml52
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml80
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml (renamed from app/views/projects/merge_requests/show/_mr_box.html.haml)0
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml42
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml5
-rw-r--r--app/views/projects/merge_requests/_new_compare.html.haml75
-rw-r--r--app/views/projects/merge_requests/_new_diffs.html.haml1
-rw-r--r--app/views/projects/merge_requests/_new_submit.html.haml58
-rw-r--r--app/views/projects/merge_requests/_pipelines.html.haml4
-rw-r--r--app/views/projects/merge_requests/_show.html.haml97
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml10
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml36
-rw-r--r--app/views/projects/merge_requests/creations/_diffs.html.haml5
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml68
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml52
-rw-r--r--app/views/projects/merge_requests/creations/branch_from.html.haml (renamed from app/views/projects/merge_requests/branch_from.html.haml)0
-rw-r--r--app/views/projects/merge_requests/creations/branch_to.html.haml (renamed from app/views/projects/merge_requests/branch_to.html.haml)0
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml7
-rw-r--r--app/views/projects/merge_requests/creations/update_branches.html.haml (renamed from app/views/projects/merge_requests/update_branches.html.haml)0
-rw-r--r--app/views/projects/merge_requests/diffs/_commit_widget.html.haml5
-rw-r--r--app/views/projects/merge_requests/diffs/_different_base.html.haml11
-rw-r--r--app/views/projects/merge_requests/diffs/_diffs.html.haml21
-rw-r--r--app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml17
-rw-r--r--app/views/projects/merge_requests/diffs/_version_controls.html.haml73
-rw-r--r--app/views/projects/merge_requests/dropdowns/_project.html.haml2
-rw-r--r--app/views/projects/merge_requests/index.html.haml25
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml4
-rw-r--r--app/views/projects/merge_requests/new.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml101
-rw-r--r--app/views/projects/merge_requests/show/_commits.html.haml8
-rw-r--r--app/views/projects/merge_requests/show/_diffs.html.haml5
-rw-r--r--app/views/projects/merge_requests/show/_how_to_merge.html.haml63
-rw-r--r--app/views/projects/merge_requests/show/_mr_title.html.haml35
-rw-r--r--app/views/projects/merge_requests/show/_pipelines.html.haml4
-rw-r--r--app/views/projects/merge_requests/show/_versions.html.haml97
-rw-r--r--app/views/projects/milestones/_form.html.haml4
-rw-r--r--app/views/projects/milestones/_milestone.html.haml6
-rw-r--r--app/views/projects/milestones/edit.html.haml1
-rw-r--r--app/views/projects/milestones/index.html.haml6
-rw-r--r--app/views/projects/milestones/new.html.haml2
-rw-r--r--app/views/projects/milestones/show.html.haml37
-rw-r--r--app/views/projects/network/_head.html.haml2
-rw-r--r--app/views/projects/network/show.html.haml10
-rw-r--r--app/views/projects/network/show.json.erb4
-rw-r--r--app/views/projects/new.html.haml236
-rw-r--r--app/views/projects/no_repo.html.haml4
-rw-r--r--app/views/projects/notes/_actions.html.haml49
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml11
-rw-r--r--app/views/projects/pages/_destroy.haml2
-rw-r--r--app/views/projects/pages/_https_only.html.haml10
-rw-r--r--app/views/projects/pages/_list.html.haml17
-rw-r--r--app/views/projects/pages/show.html.haml6
-rw-r--r--app/views/projects/pages_domains/_form.html.haml56
-rw-r--r--app/views/projects/pages_domains/edit.html.haml11
-rw-r--r--app/views/projects/pages_domains/new.html.haml6
-rw-r--r--app/views/projects/pages_domains/show.html.haml29
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml18
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml15
-rw-r--r--app/views/projects/pipeline_schedules/_tabs.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/edit.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml12
-rw-r--r--app/views/projects/pipeline_schedules/new.html.haml4
-rw-r--r--app/views/projects/pipelines/_head.html.haml34
-rw-r--r--app/views/projects/pipelines/_info.html.haml50
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml16
-rw-r--r--app/views/projects/pipelines/charts.html.haml9
-rw-r--r--app/views/projects/pipelines/charts/_overall.haml16
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_times.haml25
-rw-r--r--app/views/projects/pipelines/charts/_pipelines.haml50
-rw-r--r--app/views/projects/pipelines/index.html.haml29
-rw-r--r--app/views/projects/pipelines/new.html.haml11
-rw-r--r--app/views/projects/pipelines/show.html.haml9
-rw-r--r--app/views/projects/pipelines_settings/_badge.html.haml62
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml81
-rw-r--r--app/views/projects/project_members/_group_members.html.haml18
-rw-r--r--app/views/projects/project_members/_index.html.haml40
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml4
-rw-r--r--app/views/projects/project_members/_new_shared_group.html.haml2
-rw-r--r--app/views/projects/project_members/_shared_group_members.html.haml24
-rw-r--r--app/views/projects/project_members/_team.html.haml12
-rw-r--r--app/views/projects/project_members/import.html.haml6
-rw-r--r--app/views/projects/project_members/index.html.haml41
-rw-r--r--app/views/projects/project_members/update.js.haml4
-rw-r--r--app/views/projects/protected_branches/_branches_list.html.haml30
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml53
-rw-r--r--app/views/projects/protected_branches/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_branches/_index.html.haml29
-rw-r--r--app/views/projects/protected_branches/_matching_branch.html.haml10
-rw-r--r--app/views/projects/protected_branches/_protected_branch.html.haml22
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml30
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml33
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml24
-rw-r--r--app/views/projects/protected_branches/shared/_matching_branch.html.haml10
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml24
-rw-r--r--app/views/projects/protected_branches/show.html.haml2
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml38
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_tags/_index.html.haml29
-rw-r--r--app/views/projects/protected_tags/_matching_tag.html.haml10
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml22
-rw-r--r--app/views/projects/protected_tags/_tags_list.html.haml32
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml29
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml24
-rw-r--r--app/views/projects/protected_tags/shared/_matching_tag.html.haml10
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml22
-rw-r--r--app/views/projects/protected_tags/shared/_tags_list.html.haml30
-rw-r--r--app/views/projects/protected_tags/show.html.haml2
-rw-r--r--app/views/projects/registry/repositories/_image.html.haml33
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml92
-rw-r--r--app/views/projects/releases/edit.html.haml7
-rw-r--r--app/views/projects/remove_fork.js.haml2
-rw-r--r--app/views/projects/repositories/_feed.html.haml6
-rw-r--r--app/views/projects/runners/_form.html.haml13
-rw-r--r--app/views/projects/runners/_runner.html.haml10
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml13
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml18
-rw-r--r--app/views/projects/runners/edit.html.haml2
-rw-r--r--app/views/projects/runners/show.html.haml6
-rw-r--r--app/views/projects/services/_deprecated_message.html.haml3
-rw-r--r--app/views/projects/services/_form.html.haml25
-rw-r--r--app/views/projects/services/_index.html.haml2
-rw-r--r--app/views/projects/services/edit.html.haml6
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml6
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml26
-rw-r--r--app/views/projects/services/prometheus/_help.html.haml9
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml38
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml4
-rw-r--r--app/views/projects/settings/_head.html.haml34
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml56
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml14
-rw-r--r--app/views/projects/settings/integrations/show.html.haml2
-rw-r--r--app/views/projects/settings/members/show.html.haml1
-rw-r--r--app/views/projects/settings/repository/show.html.haml10
-rw-r--r--app/views/projects/show.atom.builder6
-rw-r--r--app/views/projects/show.html.haml81
-rw-r--r--app/views/projects/snippets/_actions.html.haml16
-rw-r--r--app/views/projects/snippets/edit.html.haml4
-rw-r--r--app/views/projects/snippets/index.html.haml11
-rw-r--r--app/views/projects/snippets/new.html.haml4
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml17
-rw-r--r--app/views/projects/tags/index.html.haml17
-rw-r--r--app/views/projects/tags/new.html.haml33
-rw-r--r--app/views/projects/tags/show.html.haml19
-rw-r--r--app/views/projects/transfer.js.haml2
-rw-r--r--app/views/projects/tree/_blob_item.html.haml7
-rw-r--r--app/views/projects/tree/_readme.html.haml6
-rw-r--r--app/views/projects/tree/_tree_commit_column.html.haml2
-rw-r--r--app/views/projects/tree/_tree_content.html.haml14
-rw-r--r--app/views/projects/tree/_tree_header.html.haml54
-rw-r--r--app/views/projects/tree/_tree_item.html.haml6
-rw-r--r--app/views/projects/tree/_truncated_notice_tree_row.html.haml7
-rw-r--r--app/views/projects/tree/show.html.haml12
-rw-r--r--app/views/projects/triggers/_content.html.haml10
-rw-r--r--app/views/projects/triggers/_index.html.haml3
-rw-r--r--app/views/projects/triggers/_trigger.html.haml6
-rw-r--r--app/views/projects/update.js.haml4
-rw-r--r--app/views/projects/variables/_content.html.haml9
-rw-r--r--app/views/projects/variables/_form.html.haml19
-rw-r--r--app/views/projects/variables/_index.html.haml16
-rw-r--r--app/views/projects/variables/_table.html.haml28
-rw-r--r--app/views/projects/variables/show.html.haml9
-rw-r--r--app/views/projects/wikis/_form.html.haml37
-rw-r--r--app/views/projects/wikis/_main_links.html.haml10
-rw-r--r--app/views/projects/wikis/_new.html.haml11
-rw-r--r--app/views/projects/wikis/_pages_wiki_page.html.haml4
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml33
-rw-r--r--app/views/projects/wikis/_sidebar_wiki_page.html.haml2
-rw-r--r--app/views/projects/wikis/edit.html.haml20
-rw-r--r--app/views/projects/wikis/empty.html.haml6
-rw-r--r--app/views/projects/wikis/git_access.html.haml16
-rw-r--r--app/views/projects/wikis/history.html.haml23
-rw-r--r--app/views/projects/wikis/pages.html.haml10
-rw-r--r--app/views/projects/wikis/show.html.haml20
-rw-r--r--app/views/search/_category.html.haml17
-rw-r--r--app/views/search/_filter.html.haml2
-rw-r--r--app/views/search/_form.html.haml2
-rw-r--r--app/views/search/_results.html.haml7
-rw-r--r--app/views/search/results/_blob.html.haml2
-rw-r--r--app/views/search/results/_issue.html.haml2
-rw-r--r--app/views/search/results/_merge_request.html.haml2
-rw-r--r--app/views/search/results/_note.html.haml2
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/search/results/_snippet_title.html.haml4
-rw-r--r--app/views/search/results/_wiki_blob.html.haml2
-rw-r--r--app/views/search/show.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml16
-rw-r--r--app/views/shared/_choose_group_avatar_button.html.haml11
-rw-r--r--app/views/shared/_clone_panel.html.haml14
-rw-r--r--app/views/shared/_commit_well.html.haml4
-rw-r--r--app/views/shared/_delete_label_modal.html.haml20
-rw-r--r--app/views/shared/_email_with_badge.html.haml8
-rw-r--r--app/views/shared/_event_filter.html.haml25
-rw-r--r--app/views/shared/_field.html.haml11
-rw-r--r--app/views/shared/_group_form.html.haml15
-rw-r--r--app/views/shared/_import_form.html.haml34
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml10
-rw-r--r--app/views/shared/_issues.html.haml2
-rw-r--r--app/views/shared/_label.html.haml78
-rw-r--r--app/views/shared/_label_row.html.haml20
-rw-r--r--app/views/shared/_logo.svg2
-rw-r--r--app/views/shared/_logo_type.svg1
-rw-r--r--app/views/shared/_merge_requests.html.haml2
-rw-r--r--app/views/shared/_milestones_filter.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml4
-rw-r--r--app/views/shared/_mr_head.html.haml4
-rw-r--r--app/views/shared/_nav_scroll.html.haml4
-rw-r--r--app/views/shared/_new_commit_form.html.haml9
-rw-r--r--app/views/shared/_new_project_item_select.html.haml24
-rw-r--r--app/views/shared/_outdated_browser.html.haml13
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml19
-rw-r--r--app/views/shared/_personal_access_tokens_table.html.haml19
-rw-r--r--app/views/shared/_recaptcha_form.html.haml20
-rw-r--r--app/views/shared/_ref_switcher.html.haml17
-rw-r--r--app/views/shared/_service_settings.html.haml15
-rw-r--r--app/views/shared/_show_aside.html.haml2
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml8
-rw-r--r--app/views/shared/_sort_dropdown.html.haml44
-rw-r--r--app/views/shared/_user_callout.html.haml13
-rw-r--r--app/views/shared/_visibility_level.html.haml2
-rw-r--r--app/views/shared/_visibility_radios.html.haml20
-rw-r--r--app/views/shared/boards/_show.html.haml39
-rw-r--r--app/views/shared/boards/components/_board.html.haml48
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml27
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml32
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml32
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml37
-rw-r--r--app/views/shared/boards/components/sidebar/_milestone.html.haml29
-rw-r--r--app/views/shared/boards/components/sidebar/_notifications.html.haml5
-rw-r--r--app/views/shared/boards/index.html.haml (renamed from app/views/projects/merge_requests/diffs.html.haml)0
-rw-r--r--app/views/shared/boards/show.html.haml1
-rw-r--r--app/views/shared/builds/_tabs.html.haml10
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml19
-rw-r--r--app/views/shared/empty_states/_issues.html.haml25
-rw-r--r--app/views/shared/empty_states/_labels.html.haml12
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml19
-rw-r--r--app/views/shared/empty_states/_priority_labels.html.haml3
-rw-r--r--app/views/shared/empty_states/icons/_issues.svg1
-rw-r--r--app/views/shared/empty_states/icons/_labels.svg1
-rw-r--r--app/views/shared/empty_states/icons/_merge_requests.svg1
-rw-r--r--app/views/shared/empty_states/icons/_pipelines_empty.svg1
-rw-r--r--app/views/shared/empty_states/icons/_pipelines_failed.svg1
-rw-r--r--app/views/shared/empty_states/icons/_priority_labels.svg1
-rw-r--r--app/views/shared/empty_states/icons/_todos_all_done.svg1
-rw-r--r--app/views/shared/empty_states/icons/_todos_empty.svg110
-rw-r--r--app/views/shared/empty_states/monitoring/_getting_started.svg1
-rw-r--r--app/views/shared/empty_states/monitoring/_loading.svg1
-rw-r--r--app/views/shared/empty_states/monitoring/_unable_to_connect.svg1
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/groups/_dropdown.html.haml45
-rw-r--r--app/views/shared/groups/_empty_state.html.haml7
-rw-r--r--app/views/shared/groups/_group.html.haml27
-rw-r--r--app/views/shared/groups/_list.html.haml6
-rw-r--r--app/views/shared/groups/_search_form.html.haml4
-rw-r--r--app/views/shared/hook_logs/_content.html.haml2
-rw-r--r--app/views/shared/icons/_abuse_reports.svg1
-rw-r--r--app/views/shared/icons/_access_tokens.svg1
-rw-r--r--app/views/shared/icons/_account.svg1
-rw-r--r--app/views/shared/icons/_add_new_group.svg8
-rw-r--r--app/views/shared/icons/_add_new_project.svg1
-rw-r--r--app/views/shared/icons/_add_new_user.svg9
-rw-r--r--app/views/shared/icons/_appearance.svg1
-rw-r--r--app/views/shared/icons/_applications.svg1
-rw-r--r--app/views/shared/icons/_authentication_log.svg1
-rw-r--r--app/views/shared/icons/_caret_down.svg1
-rw-r--r--app/views/shared/icons/_chat.svg1
-rw-r--r--app/views/shared/icons/_configure_server.svg8
-rw-r--r--app/views/shared/icons/_container_registry.svg1
-rw-r--r--app/views/shared/icons/_doc_text.svg1
-rw-r--r--app/views/shared/icons/_ellipsis_v.svg1
-rw-r--r--app/views/shared/icons/_emails.svg1
-rw-r--r--app/views/shared/icons/_express.svg1
-rw-r--r--app/views/shared/icons/_globe.svg1
-rw-r--r--app/views/shared/icons/_icon_arrow_circle_o_right.svg3
-rw-r--r--app/views/shared/icons/_icon_arrow_right.svg.erb1
-rw-r--r--app/views/shared/icons/_icon_autodevops.svg54
-rw-r--r--app/views/shared/icons/_icon_check_square_o.svg3
-rw-r--r--app/views/shared/icons/_icon_clock_o.svg2
-rw-r--r--app/views/shared/icons/_icon_clone.svg2
-rw-r--r--app/views/shared/icons/_icon_code_fork.svg3
-rw-r--r--app/views/shared/icons/_icon_comment_o.svg3
-rw-r--r--app/views/shared/icons/_icon_commit.svg3
-rw-r--r--app/views/shared/icons/_icon_edit.svg3
-rw-r--r--app/views/shared/icons/_icon_eye.svg3
-rw-r--r--app/views/shared/icons/_icon_eye_slash.svg3
-rw-r--r--app/views/shared/icons/_icon_hourglass.svg1
-rw-r--r--app/views/shared/icons/_icon_merged.svg3
-rw-r--r--app/views/shared/icons/_icon_pencil.svg3
-rw-r--r--app/views/shared/icons/_icon_random.svg3
-rw-r--r--app/views/shared/icons/_icon_resolve_discussion.svg1
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_canceled.svg0
-rw-r--r--app/views/shared/icons/_icon_status_closed.svg2
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_created.svg0
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_failed.svg0
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_manual.svg0
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_pending.svg0
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_running.svg0
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_skipped.svg0
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_success.svg0
-rw-r--r--app/views/shared/icons/_icon_status_success_borderless.svg1
-rw-r--r--app/views/shared/icons/_icon_status_success_solid.svg1
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_warning.svg0
-rw-r--r--app/views/shared/icons/_icon_tags.svg3
-rw-r--r--app/views/shared/icons/_icon_user.svg2
-rw-r--r--app/views/shared/icons/_issues.svg1
-rw-r--r--app/views/shared/icons/_issues.svg.erb4
-rw-r--r--app/views/shared/icons/_key.svg1
-rw-r--r--app/views/shared/icons/_labels.svg1
-rw-r--r--app/views/shared/icons/_lightbulb.svg1
-rw-r--r--app/views/shared/icons/_lock.svg1
-rw-r--r--app/views/shared/icons/_members.svg1
-rw-r--r--app/views/shared/icons/_messages.svg1
-rw-r--r--app/views/shared/icons/_monitoring.svg1
-rw-r--r--app/views/shared/icons/_mr_bold.svg3
-rw-r--r--app/views/shared/icons/_notifications.svg1
-rw-r--r--app/views/shared/icons/_overview.svg1
-rw-r--r--app/views/shared/icons/_pipeline.svg1
-rw-r--r--app/views/shared/icons/_plus_square.svg1
-rw-r--r--app/views/shared/icons/_preferences.svg1
-rw-r--r--app/views/shared/icons/_profile.svg1
-rw-r--r--app/views/shared/icons/_project.svg1
-rw-r--r--app/views/shared/icons/_project.svg.erb3
-rw-r--r--app/views/shared/icons/_rails.svg1
-rw-r--r--app/views/shared/icons/_service_templates.svg1
-rw-r--r--app/views/shared/icons/_settings.svg1
-rw-r--r--app/views/shared/icons/_snippets.svg1
-rw-r--r--app/views/shared/icons/_spam_logs.svg1
-rw-r--r--app/views/shared/icons/_spring.svg1
-rw-r--r--app/views/shared/icons/_system_hooks.svg1
-rw-r--r--app/views/shared/icons/_todo_done.svg1
-rw-r--r--app/views/shared/icons/_wiki.svg1
-rw-r--r--app/views/shared/issuable/_assignees.html.haml18
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml6
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml14
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml49
-rw-r--r--app/views/shared/issuable/_filter.html.haml17
-rw-r--r--app/views/shared/issuable/_form.html.haml18
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml11
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml20
-rw-r--r--app/views/shared/issuable/_milestone_dropdown.html.haml4
-rw-r--r--app/views/shared/issuable/_nav.html.haml7
-rw-r--r--app/views/shared/issuable/_participants.html.haml20
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml39
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml111
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml25
-rw-r--r--app/views/shared/issuable/_sidebar_todo.html.haml8
-rw-r--r--app/views/shared/issuable/_user_dropdown_item.html.haml3
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml5
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml20
-rw-r--r--app/views/shared/issuable/form/_issue_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml3
-rw-r--r--app/views/shared/issuable/form/_merge_request_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/form/_metadata_issue_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/form/_title.html.haml2
-rw-r--r--app/views/shared/issuable/nav_links/_all.html.haml6
-rw-r--r--app/views/shared/members/_access_request_buttons.html.haml10
-rw-r--r--app/views/shared/members/_group.html.haml6
-rw-r--r--app/views/shared/members/_member.html.haml105
-rw-r--r--app/views/shared/members/_requests.html.haml21
-rw-r--r--app/views/shared/members/update.js.haml6
-rw-r--r--app/views/shared/milestones/_issuable.html.haml17
-rw-r--r--app/views/shared/milestones/_milestone.html.haml64
-rw-r--r--app/views/shared/milestones/_participants_tab.html.haml6
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml19
-rw-r--r--app/views/shared/milestones/_tabs.html.haml11
-rw-r--r--app/views/shared/milestones/_top.html.haml64
-rw-r--r--app/views/shared/notes/_comment_button.html.haml12
-rw-r--r--app/views/shared/notes/_form.html.haml30
-rw-r--r--app/views/shared/notes/_note.html.haml26
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml31
-rw-r--r--app/views/shared/plugins/_index.html.haml23
-rw-r--r--app/views/shared/projects/_dropdown.html.haml7
-rw-r--r--app/views/shared/projects/_edit_information.html.haml6
-rw-r--r--app/views/shared/projects/_list.html.haml14
-rw-r--r--app/views/shared/projects/_project.html.haml28
-rw-r--r--app/views/shared/snippets/_form.html.haml3
-rw-r--r--app/views/shared/snippets/_header.html.haml23
-rw-r--r--app/views/shared/snippets/_snippet.html.haml6
-rw-r--r--app/views/shared/tokens/_scopes_form.html.haml1
-rw-r--r--app/views/shared/web_hooks/_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_test_button.html.haml12
-rw-r--r--app/views/snippets/_snippets.html.haml6
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml2
-rw-r--r--app/views/snippets/new.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml17
-rw-r--r--app/views/snippets/show.html.haml3
-rw-r--r--app/views/u2f/_authenticate.html.haml1
-rw-r--r--app/views/u2f/_register.html.haml17
-rw-r--r--app/views/users/_groups.html.haml2
-rw-r--r--app/views/users/calendar.html.haml9
-rw-r--r--app/views/users/calendar_activities.html.haml8
-rw-r--r--app/views/users/show.html.haml115
-rw-r--r--app/workers/admin_email_worker.rb2
-rw-r--r--app/workers/all_queues.yml106
-rw-r--r--app/workers/archive_trace_worker.rb10
-rw-r--r--app/workers/authorized_projects_worker.rb15
-rw-r--r--app/workers/background_migration_worker.rb56
-rw-r--r--app/workers/build_coverage_worker.rb4
-rw-r--r--app/workers/build_finished_worker.rb13
-rw-r--r--app/workers/build_hooks_worker.rb6
-rw-r--r--app/workers/build_queue_worker.rb6
-rw-r--r--app/workers/build_success_worker.rb6
-rw-r--r--app/workers/build_trace_sections_worker.rb8
-rw-r--r--app/workers/check_gcp_project_billing_worker.rb92
-rw-r--r--app/workers/cluster_install_app_worker.rb11
-rw-r--r--app/workers/cluster_provision_worker.rb12
-rw-r--r--app/workers/cluster_wait_for_app_installation_worker.rb14
-rw-r--r--app/workers/cluster_wait_for_ingress_ip_address_worker.rb11
-rw-r--r--app/workers/concerns/application_worker.rb60
-rw-r--r--app/workers/concerns/build_queue.rb8
-rw-r--r--app/workers/concerns/cluster_applications.rb9
-rw-r--r--app/workers/concerns/cluster_queue.rb10
-rw-r--r--app/workers/concerns/cronjob_queue.rb3
-rw-r--r--app/workers/concerns/dedicated_sidekiq_queue.rb9
-rw-r--r--app/workers/concerns/exception_backtrace.rb8
-rw-r--r--app/workers/concerns/gitlab/github_import/notify_upon_death.rb31
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb54
-rw-r--r--app/workers/concerns/gitlab/github_import/queue.rb18
-rw-r--r--app/workers/concerns/gitlab/github_import/rescheduling_methods.rb40
-rw-r--r--app/workers/concerns/gitlab/github_import/stage_methods.rb30
-rw-r--r--app/workers/concerns/new_issuable.rb26
-rw-r--r--app/workers/concerns/pipeline_background_queue.rb10
-rw-r--r--app/workers/concerns/pipeline_queue.rb4
-rw-r--r--app/workers/concerns/project_import_options.rb23
-rw-r--r--app/workers/concerns/project_start_import.rb10
-rw-r--r--app/workers/concerns/repository_check_queue.rb4
-rw-r--r--app/workers/concerns/waitable_worker.rb44
-rw-r--r--app/workers/create_gpg_signature_worker.rb15
-rw-r--r--app/workers/create_pipeline_worker.rb16
-rw-r--r--app/workers/delete_merged_branches_worker.rb3
-rw-r--r--app/workers/delete_user_worker.rb3
-rw-r--r--app/workers/email_receiver_worker.rb8
-rw-r--r--app/workers/emails_on_push_worker.rb5
-rw-r--r--app/workers/expire_build_artifacts_worker.rb4
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb3
-rw-r--r--app/workers/expire_job_cache_worker.rb18
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb34
-rw-r--r--app/workers/git_garbage_collect_worker.rb83
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb74
-rw-r--r--app/workers/gitlab/github_import/import_diff_note_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_issue_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_note_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/refresh_import_jid_worker.rb38
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb43
-rw-r--r--app/workers/gitlab/github_import/stage/import_base_data_worker.rb33
-rw-r--r--app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb31
-rw-r--r--app/workers/gitlab/github_import/stage/import_notes_worker.rb27
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb29
-rw-r--r--app/workers/gitlab/github_import/stage/import_repository_worker.rb38
-rw-r--r--app/workers/gitlab_shell_worker.rb5
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb2
-rw-r--r--app/workers/group_destroy_worker.rb6
-rw-r--r--app/workers/import_export_project_cleanup_worker.rb2
-rw-r--r--app/workers/invalid_gpg_signature_update_worker.rb11
-rw-r--r--app/workers/irker_worker.rb8
-rw-r--r--app/workers/merge_worker.rb3
-rw-r--r--app/workers/namespaceless_project_destroy_worker.rb15
-rw-r--r--app/workers/new_issue_worker.rb16
-rw-r--r--app/workers/new_merge_request_worker.rb16
-rw-r--r--app/workers/new_note_worker.rb3
-rw-r--r--app/workers/pages_domain_verification_cron_worker.rb10
-rw-r--r--app/workers/pages_domain_verification_worker.rb11
-rw-r--r--app/workers/pages_worker.rb7
-rw-r--r--app/workers/pipeline_hooks_worker.rb4
-rw-r--r--app/workers/pipeline_metrics_worker.rb2
-rw-r--r--app/workers/pipeline_notification_worker.rb2
-rw-r--r--app/workers/pipeline_process_worker.rb4
-rw-r--r--app/workers/pipeline_schedule_worker.rb15
-rw-r--r--app/workers/pipeline_success_worker.rb4
-rw-r--r--app/workers/pipeline_update_worker.rb4
-rw-r--r--app/workers/plugin_worker.rb15
-rw-r--r--app/workers/post_receive.rb26
-rw-r--r--app/workers/process_commit_worker.rb16
-rw-r--r--app/workers/project_cache_worker.rb5
-rw-r--r--app/workers/project_destroy_worker.rb13
-rw-r--r--app/workers/project_export_worker.rb9
-rw-r--r--app/workers/project_migrate_hashed_storage_worker.rb34
-rw-r--r--app/workers/project_service_worker.rb5
-rw-r--r--app/workers/propagate_service_template_worker.rb3
-rw-r--r--app/workers/prune_old_events_worker.rb2
-rw-r--r--app/workers/reactive_caching_worker.rb3
-rw-r--r--app/workers/rebase_worker.rb12
-rw-r--r--app/workers/remove_expired_group_links_worker.rb2
-rw-r--r--app/workers/remove_expired_members_worker.rb4
-rw-r--r--app/workers/remove_old_web_hook_logs_worker.rb2
-rw-r--r--app/workers/remove_unreferenced_lfs_objects_worker.rb2
-rw-r--r--app/workers/repository_archive_cache_worker.rb2
-rw-r--r--app/workers/repository_check/batch_worker.rb2
-rw-r--r--app/workers/repository_check/clear_worker.rb2
-rw-r--r--app/workers/repository_check/single_repository_worker.rb23
-rw-r--r--app/workers/repository_fork_worker.rb43
-rw-r--r--app/workers/repository_import_worker.rb51
-rw-r--r--app/workers/requests_profiles_worker.rb2
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb22
-rw-r--r--app/workers/schedule_update_user_activity_worker.rb2
-rw-r--r--app/workers/stage_update_worker.rb12
-rw-r--r--app/workers/storage_migrator_worker.rb29
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb19
-rw-r--r--app/workers/stuck_import_jobs_worker.rb58
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb39
-rw-r--r--app/workers/system_hook_push_worker.rb3
-rw-r--r--app/workers/trending_projects_worker.rb2
-rw-r--r--app/workers/update_head_pipeline_for_merge_request_worker.rb27
-rw-r--r--app/workers/update_merge_requests_worker.rb21
-rw-r--r--app/workers/update_user_activity_worker.rb3
-rw-r--r--app/workers/upload_checksum_worker.rb5
-rw-r--r--app/workers/use_key_worker.rb13
-rw-r--r--app/workers/wait_for_cluster_creation_worker.rb12
-rw-r--r--app/workers/web_hook_worker.rb5
2993 files changed, 91765 insertions, 44476 deletions
diff --git a/app/assets/images/auth_buttons/signin_with_google.png b/app/assets/images/auth_buttons/signin_with_google.png
new file mode 100644
index 00000000000..f27bb243304
--- /dev/null
+++ b/app/assets/images/auth_buttons/signin_with_google.png
Binary files differ
diff --git a/app/assets/images/emoji.png b/app/assets/images/emoji.png
index 5dcd9c09b70..723c2c3f4c8 100644
--- a/app/assets/images/emoji.png
+++ b/app/assets/images/emoji.png
Binary files differ
diff --git a/app/assets/images/emoji/gay_pride_flag.png b/app/assets/images/emoji/gay_pride_flag.png
new file mode 100644
index 00000000000..1bec5f2ffd7
--- /dev/null
+++ b/app/assets/images/emoji/gay_pride_flag.png
Binary files differ
diff --git a/app/assets/images/emoji/mrs_claus.png b/app/assets/images/emoji/mrs_claus.png
index 078f0657f95..9cf2458df1a 100644
--- a/app/assets/images/emoji/mrs_claus.png
+++ b/app/assets/images/emoji/mrs_claus.png
Binary files differ
diff --git a/app/assets/images/emoji/speech_left.png b/app/assets/images/emoji/speech_left.png
new file mode 100644
index 00000000000..00c05959bcd
--- /dev/null
+++ b/app/assets/images/emoji/speech_left.png
Binary files differ
diff --git a/app/assets/images/emoji@2x.png b/app/assets/images/emoji@2x.png
index b0fa9e1139e..987279c13cc 100644
--- a/app/assets/images/emoji@2x.png
+++ b/app/assets/images/emoji@2x.png
Binary files differ
diff --git a/app/assets/images/favicon-blue.ico b/app/assets/images/favicon-blue.ico
index 156fcf07588..156fcf07588 100755..100644
--- a/app/assets/images/favicon-blue.ico
+++ b/app/assets/images/favicon-blue.ico
Binary files differ
diff --git a/app/assets/images/file_icons.svg b/app/assets/images/file_icons.svg
new file mode 100644
index 00000000000..26ec1a6b388
--- /dev/null
+++ b/app/assets/images/file_icons.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol viewBox="0 0 24 24" id="actionscript" xmlns="http://www.w3.org/2000/svg"><text style="line-height:113.99999857%" x="5.605" y="15.892" transform="scale(.91325 1.095)" font-weight="400" font-size="42.822" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#f44336"/><path style="line-height:125%" d="M4.744 2.031c-1.157 0-1.994.31-2.51.93-.515.612-.771 1.678-.771 3.197v2.467c0 1.408-.402 2.111-1.201 2.111v2.035c.8 0 1.2.679 1.2 2.036v2.654c0 1.512.26 2.562.78 3.152.52.59 1.355.885 2.502.885V19.43c-.447 0-.77-.151-.97-.453-.195-.303-.292-.815-.292-1.538v-2.267c0-1.807-.404-2.937-1.214-3.395v-.045c.81-.464 1.214-1.581 1.214-3.351V6.025c0-1.283.42-1.925 1.262-1.925V2.03zm14.66 0V4.1c.842 0 1.262.642 1.262 1.925v2.268c0 1.843.402 2.996 1.207 3.46v.046c-.805.442-1.207 1.544-1.207 3.306v2.356c0 .715-.099 1.22-.299 1.516-.2.302-.52.453-.963.453v2.068c1.152 0 1.984-.295 2.494-.885.516-.59.772-1.663.772-3.218V14.84c0-1.379.404-2.069 1.209-2.069v-2.035c-.805 0-1.21-.696-1.21-2.09V6.113c0-1.49-.255-2.54-.77-3.152-.516-.62-1.348-.93-2.495-.93zm-3.054 4.46c-.455 0-.886.057-1.293.173a3.056 3.056 0 0 0-1.078.527c-.308.241-.551.549-.731.924-.18.37-.27.817-.27 1.336 0 .663.165 1.227.493 1.695.33.468.831.864 1.502 1.188.263.125.509.249.736.37.227.12.422.244.586.374.168.13.299.271.394.424a.963.963 0 0 1 .145.521c0 .144-.03.28-.09.405a.9.9 0 0 1-.275.318c-.12.088-.272.158-.455.21a2.34 2.34 0 0 1-.635.075c-.415 0-.825-.083-1.233-.25a3.644 3.644 0 0 1-1.13-.763v2.222a3.68 3.68 0 0 0 1.101.418c.427.093.875.139 1.346.139.459 0 .894-.05 1.305-.152a3.002 3.002 0 0 0 1.09-.5c.31-.237.556-.543.736-.918.183-.38.275-.849.275-1.405 0-.403-.052-.755-.156-1.056a2.542 2.542 0 0 0-.45-.813 3.295 3.295 0 0 0-.704-.633 6.754 6.754 0 0 0-.922-.535 12.4 12.4 0 0 1-.676-.348c-.2-.115-.37-.231-.51-.347a1.502 1.502 0 0 1-.322-.375.91.91 0 0 1-.115-.453c0-.153.033-.288.101-.408a.948.948 0 0 1 .29-.32c.123-.089.275-.156.454-.202a2.18 2.18 0 0 1 .598-.078c.16 0 .326.015.502.043.18.028.36.07.539.13.18.056.354.13.522.218.171.088.329.188.472.304V6.871a4.039 4.039 0 0 0-.957-.285 6.448 6.448 0 0 0-1.185-.096zm-8.774.165l-3.123 9.967h2.094l.605-2.217h3.053l.61 2.217h2.107L9.869 6.656H7.576zm1.072 1.78h.047c.028.347.077.646.145.896l.922 3.35H7.564l.934-3.377c.08-.288.13-.578.15-.87z" font-weight="400" font-size="51.019" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="android" xmlns="http://www.w3.org/2000/svg"><path d="M15 5h-1V4h1m-5 1H9V4h1m5.53-1.84L16.84.85c.19-.19.19-.51 0-.71a.513.513 0 0 0-.71 0l-1.48 1.48C13.85 1.23 12.95 1 12 1c-.96 0-1.86.23-2.66.63L7.85.14a.501.501 0 0 0-.7 0c-.2.2-.2.52 0 .71l1.31 1.31C6.97 3.26 6 5 6 7h12c0-2-1-3.75-2.47-4.84M20.5 8A1.5 1.5 0 0 0 19 9.5v7a1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 20.5 8m-17 0A1.5 1.5 0 0 0 2 9.5v7A1.5 1.5 0 0 0 3.5 18 1.5 1.5 0 0 0 5 16.5v-7A1.5 1.5 0 0 0 3.5 8M6 18a1 1 0 0 0 1 1h1v3.5A1.5 1.5 0 0 0 9.5 24a1.5 1.5 0 0 0 1.5-1.5V19h2v3.5a1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5V19h1a1 1 0 0 0 1-1V8H6v10z" fill="#c0ca33"/></symbol><symbol viewBox="0 0 24 24" id="angular" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#e53935"/></symbol><symbol viewBox="0 0 24 24" id="angular-component" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#0288d1"/></symbol><symbol viewBox="0 0 24 24" id="angular-directive" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#ab47bc"/></symbol><symbol viewBox="0 0 24 24" id="angular-guard" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-pipe" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#00897b"/></symbol><symbol viewBox="0 0 24 24" id="angular-resolver" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-routing" xmlns="http://www.w3.org/2000/svg"><path d="M11 10H5L3 8l2-2h6V3l1-1 1 1v1h6l2 2-2 2h-6v2h6l2 2-2 2h-6v6a2 2 0 0 1 2 2H9a2 2 0 0 1 2-2V10z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-service" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#ffca28"/></symbol><symbol viewBox="0 0 100 100" id="apiblueprint" xmlns="http://www.w3.org/2000/svg"><title>api-blueprint</title><path d="M50.133 7.521A16.998 16.998 0 0 0 33.135 24.52a16.998 16.998 0 0 0 4.945 11.974L24.861 57.398a16.998 16.998 0 0 0-3.175-.308A16.998 16.998 0 0 0 4.688 74.088a16.998 16.998 0 0 0 16.998 16.998 16.998 16.998 0 0 0 16.998-16.998 16.998 16.998 0 0 0-7.063-13.773l12.576-19.89a16.998 16.998 0 0 0 5.936 1.093 16.998 16.998 0 0 0 6.154-1.155l12.537 19.83a16.998 16.998 0 0 0-7.244 13.895 16.998 16.998 0 0 0 16.998 17 16.998 16.998 0 0 0 16.998-17A16.998 16.998 0 0 0 78.578 57.09a16.998 16.998 0 0 0-2.95.262L62.337 36.327A16.998 16.998 0 0 0 67.13 24.52 16.998 16.998 0 0 0 50.132 7.522z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="applescript" xmlns="http://www.w3.org/2000/svg"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" fill="#78909c"/></symbol><symbol viewBox="0 0 24 24" id="appveyor" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c-.084 0-.165.008-.248.01a10 10 0 0 0-.266.01 9.952 9.952 0 0 0-.754.066 10 10 0 0 0-.148.018 9.855 9.855 0 0 0-.93.177 10 10 0 0 0-.07.02c-.196.049-.392.1-.584.16v.012a10 10 0 0 0-2 .875V3.34c-.02.012-.038.027-.059.039a10 10 0 0 0-.953.635c-.09.067-.172.142-.26.213a10 10 0 0 0-.628.546c-.109.104-.211.211-.315.319a10 10 0 0 0-.476.539c-.1.12-.201.237-.295.361a10 10 0 0 0-.52.766c-.088.143-.17.288-.252.435a10 10 0 0 0-.363.723c-.072.161-.136.327-.2.492a10 10 0 0 0-.269.778c-.02.067-.044.131-.062.199a10 10 0 0 0-.008.027c-.098.364-.166.728-.22 1.09-.012.077-.024.153-.034.23a9.85 9.85 0 0 0-.08 1.182c0 .03-.006.057-.006.086a10 10 0 0 0 .008.148c.001.094-.002.188.002.282l.011.004a10 10 0 0 0 .333 2.158l-.012-.004c.012.047.033.091.047.139a10 10 0 0 0 .322.955c.02.052.037.106.059.158a10 10 0 0 0 .503 1.035c.065.116.14.226.21.34a10 10 0 0 0 .423.64c.092.128.187.252.285.375a10 10 0 0 0 .448.52c.112.123.222.248.341.365a10 10 0 0 0 .803.719 10 10 0 0 0 .01.006c.099.078.207.146.309.22a10 10 0 0 0 .648.442c.138.085.28.163.424.242a10 10 0 0 0 .715.358c.114.051.226.106.343.154a10 10 0 0 0 1.133.389c.016.004.031.01.047.015a10 10 0 0 0 .461.098 10 10 0 0 0 .482.103 10 10 0 0 0 .418.051 10 10 0 0 0 .575.065 10 10 0 0 0 .144.005A10 10 0 0 0 12 22a10 10 0 0 0 .197-.01 10 10 0 0 0 .496-.025 10 10 0 0 0 .49-.043 10 10 0 0 0 .489-.074 10 10 0 0 0 .51-.098 10 10 0 0 0 .47-.12 10 10 0 0 0 .477-.14 10 10 0 0 0 .47-.172 10 10 0 0 0 .481-.197 10 10 0 0 0 .414-.201 10 10 0 0 0 .475-.252 10 10 0 0 0 .39-.238 10 10 0 0 0 .452-.301 10 10 0 0 0 .38-.291 10 10 0 0 0 .385-.315 10 10 0 0 0 .375-.347 10 10 0 0 0 .36-.363 10 10 0 0 0 .293-.334 10 10 0 0 0 .353-.434 10 10 0 0 0 .28-.393 10 10 0 0 0 .263-.4 10 10 0 0 0 .264-.461 10 10 0 0 0 .228-.436 10 10 0 0 0 .195-.437 10 10 0 0 0 .196-.48 10 10 0 0 0 .228-.69 10 10 0 0 0 .028-.094 10 10 0 0 0 .021-.066 10 10 0 0 0 .098-.461 10 10 0 0 0 .103-.482 10 10 0 0 0 .051-.418 10 10 0 0 0 .065-.575 10 10 0 0 0 .005-.144A10 10 0 0 0 22 12a10 10 0 0 0-.01-.197 10 10 0 0 0-.025-.496 10 10 0 0 0-.043-.49 10 10 0 0 0-.074-.489 10 10 0 0 0-.098-.51 10 10 0 0 0-.12-.47 10 10 0 0 0-.14-.477 10 10 0 0 0-.172-.47 10 10 0 0 0-.197-.481 10 10 0 0 0-.201-.414 10 10 0 0 0-.252-.475 10 10 0 0 0-.238-.39 10 10 0 0 0-.301-.452 10 10 0 0 0-.291-.38 10 10 0 0 0-.315-.385 10 10 0 0 0-.347-.375 10 10 0 0 0-.363-.36 10 10 0 0 0-.334-.293 10 10 0 0 0-.434-.353 10 10 0 0 0-.393-.28 10 10 0 0 0-.4-.263 10 10 0 0 0-.461-.264 10 10 0 0 0-.436-.228 10 10 0 0 0-.437-.196 10 10 0 0 0-.48-.195 10 10 0 0 0-.69-.228 10 10 0 0 0-.094-.028 10 10 0 0 0-.066-.021 10 10 0 0 0-.461-.098 10 10 0 0 0-.482-.103 10 10 0 0 0-.418-.051 10 10 0 0 0-.575-.065 10 10 0 0 0-.144-.005A10 10 0 0 0 12 2zm-.016 5.002a5 5 0 0 1 .262.01 5 5 0 0 1 .227.011 5 5 0 0 1 .341.05 5 5 0 0 1 .135.019 5 5 0 0 1 .014.004 5 5 0 0 1 .115.025 5 5 0 0 1 .303.076 5 5 0 0 1 .265.086 5 5 0 0 1 .2.074 5 5 0 0 1 .242.106 5 5 0 0 1 .228.11 5 5 0 0 1 .196.109 5 5 0 0 1 .244.15 5 5 0 0 1 .17.12 5 5 0 0 1 .224.171 5 5 0 0 1 .186.16 5 5 0 0 1 .176.164 5 5 0 0 1 .172.18 5 5 0 0 1 .177.203 5 5 0 0 1 .133.172 5 5 0 0 1 .16.223 5 5 0 0 1 .133.214 5 5 0 0 1 .12.21 5 5 0 0 1 .107.216 5 5 0 0 1 .109.24 5 5 0 0 1 .084.223 5 5 0 0 1 .08.242 5 5 0 0 1 .07.264 5 5 0 0 1 .047.207 5 5 0 0 1 .045.277 5 5 0 0 1 .028.227 5 5 0 0 1 .02.351 5 5 0 0 1 .003.079 5 5 0 0 1-.012.271 5 5 0 0 1-.011.227 5 5 0 0 1-.05.341 5 5 0 0 1-.019.135 5 5 0 0 1-.004.014 5 5 0 0 1-.025.115 5 5 0 0 1-.076.303 5 5 0 0 1-.086.265 5 5 0 0 1-.074.2 5 5 0 0 1-.106.242 5 5 0 0 1-.11.228 5 5 0 0 1-.109.196 5 5 0 0 1-.15.244 5 5 0 0 1-.12.17 5 5 0 0 1-.171.224 5 5 0 0 1-.16.186 5 5 0 0 1-.164.176 5 5 0 0 1-.18.172 5 5 0 0 1-.203.177l-.002.002c-.018.019-.028.035-.047.053l-3.959 5.09-3.05-.979a141.684 141.684 0 0 0 3.177-3.084 5 5 0 0 1-.103-.015 5 5 0 0 1-.149-.024 5 5 0 0 1-.115-.025 5 5 0 0 1-3.57-3.04 5.072 5.072 0 0 1-.206-.661 5 5 0 0 1-.033-.147c-.025-.118-.036-.24-.054-.36-.987.993-1.964 1.993-2.954 3.05l-.98-3.053 5.092-3.957c.043-.044.082-.07.125-.11a5 5 0 0 1 .71-.634c.18-.13.367-.25.561-.356a5 5 0 0 1 .16-.08 4.94 4.94 0 0 1 .516-.222 5 5 0 0 1 .147-.057c.211-.07.43-.123.654-.164a5 5 0 0 1 .172-.027c.236-.035.476-.058.722-.059zM12 9a3 3 0 0 0-.053.002 3 3 0 0 0-.166.01 3 3 0 0 0-.133.011 3 3 0 0 0-.17.026 3 3 0 0 0-.113.021 3 3 0 0 0-.19.05 3 3 0 0 0-.103.03 3 3 0 0 0-.16.057 3 3 0 0 0-.129.053 3 3 0 0 0-.146.072 3 3 0 0 0-.12.063 3 3 0 0 0-.132.082 3 3 0 0 0-.123.08 3 3 0 0 0-.116.088 3 3 0 0 0-.126.105 3 3 0 0 0-.1.094 3 3 0 0 0-.111.111 3 3 0 0 0-.096.107 3 3 0 0 0-.094.116 3 3 0 0 0-.098.136 3 3 0 0 0-.072.11 3 3 0 0 0-.076.133 3 3 0 0 0-.07.132 3 3 0 0 0-.063.14 3 3 0 0 0-.054.14 3 3 0 0 0-.077.228 3 3 0 0 0-.007.026 3 3 0 0 0-.03.138 3 3 0 0 0-.031.149 3 3 0 0 0-.014.11 3 3 0 0 0-.02.183 3 3 0 0 0-.001.052A3 3 0 0 0 9 12a3 3 0 0 0 .002.053 3 3 0 0 0 .01.166 3 3 0 0 0 .011.133 3 3 0 0 0 .026.17 3 3 0 0 0 .021.113 3 3 0 0 0 .05.19 3 3 0 0 0 .03.103 3 3 0 0 0 .057.16 3 3 0 0 0 .053.129 3 3 0 0 0 .072.146 3 3 0 0 0 .063.12 3 3 0 0 0 .082.132 3 3 0 0 0 .08.123 3 3 0 0 0 .088.116 3 3 0 0 0 .105.126 3 3 0 0 0 .094.1 3 3 0 0 0 .111.111 3 3 0 0 0 .107.096 3 3 0 0 0 .116.094 3 3 0 0 0 .136.098 3 3 0 0 0 .11.072 3 3 0 0 0 .133.076 3 3 0 0 0 .132.07 3 3 0 0 0 .135.06 3 3 0 0 0 .153.061 3 3 0 0 0 .216.07 3 3 0 0 0 .004.003 3 3 0 0 0 .026.007 3 3 0 0 0 .138.03 3 3 0 0 0 .149.031 3 3 0 0 0 .11.014 3 3 0 0 0 .183.02 3 3 0 0 0 .011.001 3 3 0 0 0 .041 0A3 3 0 0 0 12 15a3 3 0 0 0 .053-.002 3 3 0 0 0 .166-.01 3 3 0 0 0 .133-.011 3 3 0 0 0 .17-.026 3 3 0 0 0 .113-.021 3 3 0 0 0 .19-.05 3 3 0 0 0 .103-.03 3 3 0 0 0 .16-.057 3 3 0 0 0 .129-.053 3 3 0 0 0 .146-.072 3 3 0 0 0 .12-.063 3 3 0 0 0 .132-.082 3 3 0 0 0 .123-.08 3 3 0 0 0 .116-.088 3 3 0 0 0 .126-.105 3 3 0 0 0 .1-.094 3 3 0 0 0 .111-.111 3 3 0 0 0 .096-.107 3 3 0 0 0 .094-.116 3 3 0 0 0 .098-.136 3 3 0 0 0 .072-.11 3 3 0 0 0 .076-.133 3 3 0 0 0 .07-.132 3 3 0 0 0 .06-.135 3 3 0 0 0 .061-.153 3 3 0 0 0 .07-.216 3 3 0 0 0 .003-.004 3 3 0 0 0 .007-.026 3 3 0 0 0 .03-.138 3 3 0 0 0 .031-.149 3 3 0 0 0 .002-.008 3 3 0 0 0 .012-.101 3 3 0 0 0 .02-.184 3 3 0 0 0 .001-.011 3 3 0 0 0 0-.041A3 3 0 0 0 15 12a3 3 0 0 0-.002-.053 3 3 0 0 0-.01-.166 3 3 0 0 0-.011-.133 3 3 0 0 0-.026-.17 3 3 0 0 0-.021-.113 3 3 0 0 0-.05-.19 3 3 0 0 0-.03-.103 3 3 0 0 0-.057-.16 3 3 0 0 0-.053-.129 3 3 0 0 0-.072-.146 3 3 0 0 0-.063-.12 3 3 0 0 0-.082-.132 3 3 0 0 0-.08-.123 3 3 0 0 0-.088-.116 3 3 0 0 0-.105-.126 3 3 0 0 0-.094-.1 3 3 0 0 0-.111-.111 3 3 0 0 0-.107-.096 3 3 0 0 0-.116-.094 3 3 0 0 0-.136-.098 3 3 0 0 0-.11-.072 3 3 0 0 0-.133-.076 3 3 0 0 0-.132-.07 3 3 0 0 0-.14-.063 3 3 0 0 0-.14-.054 3 3 0 0 0-.228-.077 3 3 0 0 0-.026-.007 3 3 0 0 0-.138-.03 3 3 0 0 0-.149-.031 3 3 0 0 0-.008-.002 3 3 0 0 0-.101-.012 3 3 0 0 0-.184-.02 3 3 0 0 0-.011-.001 3 3 0 0 0-.041 0A3 3 0 0 0 12 9z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 720 720" id="arduino" xmlns="http://www.w3.org/2000/svg"><defs><symbol id="ana" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke-opacity="100%" stroke-width="60" stroke="#00979c" d="M174 30a10.5 10.1 0 0 0 0 280C364 320 344 30 544 30a10.5 10.1 0 0 1 0 280C354 320 374 30 174 30"/><path d="M528 205v-32.8h-32.5v-13.7H528V126h13.9v32.5h32.5v13.7h-32.5V205H528z" text-anchor="middle" fill="#00979c" stroke-width="20" stroke="#00979c" font-family="sans-serif" font-size="167"/><path fill="#00979c" stroke="#00979c" stroke-width="23.6" transform="matrix(1.56 0 0 .64 -366 .528)" d="M321 266v-17.4h53.3V266H321z"/></symbol></defs><title>Layer 1</title><use x="20.063" y="360.85" transform="matrix(.997 0 0 .997 -18.596 -159.19)" xlink:href="#ana"/></symbol><symbol viewBox="0 0 24 24" id="assembly" xmlns="http://www.w3.org/2000/svg"><path d="M1.746 1.566v20.905H5.13v-2.088H3.438V3.656h1.69v-2.09H1.747zm17.219 0v2.09h1.693v16.727h-1.693v2.09h3.383V1.566h-3.383zM15.196 3.988c-.5 0-.93.076-1.29.225-.359.15-.652.372-.877.671-.226.302-.39.673-.494 1.108a6.715 6.715 0 0 0-.155 1.54c0 .573.049 1.083.15 1.528.1.442.264.811.49 1.11.222.298.512.524.872.676.36.153.795.23 1.304.23.518 0 .954-.075 1.308-.224.353-.153.643-.376.869-.671.219-.29.38-.661.484-1.112.104-.454.156-.967.156-1.54 0-.573-.052-1.079-.152-1.515a2.92 2.92 0 0 0-.485-1.106 2.09 2.09 0 0 0-.868-.686c-.354-.155-.79-.234-1.312-.234zm-6.814.12a.941.941 0 0 1-.138.458.849.849 0 0 1-.356.296A1.71 1.71 0 0 1 7.385 5a5.244 5.244 0 0 1-.631.037v1.11H8.19v3.6H6.754v1.188h4.545V9.745H9.894V4.11H8.382zm6.814 1.138c.375 0 .643.176.805.527.161.348.241.933.241 1.756 0 .814-.082 1.399-.247 1.756-.164.356-.43.534-.799.534-.369 0-.636-.178-.8-.534-.165-.357-.248-.941-.248-1.749 0-.829.082-1.415.243-1.763.162-.35.43-.527.805-.527zm-6.33 7.64c-.5 0-.93.073-1.29.223-.359.15-.651.374-.877.673-.225.302-.39.67-.494 1.106a6.715 6.715 0 0 0-.155 1.54c0 .573.05 1.082.15 1.527.1.442.264.814.49 1.112.222.3.514.525.874.677.36.152.793.229 1.302.229.519 0 .954-.076 1.308-.225.354-.153.643-.376.869-.672.22-.29.38-.66.484-1.111.104-.455.156-.967.156-1.54 0-.573-.05-1.079-.15-1.515a2.923 2.923 0 0 0-.487-1.106 2.084 2.084 0 0 0-.867-.686c-.353-.156-.791-.232-1.313-.232zm5.846.119a.941.941 0 0 1-.138.457.85.85 0 0 1-.356.296 1.71 1.71 0 0 1-.503.137 5.245 5.245 0 0 1-.631.037v1.112h1.435v3.597h-1.435v1.189h4.545v-1.189h-1.405v-5.636h-1.512zm-5.846 1.137c.375 0 .643.176.805.527.162.347.241.933.241 1.756 0 .813-.08 1.399-.245 1.755-.164.357-.432.534-.8.534-.37 0-.637-.177-.802-.534-.164-.356-.245-.939-.245-1.746 0-.83.08-1.418.242-1.765.161-.35.43-.527.804-.527z" fill="#ff6e40"/></symbol><symbol viewBox="0 0 24 24" id="aurelia" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="api" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#apa"/><linearGradient id="apa" x1="-3.881" x2="2.377" y1="-1.442" y2="4.304"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#apb"/><linearGradient id="apb" x1=".729" x2="-.971" y1=".844" y2="-1.477"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="apk" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#apc"/><linearGradient id="apc" x1="-2.839" x2="2.875" y1="-6.936" y2="1.017"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apl" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#apd"/><linearGradient id="apd" x1="-8.212" x2="1.02" y1="-4.691" y2="2.882"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apm" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#ape"/><linearGradient id="ape" x1="-1.404" x2="4.19" y1="-2.309" y2="2.62"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apn" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#apf"/><linearGradient id="apf" x1="1.911" x2=".204" y1="2.539" y2=".204"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="apo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#apg"/><linearGradient id="apg" x1="-3.881" x2="2.377" y1="-1.738" y2="5.19"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="app" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#aph"/><linearGradient id="aph" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><g transform="rotate(11.282 -1.694 21.569) scale(.47102)" clip-rule="evenodd" fill="none" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#api)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#apj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#apk)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#apl)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#apm)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#apn)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#apo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#app)"/></g></symbol><symbol viewBox="0 0 24 24" id="autohotkey" xmlns="http://www.w3.org/2000/svg"><path d="M5 3c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5zm3.668 3.447a.9.9 0 0 1 .652.256.84.84 0 0 1 .262.625c0 .34-.014.852-.041 1.537-.022.68-.033 1.19-.033 1.53 0 .111-.016.326-.047.644a6.149 6.149 0 0 0-.033.68l2.578-.485c1.007-.179 1.874-.281 2.603-.308.018-.3.048-1.105.088-2.416.01-.345.115-.742.317-1.19.25-.55.533-.826.851-.826.237 0 .448.08.631.236.197.17.295.382.295.637a.775.775 0 0 1-.025.201c-.09.327-.135.612-.135.854 0 .125-.014.32-.041.584-.023.26-.033.453-.033.578 0 .425-.022 1.056-.067 1.893a38.963 38.963 0 0 0-.068 1.892c0 .327.025.816.074 1.465.05.649.074 1.136.074 1.463a.84.84 0 0 1-.261.625.893.893 0 0 1-.65.254 1 1 0 0 1-.686-.254.777.777 0 0 1-.29-.611c0-.327-.015-.818-.046-1.471a39.552 39.552 0 0 1-.041-1.47c0-.256.004-.482.013-.679-.702.032-1.57.142-2.603.33-.86.157-1.719.316-2.578.477-.01.304-.042.812-.096 1.523a22.354 22.354 0 0 0-.066 1.538.84.84 0 0 1-.262.625.893.893 0 0 1-.65.253.898.898 0 0 1-.653-.253.84.84 0 0 1-.262-.625c0-.452.038-1.128.114-2.028.08-.9.12-1.575.12-2.027 0-.573.015-1.436.042-2.586.027-1.155.04-2.017.04-2.59a.84.84 0 0 1 .263-.625.895.895 0 0 1 .65-.256z" fill="#4caf50"/></symbol><symbol viewBox="0 0 24 24" id="autoit" xmlns="http://www.w3.org/2000/svg"><defs id="ardefs8"><style id="arstyle4482">.cls-1{fill:#5d83ac}.cls-2{fill:#f0f0f0;fill-rule:evenodd}</style><style id="arstyle4510">.cls-1{fill:#5d83ac}.cls-2{fill:#f0f0f0;fill-rule:evenodd}</style></defs><g id="arg4522" transform="translate(-59.538 -26.404) scale(.0555)"><path d="M12.8 2.133A10.666 10.666 0 0 0 2.136 12.799 10.666 10.666 0 0 0 12.8 23.465a10.666 10.666 0 0 0 10.668-10.666A10.666 10.666 0 0 0 12.8 2.133zm.15 4.713c.456 0 .836.105 1.142.314.306.21.565.469.78.78l6.089 8.812H9.627l1.82-2.506h3.36c.315 0 .589.01.822.03a11.93 11.93 0 0 1-.473-.663 39.13 39.13 0 0 0-.517-.75l-1.748-2.578-4.577 6.467H4.746l6.25-8.813c.204-.281.46-.534.772-.757.31-.224.705-.336 1.181-.336z" transform="matrix(16.89188 0 0 16.89188 1072.761 475.745)" id="arcircle4514" fill="#1976d2" stroke-width=".026"/></g></symbol><symbol viewBox="0 0 213.33333 213.33333" id="babel" xmlns="http://www.w3.org/2000/svg"><path d="M50.22 199.659c-.875-.406-1.261-1.6-.857-2.652.404-1.053.12-1.914-.63-1.914s-1.615.748-1.92 1.663c-.328.983-1.27.302-2.304-1.667-.962-1.831-3.718-5.533-6.126-8.226-9.418-10.535-7.71-27.444 5.432-53.77 12.459-24.96 23.117-39.033 45.966-60.696 30.229-28.66 52.679-46.223 70.587-55.22 10.98-5.518 13.025-5.059 2.778.624-11.004 6.102-11.378 6.359-10.512 7.226.33.33 7.306-2.67 15.504-6.667 15.87-7.737 16.34-7.912 16.34-6.082 0 .652-4.95 3.738-11 6.858-13.062 6.736-12.722 6.48-10.472 7.872 1.117.69 5.428-.582 11.54-3.406 5.367-2.48 10.397-4.508 11.179-4.508 2.755 0-3.928 5.302-11.541 9.157-20.437 10.35-68.937 46.043-68.07 50.097.166.777-5.792 7.639-13.241 15.248-15.257 15.587-26.14 30.002-33.748 44.706-6.379 12.326-7.457 17.734-5.385 26.996 3.482 15.56 11.592 18.366 31.482 10.895 28.228-10.603 45.758-28.704 47.022-48.556.602-9.442-1.317-13.479-8.52-17.93-4.01-2.48-5.268-2.621-12.065-1.365-4.173.771-10.153 2.906-13.289 4.744s-6.455 3.34-7.377 3.34c-.922 0-3.216 1.336-5.096 2.968-1.88 1.633.48-1.13 5.247-6.14 6.82-7.167 7.956-8.9 5.333-8.132-5.208 1.525-10.194 4.33-15.649 8.803-2.76 2.264-.923.175 4.08-4.641 11.565-11.131 21.183-15.97 33.088-16.641 17.097-.966 27.254 5.805 31.964 21.31 2.435 8.017 2.609 10.24 1.353 17.37-1.65 9.361-7.034 21.553-15.593 35.307-4.398 7.067-8.434 11.427-15.588 16.844-9.166 6.94-15.654 11.02-15.654 9.845 0-.295 2.455-2.161 5.455-4.147 8.818-5.835 5.075-5.377-8.326 1.02-6.854 3.27-15.199 6.593-18.542 7.38-7.106 1.675-30.527 3.164-32.846 2.089zm-8.408-19.899c0-1.1-.6-2-1.333-2-.734 0-1.334.9-1.334 2s.6 2 1.333 2c.734 0 1.334-.9 1.334-2zm89.255-8.204c1.53-1.945 2.473-3.845 2.097-4.222-.377-.377-.836-.435-1.02-.13-.182.306-1.787 2.206-3.565 4.223-1.778 2.016-2.571 3.666-1.763 3.666s2.72-1.591 4.25-3.536zm-77.644-1.745c-.82-2.172-1.74-3.7-2.045-3.396-.951.952 1.088 7.345 2.343 7.345.656 0 .522-1.777-.298-3.95zm82.303-27.915c-.837-.837-3.217 2.55-3.184 4.53.012.734.896.178 1.965-1.235 1.07-1.413 1.618-2.896 1.219-3.295zm-66.238-36.904c-1.312-1.312-3.676.702-3.676 3.133 0 2.035.175 2.031 2.254-.047 1.24-1.24 1.88-2.628 1.422-3.086zm39.657.768c4.403-2.196 6.8-3.986 5.333-3.982-2.838.01-16.667 6.028-16.667 7.254 0 1.6 3.717.527 11.333-3.272zm16.667-5.333c0-.733-.9-1.333-2-1.333s-2 .6-2 1.333.9 1.333 2 1.333 2-.6 2-1.333zm-3.334-3.923l5.334-1.104-7.334-.133c-4.033-.073-8.233.45-9.333 1.16-2.539 1.64 3.572 1.682 11.333.077zm35.738-63.976c2.788-1.69 4.765-3.376 4.393-3.748-.947-.947-11.942 5.654-14.237 8.548-1.792 2.258-1.714 2.276 1.44.329a1452.76 1452.76 0 0 1 8.403-5.13z" fill="#ffca28" stroke-width="1.333"/></symbol><symbol viewBox="0 0 400 400" fill-opacity=".05" id="bithound" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.88 0 0 .88 24.121 2.895)" fill="#e53935" fill-opacity="1"><path d="M370.5 207c-1.5-14.8-4.8-29.9-9.5-44-13.5-40.3-38.6-81.6-70.3-110.1-1.4-1.2-6.7-4.4-8.7-3.3-5.2 2.9 4.6 22.8 5.8 26.4 7.4 22 12.1 45.3 6.8 68.3-7.1 30.4-30.4 51.7-61.5 54.3-17.1 1.4-34.3-.5-51.4 1.5-25.6 3-51.7 11.8-68 32.8-1.9 2.4-3.6 5.1-5.2 7.9h-.4c-6.3.7-12.6-2-15.7-3.7-.8-.5-1.6-.9-2.2-1.2-19-10.5-33-34-41.6-53.4-3.9-9-7.2-18.4-9.3-27.9-1-4.3-1.1-8.8-1.3-13.2-.1-2.7.3-6.5-1.2-8.9-3.3-5.2-7.5-.2-8.2 4-1.1 6.9-2.1 13.7-1.8 20.7.5 11.8 3.8 23.5 8 34.5 6.2 16.2 14.9 31.1 26.2 44.4 4.7 5.5 9.7 10.6 15.1 15.3 4.8 4.3 10.9 7.7 14.5 13.2 4.2 6.3 4.9 14.1 4.5 21.4-1 19.3-1.6 37.4 3.9 56.2 4.8 16.7 10.8 33.8 20.8 48.1 5 7.1 11.2 14.6 18 19.9 4.6 3.6 13.3 4 8.3-9.2-11.1-29.3-12.1-59.7 5.2-87.1 14.5-22.8 40.1-43.1 69-39.5 42.5 5.3 72.1 44.3 70 86-.6 11.7-1 21.7-4.7 32.7-1.5 4.4-2.6 10-1.5 14.6 1.8 7.8 10.5 4.9 14.3-.2 10.3-14 21.1-27.6 30.8-42 31.6-47.2 47-101.8 41.3-158.5z"/><path d="M132.4 92.1c.7 2.3 1.4 4.8 1.9 7.5.1 1.1.4 2.3 1 3.4 2.6 6.8 8.9 10.5 14.8 14 3.6 2.2 10.1 4.3 14.1 5.9 5.2 2.1 16.4-.6 21.7-1 12.2-1 23.5-5.3 34.7 1.2-57.4 67.3-3.2 82.3 38.8 49.9 48-37 2.8-124.3 2.8-124.3s-1-6.8-19.2-10.8c-1.7-.9-3.4-1.7-5.1-2.4-18-8.3-34.2 5.3-47.2 16.4-3.8 3.2-7.5 6.4-11.5 9.4-5.4 4-11.2 7.3-17.3 10.2-6.4 3-14 6.4-21.1 6.7-1 0-2.9.2-4.9.6-3.1.3-4.7 1.1-5.4 2.5-1.2 1-2 2.4-1.8 4.2.2 2.5 1.4 4.6 2.7 6.2.4.1.7.3 1 .4z"/></g></symbol><symbol viewBox="0 0 400.00001 399.99999" id="bower" xmlns="http://www.w3.org/2000/svg"><g transform="translate(12.061 33.203) scale(.81733)"><path d="M447.61 200.08c-23.139-22.234-138.85-36.114-175.36-40.154a107.137 107.137 0 0 0 4.517-12.944 146.107 146.107 0 0 1 15.905-5.901c.677 1.997 3.865 9.648 5.682 13.279 73.415 2.025 77.184-54.557 80.17-70.058 2.92-15.157 2.771-29.802 27.953-56.575-37.516-10.933-91.467 16.945-109.54 58.437-6.79-2.545-13.597-4.424-20.328-5.586-4.824-19.46-29.944-73.672-95.863-73.672-83.46 0-174.43 68.853-174.43 185.41 0 97.976 66.891 183.84 104.68 183.84 16.505 0 30.703-12.36 34.036-23.44 2.795 7.597 11.368 31.213 14.184 37.225 4.162 8.89 23.41 16.583 31.833 7.357 10.83 6.017 30.703 9.641 41.534-6.405 20.86 4.412 39.3-8.026 39.702-22.868 10.235-.546 15.256-14.918 13.021-26.363-1.647-8.426-19.248-38.66-26.113-49.098 13.59 11.054 48.013 14.183 52.194.007 21.911 17.198 56.057 8.171 58.765-5.815 26.624 6.917 57.16-8.276 52.146-26.676 42.771-2.958 37.296-48.464 25.296-59.996z" fill="#543729" stroke-width=".973"/><path d="M328.514 103.025c9.212-18.277 20.788-38.234 35.409-50.58-16.093 6.485-31.981 25.873-41.375 46.595a144.914 144.914 0 0 0-14.552-8.132c13.105-27.972 43.555-51.332 77.112-53.157-22.477 20.385-14.498 62.754-32.979 85.183-5.288-5.311-17.43-15.562-23.615-19.909zm-14.53 29.762c.01-.7.272-6.094.763-8.557-1.288-.304-9.3-1.87-13.476-1.772-.304 5.245 2.204 14.17 4.684 19.541 17.075-.358 29.408-5.471 36.667-10.172-6.18-2.88-16.726-5.442-24.745-6.974-.894 1.851-3.097 6.568-3.892 7.934z" fill="#00acee"/><g stroke-width=".973"><path d="M250.54 277.39c.004.024.015.057.018.082-2.165-4.657-4.463-10.314-7.208-17.708 10.688 15.557 44.184 7.533 42.427-6.407 16.395 12.336 50.143-2.055 42.471-19.353 16.423 7.653 35.168-7.745 30.964-14.455 28 5.4 54.832 10.783 63.256 12.938-5.595 9.123-18.339 15.566-37.549 11.089 10.38 14.14-9.773 31.105-37.844 21.76 6.18 13.883-18.814 26.38-47.22 11.91.361 13.889-35.24 15.488-49.315.143zm55.543-70.194c32.497 2.495 86.238 7.34 119.51 11.997-2.102-10.828-7.844-13.921-25.905-18.772-19.425 2.072-68.706 6.913-93.604 6.776z" fill="#2baf2b"/><path d="M285.78 253.36c16.395 12.336 50.143-2.055 42.471-19.353 16.423 7.653 35.168-7.745 30.964-14.455-33.103-6.383-67.84-12.788-75.719-13.908 4.78.254 12.702.797 22.59 1.556 24.899.137 74.18-4.704 93.604-6.775-31.452-7.975-95.666-19.613-140.01-22.48-2.055 3.003-5.833 8.097-12.413 13.51-19.403 41.053-54.557 68.34-93.454 68.34-11.335 0-24.018-1.912-38.233-6.456-8.865 9.497-46.661 16.694-77.329 1.641 24.326 56.961 80.74 94.984 143.19 94.984 52.591 0 75.912-53.704 70.808-67.914-1.238-3.45-6.145-14.889-8.891-22.283 10.689 15.556 44.185 7.532 42.429-6.408z" fill="#ffcc2f"/><path d="M253.91 145.27c4.644-2.526 20.69-12.253 35.981-15.908a67.843 67.843 0 0 1-.536-5.12c-10.032 2.403-28.945 10.51-39.784-.661 22.866 6.9 34.283-6.149 51.09-6.149 10.014 0 24.305 2.798 35.57 7.22-9.061-8.37-38.772-33.63-75.558-33.717-8.213 9.957-17.09 31.526-6.764 54.334z" fill="#cecece"/><path d="M115.58 253.33c14.215 4.544 26.898 6.457 38.233 6.457 38.896 0 74.05-27.29 93.454-68.341-14.351 11.978-39.291 22.228-78.241 22.228 34.694-7.866 64.56-25.156 79.753-50.427-10.68-16.998-22.263-54.603 7.07-84.33-4.512-14.497-26.475-52.766-75.095-52.766-84.85 0-155.17 71.001-155.17 166.15 0 22.525 4.547 43.65 12.67 62.664 30.666 15.054 68.462 7.858 77.327-1.64z" fill="#ef5734"/><path d="M141.03 108.45c0 21.644 17.546 39.191 39.19 39.191s39.192-17.548 39.192-39.191c0-21.644-17.548-39.191-39.192-39.191-21.644 0-39.19 17.547-39.19 39.191z" fill="#ffcc2f"/><path d="M156.76 108.45c0 12.958 10.507 23.463 23.463 23.463 12.96 0 23.464-10.506 23.464-23.463 0-12.959-10.504-23.464-23.464-23.464-12.957 0-23.463 10.506-23.463 23.464z" fill="#543729"/><ellipse cx="180.22" cy="98.044" rx="13.673" ry="8.501" fill="#fff"/></g></g></symbol><symbol viewBox="0 0 140 140" id="browserlist" xmlns="http://www.w3.org/2000/svg"><title>Browserslist logo</title><path d="M70.314 10.066a59.828 59.828 0 0 0-59.828 59.828 59.828 59.828 0 0 0 59.828 59.828 59.828 59.828 0 0 0 59.828-59.828 59.828 59.828 0 0 0-59.828-59.828zm-4.836 8.785c.496 4.043 1.352 7.322 2.572 10.223 4.779-4.287 10.265-7.546 16.041-9.02-.981 3.938-1.357 7.295-1.261 10.43 6.026-2.314 12.349-3.404 18.3-2.706-3.182 2.413-5.482 4.717-7.128 7.015-2.201 12.074 6.858 20.43 14.779 24.551a5.128 5.128 0 0 1 5.183-3.888 5.128 5.128 0 0 1 3.7 8.435v.002c-.487 1.055-2.002 2.343-3.497 3.219-4.075 2.39-11.172 5.736-20.914 7.39.045 1.214.077 2.453.077 3.747 0 4.817-.485 8.291-1.385 10.699-3.3 13.313-12.648 26.76-24.695 31.95.357-4.083.197-7.485-.402-10.591-5.582 3.218-11.646 5.278-17.623 5.52h-.002c1.785-3.662 2.855-6.878 3.412-9.976-6.347.996-12.727.742-18.377-1.17 2.93-2.732 5.054-5.314 6.673-7.96-6.292-1.344-12.169-3.87-16.766-7.686 3.822-1.544 6.795-3.239 9.3-5.197-5.426-3.517-10.034-7.998-12.972-13.23 4.012-.07 7.321-.568 10.3-1.453-3.786-5.215-6.468-11.032-7.333-16.951 3.861 1.405 7.196 2.133 10.36 2.355-1.662-6.22-2.081-12.605-.768-18.436 3.03 2.634 5.824 4.48 8.63 5.815.678-6.406 2.576-12.52 5.893-17.496 1.926 3.622 3.914 6.391 6.111 8.672 2.93-5.754 6.9-10.798 11.791-14.262zm26.465 19.557c-2.395 5.514-1.665 11.297-.555 18.732a2.138 2.138 0 0 0 .28-4.178 3.419 3.419 0 1 1 .092 6.704c.574 3.882 1.157 8.18 1.421 13.125a67.143 67.143 0 0 0 3.25-.649c6.616-1.487 12.258-3.801 16.871-6.506.45-.264.884-.563 1.276-.867.366-.557.333-.957.035-1.285-4.831-1.245-10.891-4.53-15.258-8.795-4.764-4.653-7.428-10.164-7.412-16.281z" fill="#ffca28" stroke-width=".855"/></symbol><symbol viewBox="0 0 140 140" id="browserlist_light" xmlns="http://www.w3.org/2000/svg"><title>Browserslist logo</title><g transform="translate(10.823 10.1)" stroke-width=".855"><circle cx="59.492" cy="59.795" r="59.828" fill="#ffca28"/><path d="M54.656 8.752c-4.89 3.464-8.862 8.508-11.791 14.262-2.198-2.28-4.185-5.05-6.111-8.672-3.318 4.976-5.216 11.09-5.893 17.496-2.807-1.335-5.6-3.18-8.63-5.814-1.314 5.83-.895 12.216.767 18.436-3.164-.223-6.498-.95-10.36-2.356.865 5.92 3.548 11.737 7.333 16.951-2.978.885-6.287 1.383-10.3 1.453 2.939 5.233 7.547 9.714 12.972 13.23-2.505 1.959-5.478 3.654-9.299 5.198 4.596 3.815 10.474 6.341 16.766 7.685-1.62 2.647-3.743 5.228-6.674 7.96 5.65 1.912 12.03 2.166 18.377 1.17-.556 3.098-1.626 6.314-3.412 9.975h.002c5.977-.24 12.042-2.3 17.623-5.52.6 3.108.76 6.51.402 10.593 12.047-5.19 21.395-18.638 24.695-31.951.9-2.408 1.385-5.881 1.385-10.7 0-1.293-.031-2.531-.076-3.745 9.742-1.655 16.839-5.001 20.914-7.39 1.494-.877 3.01-2.165 3.496-3.22v-.002a5.128 5.128 0 0 0-3.7-8.435 5.128 5.128 0 0 0-5.183 3.889c-7.92-4.122-16.98-12.477-14.779-24.551 1.646-2.299 3.947-4.603 7.13-7.016-5.952-.698-12.276.392-18.302 2.707-.095-3.135.28-6.492 1.262-10.43-5.776 1.473-11.262 4.733-16.041 9.02-1.22-2.902-2.076-6.18-2.572-10.223zm26.465 19.557c-.015 6.117 2.648 11.628 7.412 16.281 4.366 4.265 10.426 7.55 15.258 8.795.298.328.331.728-.035 1.285-.392.304-.825.603-1.275.867-4.613 2.704-10.256 5.019-16.871 6.506-1.071.24-2.154.458-3.25.649-.265-4.945-.848-9.243-1.422-13.125a3.419 3.419 0 1 0-.092-6.703 2.138 2.138 0 0 1-.28 4.177c-1.11-7.435-1.84-13.218.555-18.732z" fill="#37474f"/></g></symbol><symbol viewBox="0 0 24 24" id="bucklescript" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18V3H3zm14.1 8.858a5.5 5.5 0 0 1 1.26.145c.417.093.778.213 1.082.357v1.723h-.18a3.281 3.281 0 0 0-.959-.603 2.867 2.867 0 0 0-1.155-.247c-.14 0-.277.011-.416.035a1.4 1.4 0 0 0-.395.12.756.756 0 0 0-.291.231.54.54 0 0 0-.123.348c0 .198.065.35.196.456.13.104.376.2.738.288.237.057.466.11.683.164.22.054.455.128.706.222.496.188.86.444 1.095.77.238.32.357.738.357 1.253 0 .737-.271 1.336-.813 1.798-.538.46-1.27.689-2.197.689a5.447 5.447 0 0 1-1.402-.161 6.725 6.725 0 0 1-1.117-.416v-1.794h.183c.344.318.73.563 1.155.734.429.17.839.256 1.233.256.1 0 .235-.01.4-.03.166-.02.3-.055.403-.102a.97.97 0 0 0 .313-.225c.084-.09.127-.223.127-.4a.568.568 0 0 0-.183-.424c-.119-.12-.294-.213-.526-.276-.243-.067-.5-.128-.773-.185a5.523 5.523 0 0 1-.76-.227c-.544-.204-.936-.48-1.177-.828-.237-.351-.357-.786-.357-1.305 0-.697.27-1.265.81-1.703.54-.442 1.235-.663 2.083-.663zm-8.981.135h2.51c.521 0 .903.02 1.143.06.243.041.484.13.721.266.246.144.43.338.548.583.121.24.181.518.181.83 0 .36-.082.68-.247.959a1.697 1.697 0 0 1-.7.642v.04c.423.098.758.298 1.004.603.249.305.373.706.373 1.205 0 .361-.063.686-.19.97-.125.285-.296.52-.516.707a2.31 2.31 0 0 1-.845.472c-.304.094-.69.141-1.159.141H8.12v-7.478zm1.659 1.372v1.582h.262c.263 0 .486-.007.672-.017.185-.01.332-.043.44-.1.15-.077.248-.175.294-.295.046-.124.07-.266.07-.427a.91.91 0 0 0-.083-.371.518.518 0 0 0-.282-.277 1.187 1.187 0 0 0-.456-.086c-.18-.007-.433-.01-.76-.01h-.157zm0 2.873V18.1H9.9c.469 0 .804-.002 1.007-.006.202-.003.39-.046.56-.13a.712.712 0 0 0 .357-.33c.067-.142.099-.302.099-.483 0-.237-.04-.42-.121-.547-.078-.13-.214-.228-.405-.291a1.842 1.842 0 0 0-.538-.072 49.47 49.47 0 0 0-.716-.003h-.366z" fill="#26a69a" stroke-width="1.067"/></symbol><symbol viewBox="0 0 24 24" id="c" xmlns="http://www.w3.org/2000/svg"><path d="M15.45 15.97l.42 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96-1.14-1.27-1.68-2.88-1.68-4.83C6 9.9 6.68 8.13 8 6.89 9.28 5.64 10.92 5 12.9 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.6 2.49-1.04-.34c-.4-.1-.87-.15-1.4-.15-1.15-.01-2.11.36-2.86 1.1-.76.73-1.14 1.85-1.18 3.34.01 1.36.37 2.42 1.08 3.2.71.77 1.7 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.09-.32z" fill="#0277bd"/></symbol><symbol viewBox="0 0 300 300" id="cabal" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -822.52)" fill-rule="evenodd" color="#000"><rect transform="matrix(-.98339 .18149 .60192 .79856 0 0)" x="405.55" y="967.22" width="107.25" height="156.59" rx="12.306" ry="12.31" fill="#2d9bbd"/><rect transform="matrix(-.98528 .17093 -.59175 .80612 0 0)" x="-1156.5" y="1461.9" width="108.34" height="123.15" rx="10.69" ry="12.31" fill="#4a4bcd"/><path d="M52.112 965.158c-1.343 3.515-26.292 23.248-25.744 27.277.548 4.03 29.812 16.023 32.04 19.027s10.545 41.668 13.603 42.5 18.828-31.274 21.548-32.932c2.72-1.658 32.808 2.503 34.15-1.01 1.343-3.515-18.174-35.352-18.721-39.381-.548-4.03 9.732-40.12 7.502-43.125-2.229-3.005-30.06 9.427-33.118 8.594-3.059-.833-26.793-27.3-29.514-25.643-2.72 1.657-.405 41.177-1.747 44.693z" fill="#2e5bc1"/></g></symbol><symbol viewBox="0 0 24 24" id="cake" xmlns="http://www.w3.org/2000/svg"><path d="M12.254 6.621a1.807 1.807 0 0 0 1.808-1.807c0-.344-.09-.66-.262-.932l-1.546-2.684-1.546 2.684a1.72 1.72 0 0 0-.262.932 1.808 1.808 0 0 0 1.808 1.807m4.158 9.04l-.967-.976-.976.976c-1.175 1.166-3.236 1.175-4.42 0l-.959-.976-.994.976a3.134 3.134 0 0 1-3.977.353v4.167a.904.904 0 0 0 .904.904h14.463a.904.904 0 0 0 .904-.904v-4.167a3.134 3.134 0 0 1-3.977-.353m1.265-6.328h-4.52V7.525H11.35v1.808H6.83a2.712 2.712 0 0 0-2.711 2.712v1.392c0 .977.795 1.772 1.771 1.772.489 0 .94-.18 1.248-.515l1.952-1.926 1.908 1.926c.669.669 1.835.669 2.504 0l1.916-1.926 1.944 1.926c.316.334.768.515 1.247.515.976 0 1.78-.795 1.78-1.772v-1.392a2.712 2.712 0 0 0-2.711-2.712z" fill="#ff7043" stroke-width=".904"/></symbol><symbol viewBox="0 0 24 24" id="certificate" xmlns="http://www.w3.org/2000/svg"><path d="M4 3c-1.11 0-2 .89-2 2v10a2 2 0 0 0 2 2h8v5l3-3 3 3v-5h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4m8 2l3 2 3-2v3.5l3 1.5-3 1.5V15l-3-2-3 2v-3.5L9 10l3-1.5V5M4 5h5v2H4V5m0 4h3v2H4V9m0 4h5v2H4v-2z" fill="#ff5722"/></symbol><symbol viewBox="0 0 24 24" id="changelog" xmlns="http://www.w3.org/2000/svg"><path d="M11 7v5.11l4.71 2.79.79-1.28-4-2.37V7m0-5C8.97 2 5.91 3.92 4.27 6.77L2 4.5V11h6.5L5.75 8.25C6.96 5.73 9.5 4 12.5 4a7.5 7.5 0 0 1 7.5 7.5 7.5 7.5 0 0 1-7.5 7.5c-3.27 0-6.03-2.09-7.06-5h-2.1c1.1 4.03 4.77 7 9.16 7 5.24 0 9.5-4.25 9.5-9.5A9.5 9.5 0 0 0 12.5 2z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 24 24" id="clojure" xmlns="http://www.w3.org/2000/svg"><path d="M3.355 1.78c-.845 0-1.525.68-1.525 1.525v17.441c0 .845.68 1.525 1.525 1.525h17.442c.845 0 1.525-.68 1.525-1.525V3.305c0-.845-.68-1.526-1.525-1.526H3.355zm6.168 2.572h1.963l6.368 14.931H15.93l-3.38-8.086-3.349 8.086H7.21l4.346-10.38-2.032-4.551z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="cmake" xmlns="http://www.w3.org/2000/svg"><path d="M11.99 2.965L2.977 20.999l9.874-8.47-.863-9.564z" fill="#1e88e5"/><path d="M12.007 2.963l.002.29 1.312 14.498-.001.006.023.26 7.362 2.979h.416l-.158-.311-.114-.228h-.002l-8.84-17.494z" fill="#e53935"/><path d="M8.607 16.11L2.98 20.995h17.743v-.016L8.607 16.11z" fill="#7cb342"/></symbol><symbol class="bfmain_logo__svg" viewBox="0 0 300 300.00001" id="code-climate" xmlns="http://www.w3.org/2000/svg"><path class="bfsymbol" d="M196.19 75.562l-51.846 51.561 30.766 30.766 21.08-21.08 59.252 59.537 30.481-30.766zm-61.246 60.961l-30.481-30.481-78.053 78.053-11.964 11.964 30.766 30.766 11.964-12.249 39.596-39.312 7.691-7.691 30.481 30.48 28.772 28.773 30.766-30.766-28.772-28.772z" fill="#eee" stroke-width="2.849"/></symbol><symbol class="bgmain_logo__svg" viewBox="0 0 300 300.00001" id="code-climate_light" xmlns="http://www.w3.org/2000/svg"><path class="bgsymbol" d="M196.19 75.562l-51.846 51.561 30.766 30.766 21.08-21.08 59.252 59.537 30.481-30.766zm-61.246 60.961l-30.481-30.481-78.053 78.053-11.964 11.964 30.766 30.766 11.964-12.249 39.596-39.312 7.691-7.691 30.481 30.48 28.772 28.773 30.766-30.766-28.772-28.772z" fill="#455a64" stroke-width="2.849"/></symbol><symbol viewBox="0 0 24 24" id="coffee" xmlns="http://www.w3.org/2000/svg"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="coldfusion" xmlns="http://www.w3.org/2000/svg"><rect transform="rotate(90)" x="2.283" y="-21.86" width="19.487" height="19.487" ry="0" fill="#0d3858" stroke="#4dd0e1" stroke-width=".7"/><text x="6.653" y="16.426" fill="#4dd0e1" font-family="Calibri" font-size="29.001" font-weight="bold" letter-spacing="0" stroke-width=".725" word-spacing="0" style="line-height:1.25"><tspan x="6.653" y="16.426" font-family="'Segoe UI'" font-size="10.634" font-weight="normal">C<tspan font-size="11.844">f</tspan></tspan></text></symbol><symbol viewBox="0 0 24 24" id="conduct" xmlns="http://www.w3.org/2000/svg"><path d="M10 17l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9m-6-6a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#cddc39"/></symbol><symbol viewBox="0 0 24 24" id="console" xmlns="http://www.w3.org/2000/svg"><path d="M20 19V7H4v12h16m0-16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16m-7 14v-2h5v2h-5m-3.42-4L5.57 9H8.4l3.3 3.3c.39.39.39 1.03 0 1.42L8.42 17H5.59z" fill="#ff7043"/></symbol><symbol viewBox="0 0 24 24" id="contributing" xmlns="http://www.w3.org/2000/svg"><path d="M17 9H7V7h10m0 6H7v-2h10m-3 6H7v-2h7M12 3a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="cpp" xmlns="http://www.w3.org/2000/svg"><path d="M10.5 15.97l.41 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96C1.56 15.77 1 14.16 1 12.21c.05-2.31.72-4.08 2-5.32C4.32 5.64 5.96 5 7.94 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.58 2.49-1.06-.34c-.4-.1-.86-.15-1.39-.15-1.16-.01-2.12.36-2.87 1.1-.76.73-1.15 1.85-1.18 3.34 0 1.36.37 2.42 1.08 3.2.71.77 1.71 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.1-.32M11 11h2V9h2v2h2v2h-2v2h-2v-2h-2v-2m7 0h2V9h2v2h2v2h-2v2h-2v-2h-2v-2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="credits" xmlns="http://www.w3.org/2000/svg"><path d="M3 3h18v2H3V3m4 4h10v2H7V7m-4 4h18v2H3v-2m4 4h10v2H7v-2m-4 4h18v2H3v-2z" fill="#9ccc65"/></symbol><symbol viewBox="0 0 200 200" id="crystal" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:none}</style><path d="M179.363 121.67l-57.623 57.507c-.23.23-.576.346-.806.23l-78.713-21.09c-.346-.115-.577-.345-.577-.576L20.44 79.144c-.115-.345 0-.576.23-.806L78.294 20.83c.23-.23.576-.346.807-.23l78.713 21.09c.345.114.576.345.576.575l21.09 78.597c.23.346.115.577-.115.807zM102.148 59.09l-77.33 20.63c-.115 0-.23.23-.115.345l56.586 56.47c.115.115.346.115.346-.115l20.744-77.215c.115 0-.115-.23-.23-.116z" stroke-width="1.153" fill="#cfd8dc"/></symbol><symbol viewBox="0 0 200 200" id="crystal_light" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:none}</style><path d="M179.363 121.67l-57.623 57.507c-.23.23-.576.346-.806.23l-78.713-21.09c-.346-.115-.577-.345-.577-.576L20.44 79.144c-.115-.345 0-.576.23-.806L78.294 20.83c.23-.23.576-.346.807-.23l78.713 21.09c.345.114.576.345.576.575l21.09 78.597c.23.346.115.577-.115.807zM102.148 59.09l-77.33 20.63c-.115 0-.23.23-.115.345l56.586 56.47c.115.115.346.115.346-.115l20.744-77.215c.115 0-.115-.23-.23-.116z" fill="#37474f" stroke-width="1.153"/></symbol><symbol viewBox="0 0 24 24" id="csharp" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 15.97l.41 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96C2.56 15.77 2 14.16 2 12.21c.05-2.31.72-4.08 2-5.32C5.32 5.64 6.96 5 8.94 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.58 2.49-1.06-.34c-.4-.1-.86-.15-1.39-.15-1.16-.01-2.12.36-2.87 1.1-.76.73-1.15 1.85-1.18 3.34 0 1.36.37 2.42 1.08 3.2.71.77 1.71 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.1-.32M13.89 19l.61-4H13l.34-2h1.5l.32-2h-1.5L14 9h1.5l.61-4h2l-.61 4h1l.61-4h2l-.61 4H22l-.34 2h-1.5l-.32 2h1.5L21 15h-1.5l-.61 4h-2l.61-4h-1l-.61 4h-2m2.95-6h1l.32-2h-1l-.32 2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="css" xmlns="http://www.w3.org/2000/svg"><path d="M5 3l-.65 3.34h13.59L17.5 8.5H3.92l-.66 3.33h13.59l-.76 3.81-5.48 1.81-4.75-1.81.33-1.64H2.85l-.79 4 7.85 3 9.05-3 1.2-6.03.24-1.21L21.94 3H5z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="css-map" xmlns="http://www.w3.org/2000/svg"><path d="M18 8v2h2v10H10v-2H8v4h14V8h-4z" fill="#42a5f5"/><path d="M4.676 3l-.488 2.51h10.211l-.33 1.623H3.864l-.496 2.502H13.58l-.57 2.863-4.119 1.36-3.569-1.36.248-1.232H3.06l-.593 3.005 5.898 2.254 6.8-2.254.902-4.53.18-.91L17.406 3H4.675z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 33 33" id="cucumber" xmlns="http://www.w3.org/2000/svg"><title>cucumber-mark-transparent-pips</title><g transform="translate(0 -5)" fill="none" fill-rule="evenodd"><path d="M-4-1h40v40H-4z"/><path d="M16.641 7.092c-7.028 0-12.714 5.686-12.714 12.714 0 6.187 4.435 11.327 10.288 12.471v3.64C21.824 34.77 28.561 28.73 29.063 20.8c.303-4.772-2.076-9.644-6.09-12.01a10.575 10.575 0 0 0-1.455-.728l-.243-.097c-.223-.082-.448-.175-.68-.242a12.614 12.614 0 0 0-3.954-.632zm2.62 4.707a1.387 1.387 0 0 0-1.213.485c-.233.31-.379.611-.534.923-.466 1.087-.31 2.251.388 3.105 1.087-.233 2.01-.927 2.475-2.014a2.45 2.45 0 0 0 .243-1.02c.048-.824-.634-1.404-1.359-1.479zm-5.654.073c-.708.068-1.382.63-1.382 1.407 0 .31.087.709.243 1.02.466 1.086 1.46 1.78 2.546 2.013.621-.854.782-2.018.316-3.105-.155-.311-.3-.617-.534-.85a1.364 1.364 0 0 0-1.188-.485zm-3.809 3.735c-1.224.063-1.77 1.602-.752 2.402.31.233.612.403.922.559 1.087.466 2.344.306 3.275-.316-.233-1.009-1.023-1.936-2.11-2.402-.388-.155-.703-.243-1.092-.243-.087-.009-.161-.004-.243 0zm11.961 4.708a3.551 3.551 0 0 0-2.013.582c.233 1.01 1.023 1.936 2.11 2.401.389.156.705.244 1.093.244 1.397.077 2.08-1.65.994-2.427-.31-.233-.611-.379-.922-.534a3.354 3.354 0 0 0-1.262-.266zm-10.603.072a3.376 3.376 0 0 0-1.261.267c-.389.155-.69.325-.923.558-1.009.854-.33 2.48 1.068 2.402.388 0 .782-.087 1.092-.243 1.087-.465 1.859-1.392 2.014-2.401a3.474 3.474 0 0 0-1.99-.582zm3.931 2.378c-1.087.233-2.009.927-2.475 2.014-.155.31-.243.684-.243.995-.077 1.32 1.724 2.028 2.5 1.02.233-.312.378-.613.534-.923.466-1.01.306-2.174-.316-3.106zm2.887.073c-.621.854-.781 2.019-.315 3.106.155.31.3.615.534.848.854.932 2.65.243 2.572-.921 0-.31-.088-.71-.243-1.02-.466-1.087-1.46-1.78-2.547-2.013z" fill="#4caf50" stroke-width=".776"/></g></symbol><symbol id="cuda" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><style>.bust0{fill:#76b900}</style><title>NVIDIA-Logo</title><path id="buEye_Mark" class="bust0" d="M76.362 75.199V64.116c1.095-.068 2.19-.137 3.284-.137 30.377-.958 50.286 26.135 50.286 26.135s-21.483 29.83-44.539 29.83c-3.079 0-6.089-.48-8.962-1.438v-33.66c11.836 1.436 14.23 6.636 21.277 18.471l15.804-13.273s-11.562-15.12-30.992-15.12c-2.053-.068-4.105.069-6.158.274m0-36.67v16.556l3.284-.205c42.213-1.437 69.784 34.618 69.784 34.618s-31.608 38.45-64.516 38.45c-2.873 0-5.678-.274-8.483-.753v10.262c2.326.274 4.72.48 7.046.48 30.65 0 52.817-15.668 74.3-34.14 3.558 2.874 18.13 9.784 21.14 12.794-20.388 17.104-67.937 30.856-94.893 30.856-2.6 0-5.062-.137-7.525-.41v14.436h116.44V38.532zm0 79.977v8.757C48.038 122.2 40.17 92.712 40.17 92.712s13.615-15.05 36.192-17.514v9.579h-.068c-11.836-1.437-21.14 9.646-21.14 9.646s5.268 18.678 21.209 24.082M26.077 91.481S42.839 66.714 76.43 64.115v-9.03C39.213 58.094 7.057 89.565 7.057 89.565s18.199 52.68 69.305 57.47v-9.579c-37.492-4.652-50.286-45.975-50.286-45.975z" fill="#8bc34a" stroke-width=".684"/></symbol><symbol viewBox="0 0 24 24" id="dart" xmlns="http://www.w3.org/2000/svg"><title>Dart</title><path d="M12.486 1.385a.978.978 0 0 0-.682.281l-.01.007-6.387 3.692 6.371 6.372v.004l7.659 7.659 1.46-2.63-5.265-12.64-2.456-2.457a.972.972 0 0 0-.69-.288z" fill="#00ca94"/><path d="M5.422 5.35L1.73 11.733l-.007.01a.967.967 0 0 0 .006 1.371l3.059 3.061 11.963 4.706 2.704-1.502-.073-.073-.018.002-7.5-7.512h-.01L5.423 5.35z" fill="#1565c0"/><path d="M5.405 5.353l6.518 6.525h.01l7.502 7.51 2.855-.544.005-8.449-3.016-2.955c-.66-.647-1.675-1.064-2.695-1.202l.002-.032-11.181-.853z" fill="#1565c0"/><path d="M5.414 5.361l6.521 6.522v.009l7.506 7.506-.546 2.855h-8.448l-2.954-3.017c-.647-.66-1.064-1.676-1.2-2.696l-.033.003L5.414 5.36z" fill="#00ee94"/></symbol><symbol viewBox="0 0 24 24" id="database" xmlns="http://www.w3.org/2000/svg"><path d="M12 3C7.58 3 4 4.79 4 7s3.58 4 8 4 8-1.79 8-4-3.58-4-8-4M4 9v3c0 2.21 3.58 4 8 4s8-1.79 8-4V9c0 2.21-3.58 4-8 4s-8-1.79-8-4m0 5v3c0 2.21 3.58 4 8 4s8-1.79 8-4v-3c0 2.21-3.58 4-8 4s-8-1.79-8-4z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="diff" xmlns="http://www.w3.org/2000/svg"><path d="M3 1c-1.11 0-2 .89-2 2v11c0 1.11.89 2 2 2h2v-2H3V3h11v2h2V3c0-1.11-.89-2-2-2H3m6 6c-1.11 0-2 .89-2 2v2h2V9h2V7H9m4 0v2h1v1h2V7h-3m5 0v2h2v11H9v-2H7v2c0 1.11.89 2 2 2h11c1.11 0 2-.89 2-2V9c0-1.11-.89-2-2-2h-2m-4 5v2h-2v2h2c1.11 0 2-.89 2-2v-2h-2m-7 1v3h3v-2H9v-1H7z" fill="#42a5f5"/></symbol><symbol id="docker" viewBox="0 0 41 34.5" xmlns="http://www.w3.org/2000/svg"><style id="bystyle2">.byst0{fill:#fff}.byst1{clip-path:url(#bySVGID_4_)}</style><g id="byg34" transform="translate(.292 1.9)" fill="#0087c9"><g id="byg32"><g id="byg30"><title id="bytitle4">Group 3</title><g id="byg28"><g id="byg26"><g id="byg9"><path id="bySVGID_1_" class="byst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2c1.2 0 2.1.9 2.1 2s-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></g><g id="byg24"><defs id="bydefs12"><path id="bySVGID_2_" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2c1.2 0 2.1.9 2.1 2s-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></defs><clipPath id="bySVGID_4_"><use xlink:href="#bySVGID_2_" id="byuse14" width="100%" height="100%" overflow="visible"/></clipPath><g class="byst1" clip-path="url(#bySVGID_4_)" id="byg22"><g id="byg20"><g id="byg18"><path id="bySVGID_3_" class="byst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></g></g></g></g></g></g></g></symbol><symbol viewBox="0 0 24 24" id="document" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m9 16v-2H6v2h9m3-4v-2H6v2h12z" fill="#42a5f5"/></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" id="drone" xmlns="http://www.w3.org/2000/svg"><g fill="#e0e0e0" transform="translate(9.063 22.346) scale(.71044)"><path d="M128.22.723C32.095.723.39 84.566.39 115.222h77.928S89.36 75.275 128.22 75.275s49.906 39.947 49.906 39.947h77.476c0-30.66-31.257-114.5-127.38-114.5m98.82 134.45h-48.914s-8.55 39.946-49.906 39.946c-41.355 0-49.902-39.948-49.902-39.948H30.255c0 10.25 37.727 82.708 98.443 82.708 60.714 0 98.344-59.604 98.344-82.708"/><circle cx="128" cy="126.08" r="32.768"/></g></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" id="drone_light" xmlns="http://www.w3.org/2000/svg"><g fill="#424242" transform="translate(9.063 22.346) scale(.71044)"><path d="M128.22.723C32.095.723.39 84.566.39 115.222h77.928S89.36 75.275 128.22 75.275s49.906 39.947 49.906 39.947h77.476c0-30.66-31.257-114.5-127.38-114.5m98.82 134.45h-48.914s-8.55 39.946-49.906 39.946c-41.355 0-49.902-39.948-49.902-39.948H30.255c0 10.25 37.727 82.708 98.443 82.708 60.714 0 98.344-59.604 98.344-82.708"/><circle cx="128" cy="126.08" r="32.768"/></g></symbol><symbol viewBox="0 0 3473 3473" id="editorconfig" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><defs id="ccdefs4"><style id="ccstyle2">.ccfil2{fill:#020202}.ccfil0{fill:#e3e3f8}.ccfil5{fill:#efefef}.ccfil6{fill:#faf1f1}.ccfil3{fill:#fdf2f2}.ccfil1{fill:#fdfdfd}.ccfil4{fill:#fef3f3}</style></defs><g id="ccLayer_x0020_1" transform="matrix(.8945 0 0 .8945 138.649 275.985)"><g id="cc_631799120"><g id="ccg11"><path class="ccfil0" d="M967 1895c46-30 84-105 61-158-63 27-60 89-61 158z" id="ccpath7" fill="#e3e3f8"/><path class="ccfil0" d="M1679 2067c50-16 98-72 71-130-39 27-64 64-71 130z" id="ccpath9" fill="#e3e3f8"/></g><g id="ccg21"><path class="ccfil1" d="M280 2895c0 63 16 131 60 155 162 91 730 20 923-23 101-23 183-98 278-139 214-93 369-168 540-293 124-91 321-347 342-500l-169-38c-4 172-43 211-196 251-103 28-304 34-409 16-139-23-202-96-265-179-122-162 27-275-166-286-203 249-561 70-718 45-67 97-224 727-222 871 97-33 158 3 245 37 308 119 39 224-84 193-84-20-110-75-159-110z" id="ccpath13" fill="#fdfdfd"/><path class="ccfil1" d="M683 1458c125 24 236 76 342 129 173 86 204 74 220 194 2 22-2 34 61 54 106 33-61-26 223-25 169 1 556 69 681 148 52 33 42 75 218 70-2-207-57-516-138-706-99-230-230-265-497-351-156-50-614-105-756-17-133 83-158 182-282 356-36 51-49 90-72 148z" id="ccpath15" fill="#fdfdfd"/><path class="ccfil1" d="M1784 1883c100 41-5 306-144 242-45-127 62-199 91-256-60-9-231-36-282-17-66 25-81 166-47 232 160 314 867 247 792 3-30-99-58-115-159-149-81-27-162-55-251-55z" id="ccpath17" fill="#fdfdfd"/><path class="ccfil1" d="M527 1848c80 77 261 89 378 95 15-155 28-271 152-262 61 83 29 181-35 244 109-1 172-83 156-202-92-66-371-198-511-217-39 42-135 272-140 342z" id="ccpath19" fill="#fdfdfd"/></g><path class="ccfil2" d="M339 2838c66-6 238 44 252 100-107 13-243 3-252-100zm-59 57c49 35 75 90 159 110 123 31 392-74 84-193-87-34-148-70-245-37-2-144 155-774 222-871 157 25 515 204 718-45 193 11 44 124 166 286 63 83 126 156 265 179 105 18 306 12 409-16 153-40 192-79 196-251l169 38c-21 153-218 409-342 500-171 125-326 200-540 293-95 41-177 116-278 139-193 43-761 114-923 23-44-24-60-92-60-155zm1399-828c7-66 32-103 71-130 27 58-21 114-71 130zm105-184c89 0 170 28 251 55 101 34 129 50 159 149 75 244-632 311-792-3-34-66-19-207 47-232 51-19 222 8 282 17-29 57-136 129-91 256 139 64 244-201 144-242zm-817 12c1-69-2-131 61-158 23 53-15 128-61 158zm-440-47c5-70 101-300 140-342 140 19 419 151 511 217 16 119-47 201-156 202 64-63 96-161 35-244-124-9-137 107-152 262-117-6-298-18-378-95zm-100-80c-37-102-37-261 120-274l-80 223c-21 48-21 37-40 51zm256-310c23-58 36-97 72-148 124-174 149-273 282-356 142-88 600-33 756 17 267 86 398 121 497 351 81 190 136 499 138 706-176 5-166-37-218-70-125-79-512-147-681-148-284-1-117 58-223 25-63-20-59-32-61-54-16-120-47-108-220-194-106-53-217-105-342-129zm1770-49c-19-63 16-59 77-102 35-25 63-51 106-75 161-90 461-105 589 2 52 43 137 127 124 237-27 219-177 339-300 439-125 102-333 207-548 137-18-44-4-323-25-426-19-92-9-102 44-157 156-162 494-280 686-141 81 60 58 83 100 129 52-56-45-244-403-232-243 8-348 198-450 189zM997 840c5-139 133-427 261-527 155-120 317-233 555-98 59 33 56 50 62 132 5 79-2 108-22 172-158 510-290 217-796 338 19-166 163-314 243-391 137-133 236-219 442-191 57 95 63 155-6 266-92 148-115 139-101 240 72-18 94-88 127-158 201-420-91-471-270-394-120 51-334 287-404 429-14 28-29 64-42 95zm792 21c21-125 145-156 145-541 0-166-204-315-471-204-229 94-264 166-386 350-115 174-111 365-210 526-29 46-55 62-87 108-23 34-40 77-63 117-47 77-95 133-133 225-120 3-221 5-233 129-16 170 64 212 64 276-1 69-281 765-203 1180 22 114 97 115 217 129 289 35 664 23 923-81l470-225c119-67 319-194 408-287 63-65 96-120 150-197 74-108 76-106 92-253 98 18 281 61 342 114-7 69-41 36-41 98 39 1 104-48 120-102-41-60-84-50-143-98 47-37 132-54 197-81 140-58 379-234 438-394 47-129 12-344-64-428-80-88-266-133-418-133-181 0-368 130-514 186-56-49-60-105-101-159-47-64-353-224-499-255z" id="ccpath23" fill="#020202"/><path class="ccfil3" d="M2453 1409c102 9 207-181 450-189 358-12 455 176 403 232-42-46-19-69-100-129-192-139-530-21-686 141-53 55-63 65-44 157 21 103 7 382 25 426 215 70 423-35 548-137 123-100 273-220 300-439 13-110-72-194-124-237-128-107-428-92-589-2-43 24-71 50-106 75-61 43-96 39-77 102z" id="ccpath25" fill="#fdf2f2"/><path class="ccfil4" d="M997 840l49-87c13-31 28-67 42-95 70-142 284-378 404-429 179-77 471-26 270 394-33 70-55 140-127 158-14-101 9-92 101-240 69-111 63-171 6-266-206-28-305 58-442 191-80 77-224 225-243 391 506-121 638 172 796-338 20-64 27-93 22-172-6-82-3-99-62-132-238-135-400-22-555 98-128 100-256 388-261 527z" id="ccpath27" fill="#fef3f3"/><path class="ccfil5" d="M427 1768c19-14 19-3 40-51l80-223c-157 13-157 172-120 274z" id="ccpath29" fill="#efefef"/><path class="ccfil6" d="M591 2938c-14-56-186-106-252-100 9 103 145 113 252 100z" id="ccpath31" fill="#faf1f1"/></g></g></symbol><symbol viewBox="0 0 24 24" id="elixir" xmlns="http://www.w3.org/2000/svg"><path d="M12.431 22.383c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.774-8.172 4.916-10.91 1.014-1.295 2.931-2.321 2.931-2.321s-.982 5.238 1.683 7.318c2.365 1.847 4.105 4.25 4.105 6.363 0 4.232-2.784 7.68-6.645 7.68z" fill="#9575cd" stroke-width="1.256"/></symbol><symbol viewBox="0 0 323.00001 322.99999" id="elm" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.8053 0 0 .8053 30.106 31.524)"><path fill="#f0ad00" d="M160.8 153.865l68.028-68.03H92.77z"/><path fill="#7fd13b" d="M160.983 5.098H12.033l68.524 68.525H229.51z"/><path fill="#7fd13b" stroke-width=".974" d="M243.906 88.021l74.136 74.137-74.474 74.475-74.137-74.137z"/><path fill="#60b5cc" d="M318.2 145.045V5.098H178.252z"/><path fill="#5a6378" d="M152.164 162.499L3.4 13.733v297.533z"/><path fill="#f0ad00" d="M252.205 245.27l65.995 65.996v-131.99z"/><path fill="#60b5cc" d="M160.8 171.134L12.034 319.899h297.53z"/></g></symbol><symbol viewBox="0 0 24 24" id="email" xmlns="http://www.w3.org/2000/svg"><path d="M20 8l-8 5-8-5V6l8 5 8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 30 30" id="erlang" xmlns="http://www.w3.org/2000/svg"><path style="line-height:1.25;-inkscape-font-specification:'Wide Latin'" d="M5.217 4.367c-.048.052-.097.1-.144.153C2.697 7.182 1.51 10.798 1.51 15.366c0 4.418 1.156 7.862 3.46 10.34h19.414c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52-1.454 1.381c-.866.773-.845.931-2.314 1.78-1.496.674-3.04.966-4.634.966-2.516 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.097-6.68l17.458.067-.182-1.472s-.847-7.129-2.542-9.372zm8.76.846c1.565 0 3.22.535 3.96 1.471.742.937.932 1.667.974 3.524H9.12c.111-1.955.436-2.81 1.372-3.697.937-.888 2.03-1.298 3.484-1.298z" font-weight="400" font-size="48" font-family="Wide Latin" letter-spacing="0" word-spacing="0" fill="#f44336" stroke-width=".97"/></symbol><symbol viewBox="0 0 299.99999 300.00001" id="eslint" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-2.88 18.438) scale(1.0344)"><path d="M97.021 99.016l48.432-27.962c1.212-.7 2.706-.7 3.918 0l48.433 27.962a3.92 3.92 0 0 1 1.959 3.393v55.924a3.924 3.924 0 0 1-1.959 3.394l-48.433 27.962c-1.212.7-2.706.7-3.918 0l-48.432-27.962a3.92 3.92 0 0 1-1.959-3.394v-55.924a3.922 3.922 0 0 1 1.959-3.393" fill="#7986cb"/><path d="M273.34 124.49L215.473 23.82c-2.102-3.64-5.985-6.325-10.188-6.325H89.545c-4.204 0-8.088 2.685-10.19 6.325L21.488 124.27c-2.102 3.641-2.102 8.236 0 11.877l57.867 99.847c2.102 3.64 5.986 5.501 10.19 5.501h115.74c4.203 0 8.087-1.805 10.188-5.446l57.867-100.01c2.104-3.639 2.104-7.907.001-11.547m-47.917 48.41c0 1.48-.891 2.849-2.174 3.59l-73.71 42.527a4.194 4.194 0 0 1-4.17 0l-73.767-42.527c-1.282-.741-2.179-2.109-2.179-3.59V87.847c0-1.481.884-2.849 2.167-3.59l73.707-42.527a4.185 4.185 0 0 1 4.168 0l73.772 42.527c1.283.741 2.186 2.109 2.186 3.59z" fill="#3f51b5"/></g></symbol><symbol viewBox="0 0 24 24" id="exe" xmlns="http://www.w3.org/2000/svg"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14m0 14V8H5v10h14z" fill="#e64a19"/></symbol><symbol viewBox="0 0 24 24" id="favicon" xmlns="http://www.w3.org/2000/svg"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2 9.19 8.62 2 9.24l5.45 4.73L5.82 21 12 17.27z" fill="#ffd54f"/></symbol><symbol viewBox="0 0 24 24" id="file" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m5 2H6v16h12v-9h-7V4z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 400 400" id="firebase" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 103)"><path d="M72.55 208.77l44.456-292.29 56.209 90.445L195.49-37.57 330.6 209.28z" fill="#ffa712"/><path d="M195.7 276.73l134.9-67.45-46.5-224.83L72.55 208.77z" fill="#fcca3f"/><path d="M173.22 6.932L72.56 208.772l136.35-144.58z" fill="#f6820c"/></g></symbol><symbol viewBox="0 0 24 24" id="flash" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="cma"><stop offset="0" stop-color="#d92f3c"/><stop offset="1" stop-color="#791223"/></linearGradient><linearGradient xlink:href="#cma" id="cmb" x1="2.373" y1="12.027" x2="21.86" y2="12.027" gradientUnits="userSpaceOnUse" gradientTransform="translate(-.09 -24.144)"/></defs><rect width="19.487" height="19.487" x="2.283" y="-21.86" transform="rotate(90)" ry="0" fill="url(#cmb)"/><path style="line-height:125%" d="M16.802 5.768l-.013.002a6.43 6.43 0 0 0-1.182.192 5.062 5.062 0 0 0-1.494.718c-.428.323-.817.72-1.17 1.191-.34.48-.682 1.032-1.022 1.66-.12.228-.233.424-.35.636v.002h-.004l-1.34 2.394-.005-.002c-.238.443-.461.847-.665 1.198a4.358 4.358 0 0 1-.716.94 2.79 2.79 0 0 1-.907.594c-.072.027-.161.042-.242.063h-.989v2.414h.989v-.002a6.427 6.427 0 0 0 1.185-.192 5.062 5.062 0 0 0 1.494-.718 5.94 5.94 0 0 0 1.171-1.191c.34-.48.681-1.033 1.021-1.66.12-.228.235-.425.353-.637l.006.002.003-.005.037-.066h2.53v.002h1.124v-2.408h-.33v-.001h-1.98c.22-.407.432-.789.621-1.115.214-.37.452-.682.717-.94a2.79 2.79 0 0 1 .906-.594c.07-.027.16-.041.239-.061h.992V8.18h-.002V5.77h-.977v-.002z" font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#fff"/></symbol><symbol class="cnflow-logo" viewBox="0 0 299.99999 300" id="flow" xmlns="http://www.w3.org/2000/svg"><title>Flow logo</title><path d="M38.75 33.427l77.461 77.47H54.436l61.145 61.16H38.437l93.462 93.478v-77.158l.01-.01v-77.47h-.01V66.982h46.691l20.394 20.393H153.57v76.531h22.05l24.474 24.473h-15.806l-.01-.01v.01h-31.665l-.01-.01v.01h-.313l.313.313v77.148h109.149l-39.2-39.2v-15.806l8.465 8.466v-77.37h-15.682l.017-38.191 30.09 30.086V56.362h-64.874l-22.94-22.934H113.71z" fill="#fbc02d" fill-opacity=".976" stroke-width=".955" class="cnflow-logo-mark"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-aurelia" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="coa" x1="-388.15%" x2="237.68%" y1="-144.18%" y2="430.41%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cob" x1="72.945%" x2="-97.052%" y1="84.424%" y2="-147.7%"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="coc" x1="-283.88%" x2="287.54%" y1="-693.6%" y2="101.71%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cod" x1="-821.19%" x2="101.99%" y1="-469.05%" y2="288.24%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="coe" x1="-140.36%" x2="419.01%" y1="-230.93%" y2="261.98%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cof" x1="191.08%" x2="20.358%" y1="253.95%" y2="20.403%"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cog" x1="-388.09%" x2="237.67%" y1="-173.85%" y2="518.99%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="coi" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#coa"/><linearGradient id="coj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#cob"/><linearGradient id="cok" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#coc"/><linearGradient id="col" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#cod"/><linearGradient id="com" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#coe"/><linearGradient id="con" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#cof"/><linearGradient id="coo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cog"/><linearGradient id="cop" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#coh"/><linearGradient id="coh" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#f06292" fill-rule="nonzero"/><g transform="matrix(.31022 .0619 -.0619 .31022 11.807 7.546)" fill="none"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#coi)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#coj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#cok)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#col)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#com)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#con)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#coo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#cop)"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-aurelia-open" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="cpi" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cpa"/><linearGradient id="cpa" x1="-3.881" x2="2.377" y1="-1.442" y2="4.304"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#cpb"/><linearGradient id="cpb" x1=".729" x2="-.971" y1=".844" y2="-1.477"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cpk" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#cpc"/><linearGradient id="cpc" x1="-2.839" x2="2.875" y1="-6.936" y2="1.017"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpl" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#cpd"/><linearGradient id="cpd" x1="-8.212" x2="1.02" y1="-4.691" y2="2.882"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpm" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#cpe"/><linearGradient id="cpe" x1="-1.404" x2="4.19" y1="-2.309" y2="2.62"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpn" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#cpf"/><linearGradient id="cpf" x1="1.911" x2=".204" y1="2.539" y2=".204"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cpo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cpg"/><linearGradient id="cpg" x1="-3.881" x2="2.377" y1="-1.738" y2="5.19"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpp" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#cph"/><linearGradient id="cph" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#f06292" fill-rule="nonzero"/><g transform="matrix(.31022 .0619 -.0619 .31022 11.807 7.546)" fill="none"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#cpi)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#cpj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#cpk)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#cpl)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#cpm)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#cpn)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#cpo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#cpp)"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-components" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#cddc39" fill-rule="nonzero"/><path d="M11.185 9.613h5.346v2.9l3.782-3.775 3.775 3.775-3.775 3.782h2.9v5.346h-5.346v-5.346h2.446l-3.782-3.782v2.446h-5.346V9.613m0 6.682h5.346v5.346h-5.346z" fill="#f0f4c3" stroke-width=".668"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-components-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#cddc39"/><path d="M11.185 9.613h5.346v2.9l3.782-3.775 3.775 3.775-3.775 3.782h2.9v5.346h-5.346v-5.346h2.446l-3.782-3.782v2.446h-5.346V9.613m0 6.682h5.346v5.346h-5.346z" fill="#f0f4c3" stroke-width=".668"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-config" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00acc1" fill-rule="nonzero"/><path d="M17.293 17.786a2.308 2.308 0 0 1-2.308-2.308 2.308 2.308 0 0 1 2.308-2.307 2.308 2.308 0 0 1 2.308 2.307 2.308 2.308 0 0 1-2.308 2.308m4.899-1.668c.026-.211.046-.422.046-.64 0-.217-.02-.435-.046-.659l1.391-1.075a.333.333 0 0 0 .08-.422l-1.32-2.28c-.079-.146-.257-.205-.402-.146l-1.641.66a4.779 4.779 0 0 0-1.115-.647l-.244-1.747a.333.333 0 0 0-.33-.277h-2.637a.333.333 0 0 0-.33.277l-.243 1.747a4.78 4.78 0 0 0-1.114.646l-1.642-.659a.324.324 0 0 0-.402.145l-1.319 2.281a.325.325 0 0 0 .08.422l1.39 1.075c-.026.224-.046.442-.046.66s.02.428.046.639l-1.39 1.094a.325.325 0 0 0-.08.422l1.319 2.282c.079.145.257.197.402.145l1.642-.666c.342.264.698.488 1.114.653l.244 1.747a.333.333 0 0 0 .33.277h2.637a.333.333 0 0 0 .33-.277l.243-1.747a4.802 4.802 0 0 0 1.115-.653l1.641.666c.145.052.323 0 .403-.145l1.318-2.282a.333.333 0 0 0-.079-.422z" fill="#80deea" stroke-width=".659"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-config-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00acc1"/><path d="M17.293 17.786a2.308 2.308 0 0 1-2.308-2.308 2.308 2.308 0 0 1 2.308-2.307 2.308 2.308 0 0 1 2.308 2.307 2.308 2.308 0 0 1-2.308 2.308m4.899-1.668c.026-.211.046-.422.046-.64 0-.217-.02-.435-.046-.659l1.391-1.075a.333.333 0 0 0 .08-.422l-1.32-2.28c-.079-.146-.257-.205-.402-.146l-1.641.66a4.779 4.779 0 0 0-1.115-.647l-.244-1.747a.333.333 0 0 0-.33-.277h-2.637a.333.333 0 0 0-.33.277l-.243 1.747a4.78 4.78 0 0 0-1.114.646l-1.642-.659a.324.324 0 0 0-.402.145l-1.319 2.281a.325.325 0 0 0 .08.422l1.39 1.075c-.026.224-.046.442-.046.66s.02.428.046.639l-1.39 1.094a.325.325 0 0 0-.08.422l1.319 2.282c.079.145.257.197.402.145l1.642-.666c.342.264.698.488 1.114.653l.244 1.747a.333.333 0 0 0 .33.277h2.637a.333.333 0 0 0 .33-.277l.243-1.747a4.802 4.802 0 0 0 1.115-.653l1.641.666c.145.052.323 0 .403-.145l1.318-2.282a.333.333 0 0 0-.079-.422z" fill="#80deea" stroke-width=".659"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-css" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#42a5f5" fill-rule="nonzero"/><path d="M12.488 9.415l-.44 2.259h9.188l-.298 1.46h-9.18l-.447 2.251H20.5l-.514 2.576-3.705 1.224-3.211-1.224.223-1.109h-2.258l-.534 2.704 5.307 2.029 6.118-2.029.812-4.076.162-.818 1.041-5.247H12.488z" fill-rule="nonzero" fill="#bbdefb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-css-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#42a5f5" fill-rule="nonzero"/><path d="M12.488 9.415l-.44 2.259h9.188l-.298 1.46h-9.18l-.447 2.251H20.5l-.514 2.576-3.705 1.224-3.211-1.224.223-1.109h-2.258l-.534 2.704 5.307 2.029 6.118-2.029.812-4.076.162-.818 1.041-5.247H12.488z" fill-rule="nonzero" fill="#bbdefb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-dist" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#e57373" fill-rule="nonzero"/><path d="M18.575 11.113h-2.576V9.825h2.576m3.864 1.288h-2.576V9.825l-1.288-1.288h-2.576L14.71 9.825v1.288h-2.577c-.715 0-1.288.573-1.288 1.288v7.085a1.288 1.288 0 0 0 1.288 1.288H22.44a1.288 1.288 0 0 0 1.288-1.288V12.4c0-.715-.58-1.288-1.288-1.288z" fill="#ffcdd2" stroke-width=".644"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-dist-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#e57373"/><path d="M18.575 11.113h-2.576V9.825h2.576m3.864 1.288h-2.576V9.825l-1.288-1.288h-2.576L14.71 9.825v1.288h-2.577c-.715 0-1.288.573-1.288 1.288v7.085a1.288 1.288 0 0 0 1.288 1.288H22.44a1.288 1.288 0 0 0 1.288-1.288V12.4c0-.715-.58-1.288-1.288-1.288z" fill="#ffcdd2" fill-rule="evenodd" stroke-width=".644"/></symbol><symbol id="folder-docker" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs id="cydefs10"><path id="cySVGID_2_" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></defs><path id="cypath2" d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><style id="cystyle2">.cyst0{fill:#fff}.cyst1{clip-path:url(#cySVGID_4_)}</style><g id="cyg34" transform="translate(8.319 9.626) scale(.39491)" fill="#b3e5fc"><g id="cyg32"><g id="cyg30"><title id="cytitle4">Group 3</title><g id="cyg28"><g id="cyg26"><g id="cyg9"><path id="cySVGID_1_" class="cyst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></g><g id="cyg24"><clipPath id="cySVGID_4_"><use id="cyuse14" width="100%" height="100%" xlink:href="#cySVGID_2_"/></clipPath><g id="cyg22" class="cyst1" clip-path="url(#cySVGID_4_)"><g id="cyg20"><g id="cyg18"><path id="cySVGID_3_" class="cyst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></g></g></g></g></g></g></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docker-open" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="cza"><use width="100%" height="100%" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#SVGID_2_"/></clipPath></defs><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><g transform="matrix(.3949 0 0 .39489 8.319 9.626)" fill="#b3e5fc"><title>Group 3</title><path class="czst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/><g class="czst1" clip-path="url(#cza)"><path class="czst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docs" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#0277bd" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m6.075 10.8v-1.35H13.85v1.35h6.075m2.025-2.7v-1.35h-8.1v1.35h8.1z" fill-rule="nonzero" fill="#b3e5fc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docs-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#0277bd" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m6.075 10.8v-1.35H13.85v1.35h6.075m2.025-2.7v-1.35h-8.1v1.35h8.1z" fill-rule="nonzero" fill="#b3e5fc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-expo" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#01579b" fill-rule="nonzero"/><style>.dcst0{fill:#1173b6}.st1{fill:#585d67}</style><path class="dcst0" d="M18.575 9.82c-.489-.745-.605-.844-1.6-.844h-.024c-.996 0-1.106.099-1.601.844-.46.699-5.024 9.058-5.024 9.291 0 .338.087.658.402 1.112.32.46.873.716 1.275.309.273-.274 3.201-5.321 4.616-7.23a.425.425 0 0 1 .693 0c1.414 1.909 4.343 6.956 4.616 7.23.402.407.955.15 1.275-.309.314-.454.402-.774.402-1.112-.006-.233-4.57-8.598-5.03-9.291z" fill="#1173b6" stroke-width=".058"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-expo-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#01579b"/><path class="ddst0" d="M18.575 9.82c-.489-.745-.605-.844-1.6-.844h-.024c-.996 0-1.106.099-1.601.844-.46.699-5.024 9.058-5.024 9.291 0 .338.087.658.402 1.112.32.46.873.716 1.275.309.273-.274 3.201-5.321 4.616-7.23a.425.425 0 0 1 .693 0c1.414 1.909 4.343 6.956 4.616 7.23.402.407.955.15 1.275-.309.314-.454.402-.774.402-1.112-.006-.233-4.57-8.598-5.03-9.291z" fill="#1173b6" stroke-width=".058" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-font" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef9a9a" fill-rule="nonzero"/><path d="M14.62 17.403l2.38-6.33 2.37 6.33m-3.37-9l-5.5 14h2.25l1.12-3h6.25l1.13 3h2.25l-5.5-14h-2z" fill="#f44336" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-font-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef9a9a" fill-rule="nonzero"/><path d="M14.62 17.403l2.38-6.33 2.37 6.33m-3.37-9l-5.5 14h2.25l1.12-3h6.25l1.13 3h2.25l-5.5-14h-2z" fill="#f44336" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-git" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ff8a65" fill-rule="nonzero"/><path d="M10.43 14.14l4.044-4.052 1.183 1.19a1.387 1.387 0 0 0 .65 1.56v3.877c-.42.238-.699.693-.699 1.21 0 .768.632 1.4 1.4 1.4.767 0 1.4-.632 1.4-1.4 0-.517-.28-.972-.7-1.21v-3.4l1.448 1.462c-.05.105-.05.224-.05.35 0 .767.633 1.399 1.4 1.399.768 0 1.4-.632 1.4-1.4 0-.767-.632-1.4-1.4-1.4-.126 0-.245 0-.35.05l-1.798-1.799a1.385 1.385 0 0 0-.805-1.637c-.3-.112-.615-.14-.895-.063l-1.19-1.183.553-.545a1.381 1.381 0 0 1 1.973 0l5.591 5.59a1.381 1.381 0 0 1 0 1.974l-5.59 5.591a1.381 1.381 0 0 1-1.974 0l-5.591-5.59a1.381 1.381 0 0 1 0-1.974z" fill="#e64a19" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-git-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ff8a65" fill-rule="nonzero"/><path d="M10.43 14.14l4.044-4.052 1.183 1.19a1.387 1.387 0 0 0 .65 1.56v3.877c-.42.238-.699.693-.699 1.21 0 .768.632 1.4 1.4 1.4.767 0 1.4-.632 1.4-1.4 0-.517-.28-.972-.7-1.21v-3.4l1.448 1.462c-.05.105-.05.224-.05.35 0 .767.633 1.399 1.4 1.399.768 0 1.4-.632 1.4-1.4 0-.767-.632-1.4-1.4-1.4-.126 0-.245 0-.35.05l-1.798-1.799a1.385 1.385 0 0 0-.805-1.637c-.3-.112-.615-.14-.895-.063l-1.19-1.183.553-.545a1.381 1.381 0 0 1 1.973 0l5.591 5.59a1.381 1.381 0 0 1 0 1.974l-5.59 5.591a1.381 1.381 0 0 1-1.974 0l-5.591-5.59a1.381 1.381 0 0 1 0-1.974z" fill="#e64a19" fill-rule="nonzero"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-global" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#5c6bc0" fill-rule="nonzero"/><path d="M21.132 18.585a1.22 1.22 0 0 0-1.156-.846h-.609v-1.825a.608.608 0 0 0-.608-.609h-3.65v-1.217h1.216a.608.608 0 0 0 .609-.608v-1.217h1.217a1.217 1.217 0 0 0 1.216-1.217v-.25a4.858 4.858 0 0 1 1.765 7.79m-4.198 1.545a4.86 4.86 0 0 1-4.26-4.826c0-.377.049-.742.128-1.089l2.915 2.915v.608a1.217 1.217 0 0 0 1.217 1.217m.608-9.735a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.085-6.085 6.085 6.085 0 0 0-6.085-6.084z" fill="#c5cae9" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-global-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#5c6bc0"/><path d="M21.133 18.585a1.22 1.22 0 0 0-1.156-.846h-.609v-1.825a.608.608 0 0 0-.608-.609h-3.65v-1.217h1.216a.608.608 0 0 0 .609-.608v-1.217h1.217a1.217 1.217 0 0 0 1.216-1.217v-.25a4.858 4.858 0 0 1 1.765 7.79m-4.198 1.545a4.86 4.86 0 0 1-4.26-4.826c0-.377.049-.742.128-1.089l2.915 2.915v.608a1.217 1.217 0 0 0 1.216 1.217m.609-9.735a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.085-6.085 6.085 6.085 0 0 0-6.085-6.084z" fill="#c5cae9" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-i18n" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#5c6bc0" fill-rule="nonzero"/><path d="M17.293 17.786l-1.53-1.512.018-.018a10.555 10.555 0 0 0 2.235-3.934h1.765v-1.205h-4.217V9.912h-1.205v1.205h-4.217v1.205h6.73a9.5 9.5 0 0 1-1.91 3.223 9.424 9.424 0 0 1-1.392-2.018h-1.205c.44.982 1.042 1.91 1.795 2.747l-3.067 3.024.856.856 3.012-3.013 1.874 1.874.458-1.229m3.392-3.054H19.48l-2.711 7.23h1.205l.674-1.808h2.862l.68 1.807h1.206l-2.711-7.23m-1.579 4.218l.976-2.609.976 2.609z" fill="#c5cae9" stroke-width=".602"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-i18n-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#5c6bc0"/><path d="M17.293 17.786l-1.53-1.512.018-.018a10.555 10.555 0 0 0 2.235-3.934h1.765v-1.205h-4.217V9.912h-1.205v1.205h-4.217v1.205h6.73a9.5 9.5 0 0 1-1.91 3.223 9.424 9.424 0 0 1-1.392-2.018h-1.205c.44.982 1.042 1.91 1.795 2.747l-3.067 3.024.856.856 3.012-3.013 1.874 1.874.458-1.229m3.392-3.054H19.48l-2.711 7.23h1.205l.674-1.808h2.862l.68 1.807h1.206l-2.711-7.23m-1.579 4.218l.976-2.609.976 2.609z" fill="#c5cae9" stroke-width=".602"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-images" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#009688" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m0 12.15h8.1v-5.4l-2.7 2.7-1.35-1.35-4.05 4.05m1.35-7.425c-.74 0-1.35.61-1.35 1.35s.61 1.35 1.35 1.35 1.35-.61 1.35-1.35-.61-1.35-1.35-1.35z" fill-rule="nonzero" fill="#b2dfdb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-images-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#009688" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m0 12.15h8.1v-5.4l-2.7 2.7-1.35-1.35-4.05 4.05m1.35-7.425c-.74 0-1.35.61-1.35 1.35s.61 1.35 1.35 1.35 1.35-.61 1.35-1.35-.61-1.35-1.35-1.35z" fill-rule="nonzero" fill="#b2dfdb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-include" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><path d="M20.788 15.981h-2.434v2.434h-1.217V15.98h-2.434v-1.217h2.434V12.33h1.217v2.434h2.434m-3.042-5.476a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.084-6.085 6.085 6.085 0 0 0-6.084-6.084z" fill="#b3e5fc" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-include-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><path d="M20.788 15.981h-2.434v2.434h-1.217V15.98h-2.434v-1.217h2.434V12.33h1.217v2.434h2.434m-3.042-5.476a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.084-6.085 6.085 6.085 0 0 0-6.084-6.084z" fill="#b3e5fc" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-javascript" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ffca28" fill-rule="nonzero"/><path d="M17.935 18.374a2.18 2.18 0 0 0 1.972 1.213c.829 0 1.354-.415 1.354-.987 0-.682-.542-.927-1.452-1.324l-.502-.216c-1.435-.613-2.404-1.378-2.404-3.005 0-1.5 1.167-2.638 2.917-2.638a2.957 2.957 0 0 1 2.842 1.599l-1.552.999a1.362 1.362 0 0 0-1.29-.858.873.873 0 0 0-.957.858c0 .583.374.84 1.226 1.213l.502.216c1.697.733 2.654 1.47 2.654 3.139 0 1.798-1.411 2.783-3.308 2.783a3.839 3.839 0 0 1-3.618-2.046zm-7.048.175c.315.583.583 1.027 1.283 1.027s1.066-.256 1.066-1.255v-6.774h1.998v6.804c0 2.064-1.214 3.01-2.982 3.01a3.104 3.104 0 0 1-2.993-1.826z" fill-rule="nonzero" fill="#ffecb3"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-javascript-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ffca28"/><path d="M17.935 18.374a2.18 2.18 0 0 0 1.972 1.213c.829 0 1.354-.415 1.354-.987 0-.682-.542-.927-1.452-1.324l-.502-.216c-1.435-.613-2.404-1.378-2.404-3.005 0-1.5 1.167-2.638 2.917-2.638a2.957 2.957 0 0 1 2.842 1.599l-1.552.999a1.362 1.362 0 0 0-1.29-.858.873.873 0 0 0-.957.858c0 .583.374.84 1.226 1.213l.502.216c1.697.733 2.654 1.47 2.654 3.139 0 1.798-1.412 2.783-3.308 2.783a3.839 3.839 0 0 1-3.618-2.046zm-7.048.175c.315.583.583 1.027 1.283 1.027s1.066-.256 1.066-1.255v-6.774h1.998v6.804c0 2.064-1.214 3.01-2.982 3.01a3.104 3.104 0 0 1-2.993-1.826z" fill="#ffecb3"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-lib" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#c0ca33" fill-rule="nonzero"/><path d="M17.39 12.544a2.05 2.05 0 0 0 2.05-2.05 2.05 2.05 0 0 0-2.05-2.052 2.05 2.05 0 0 0-2.05 2.051 2.05 2.05 0 0 0 2.05 2.051m0 2.42a8.992 8.992 0 0 0-6.152-2.42v7.52c2.392 0 4.539.923 6.152 2.42a8.992 8.992 0 0 1 6.152-2.42v-7.52c-2.392 0-4.539.923-6.152 2.42z" fill="#f0f4c3" stroke-width=".684"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-lib-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#c0ca33"/><path d="M17.391 12.543a2.05 2.05 0 0 0 2.05-2.05 2.05 2.05 0 0 0-2.05-2.052 2.05 2.05 0 0 0-2.05 2.051 2.05 2.05 0 0 0 2.05 2.051m0 2.42a8.992 8.992 0 0 0-6.152-2.42v7.52c2.392 0 4.539.923 6.152 2.42a8.992 8.992 0 0 1 6.152-2.42v-7.52c-2.392 0-4.539.923-6.152 2.42z" fill="#f0f4c3" stroke-width=".684"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-actions" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ab47bc" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#e1bee7" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-actions-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ab47bc"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#e1bee7" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-effects" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00bcd4" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#b2ebf2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-effects-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00bcd4"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#b2ebf2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef5350" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#ffcdd2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-reducer-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef5350"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#ffcdd2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-state" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#dcedc8" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-state-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#dcedc8" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-node" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.25 8.403c-.188 0-.382.048-.542.139l-5.166 2.986a1.096 1.096 0 0 0-.542.944v5.959c0 .388.208.75.542.944l1.354.778c.66.32.882.326 1.187.326.973 0 1.535-.59 1.535-1.618V12.98a.154.154 0 0 0-.153-.153h-.646c-.09 0-.16.07-.16.153v5.882c0 .458-.472.91-1.228.528l-1.424-.813a.181.181 0 0 1-.076-.145v-5.959c0-.062.027-.118.076-.146l5.167-2.979a.15.15 0 0 1 .152 0l5.167 2.98a.164.164 0 0 1 .076.145v5.959a.181.181 0 0 1-.076.145l-5.167 2.98c-.041.027-.11.027-.16 0l-1.305-.792c-.055-.021-.111-.028-.146-.007-.368.208-.437.25-.778.354-.083.028-.215.076.05.222l1.721 1.021c.167.097.348.146.542.146s.375-.049.542-.146l5.166-2.979c.334-.194.542-.556.542-.944v-5.959c0-.389-.208-.75-.542-.944l-5.166-2.986a1.103 1.103 0 0 0-.542-.14m1.389 4.272c-1.472 0-2.354.618-2.354 1.66 0 1.117.875 1.444 2.291 1.583 1.688.166 1.82.416 1.82.75 0 .576-.465.82-1.549.82-1.375 0-1.666-.341-1.77-1.022a.157.157 0 0 0-.153-.125h-.667c-.083 0-.146.063-.146.153 0 .861.472 1.903 2.736 1.903 1.632 0 2.57-.646 2.57-1.771 0-1.118-.75-1.41-2.34-1.625-1.605-.208-1.765-.32-1.765-.694 0-.313.14-.73 1.327-.73 1.042 0 1.451.23 1.611.945.014.07.076.118.146.118h.673a.134.134 0 0 0 .105-.049c.027-.028.048-.07.034-.11-.097-1.237-.916-1.806-2.57-1.806z" fill-rule="nonzero" fill="#f1f8e9"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-node-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.25 8.403c-.188 0-.382.048-.542.139l-5.166 2.986a1.096 1.096 0 0 0-.542.944v5.959c0 .388.208.75.542.944l1.354.778c.66.32.882.326 1.187.326.973 0 1.535-.59 1.535-1.618V12.98a.154.154 0 0 0-.153-.153h-.646c-.09 0-.16.07-.16.153v5.882c0 .458-.472.91-1.228.528l-1.424-.813a.181.181 0 0 1-.076-.145v-5.959c0-.062.027-.118.076-.146l5.167-2.979a.15.15 0 0 1 .152 0l5.167 2.98a.164.164 0 0 1 .076.145v5.959a.181.181 0 0 1-.076.145l-5.167 2.98c-.041.027-.11.027-.16 0l-1.305-.792c-.055-.021-.111-.028-.146-.007-.368.208-.437.25-.778.354-.083.028-.215.076.05.222l1.721 1.021c.167.097.348.146.542.146s.375-.049.542-.146l5.166-2.979c.334-.194.542-.556.542-.944v-5.959c0-.389-.208-.75-.542-.944l-5.166-2.986a1.103 1.103 0 0 0-.542-.14m1.389 4.272c-1.472 0-2.354.618-2.354 1.66 0 1.117.875 1.444 2.291 1.583 1.688.166 1.82.416 1.82.75 0 .576-.465.82-1.549.82-1.375 0-1.666-.341-1.77-1.022a.157.157 0 0 0-.153-.125h-.667c-.083 0-.146.063-.146.153 0 .861.472 1.903 2.736 1.903 1.632 0 2.57-.646 2.57-1.771 0-1.118-.75-1.41-2.34-1.625-1.605-.208-1.765-.32-1.765-.694 0-.313.14-.73 1.327-.73 1.042 0 1.451.23 1.611.945.014.07.076.118.146.118h.673a.134.134 0 0 0 .105-.049c.027-.028.048-.07.034-.11-.097-1.237-.916-1.806-2.57-1.806z" fill-rule="nonzero" fill="#f1f8e9"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-public" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><path d="M20.036 16.746c.05-.408.087-.817.087-1.237s-.037-.83-.087-1.238h2.091c.099.396.16.81.16 1.238a5.1 5.1 0 0 1-.16 1.237m-3.186 3.44c.371-.687.656-1.43.854-2.203h1.825a4.968 4.968 0 0 1-2.679 2.203m-.155-3.44h-2.895c-.062-.408-.099-.817-.099-1.237s.037-.835.1-1.238h2.894c.056.403.1.817.1 1.238s-.044.829-.1 1.237m-1.447 3.687a8.39 8.39 0 0 1-1.182-2.45h2.363a8.39 8.39 0 0 1-1.181 2.45m-2.475-7.399h-1.806a4.902 4.902 0 0 1 2.672-2.202c-.37.686-.65 1.429-.866 2.202m-1.806 4.95h1.806c.217.773.495 1.515.866 2.202a4.954 4.954 0 0 1-2.672-2.203m-.508-1.237a5.099 5.099 0 0 1-.16-1.237 5.1 5.1 0 0 1 .16-1.238h2.091c-.049.409-.086.817-.086 1.238s.037.829.086 1.237m2.698-6.168a8.425 8.425 0 0 1 1.181 2.456h-2.363a8.426 8.426 0 0 1 1.182-2.456m4.28 2.456h-1.824a9.682 9.682 0 0 0-.854-2.202 4.94 4.94 0 0 1 2.679 2.202m-4.281-3.712a6.193 6.193 0 0 0-6.187 6.187 6.186 6.186 0 0 0 6.187 6.186 6.186 6.186 0 0 0 6.186-6.186 6.186 6.186 0 0 0-6.186-6.187z" fill="#b3e5fc" stroke-width=".619"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-public-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><path d="M20.037 16.746c.05-.408.087-.817.087-1.237s-.037-.83-.087-1.238h2.091c.099.396.16.81.16 1.238a5.1 5.1 0 0 1-.16 1.237m-3.186 3.44c.371-.687.656-1.43.854-2.203h1.825a4.967 4.967 0 0 1-2.68 2.203m-.154-3.44h-2.895c-.062-.408-.099-.817-.099-1.237s.037-.835.099-1.238h2.895c.056.403.1.817.1 1.238s-.044.829-.1 1.237m-1.447 3.687a8.39 8.39 0 0 1-1.182-2.45h2.363a8.39 8.39 0 0 1-1.181 2.45m-2.475-7.399H13.06a4.902 4.902 0 0 1 2.672-2.202c-.371.686-.65 1.429-.866 2.202m-1.806 4.95h1.806c.217.773.495 1.515.866 2.202a4.954 4.954 0 0 1-2.672-2.203m-.508-1.237a5.099 5.099 0 0 1-.16-1.237 5.1 5.1 0 0 1 .16-1.238h2.091c-.05.409-.086.817-.086 1.238s.037.829.086 1.237m2.698-6.168a8.425 8.425 0 0 1 1.181 2.456h-2.363a8.426 8.426 0 0 1 1.182-2.456m4.28 2.456h-1.824a9.682 9.682 0 0 0-.854-2.202 4.941 4.941 0 0 1 2.679 2.202M17.34 9.322a6.193 6.193 0 0 0-6.187 6.187 6.186 6.186 0 0 0 6.187 6.186 6.186 6.186 0 0 0 6.186-6.186 6.186 6.186 0 0 0-6.186-6.187z" fill="#b3e5fc" stroke-width=".619"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-react-components" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00bcd4" fill-rule="nonzero"/><path d="M16.473 13.927c.723 0 1.313.59 1.313 1.327 0 .703-.59 1.3-1.313 1.3a1.318 1.318 0 0 1-1.313-1.3c0-.737.59-1.327 1.313-1.327m-3.252 6.946c.443.267 1.412-.14 2.529-1.194a17.015 17.015 0 0 1-1.06-1.335 15.945 15.945 0 0 1-1.686-.252c-.358 1.502-.225 2.535.217 2.78m.499-4.03l-.204-.359a5.558 5.558 0 0 0-.203.604c.19.042.4.078.618.113l-.211-.359m4.593-.533l.569-1.054-.569-1.053c-.21-.372-.435-.702-.639-1.032-.38-.022-.78-.022-1.2-.022-.422 0-.823 0-1.202.022-.204.33-.428.66-.639 1.032l-.569 1.053.57 1.054c.21.372.434.702.638 1.032.38.021.78.021 1.201.021.421 0 .822 0 1.201-.02.204-.331.428-.661.639-1.033m-1.84-4.72c-.133.155-.274.316-.414.506h.828c-.14-.19-.28-.351-.414-.506m0 7.332c.133-.154.274-.316.414-.505h-.828c.14.19.28.35.414.505m3.245-9.284c-.436-.267-1.405.14-2.522 1.194.366.414.724.864 1.06 1.334.577.057 1.146.14 1.686.253.359-1.503.225-2.535-.224-2.78m-.492 4.03l.204.358c.077-.203.154-.407.203-.604-.19-.042-.4-.077-.618-.112l.211.358m1.018-4.95c1.033.589 1.145 2.141.71 3.953 1.784.527 3.069 1.398 3.069 2.584 0 1.187-1.285 2.058-3.07 2.585.436 1.812.324 3.364-.709 3.954-1.025.59-2.423-.085-3.77-1.37-1.35 1.285-2.747 1.96-3.78 1.37-1.025-.59-1.137-2.142-.702-3.954-1.783-.527-3.069-1.398-3.069-2.585s1.286-2.057 3.07-2.584c-.436-1.812-.324-3.364.702-3.954 1.032-.59 2.43.084 3.778 1.37 1.348-1.286 2.746-1.96 3.771-1.37m-.203 6.538c.239.527.45 1.054.625 1.588 1.475-.443 2.303-1.075 2.303-1.588 0-.512-.828-1.144-2.303-1.587a15.81 15.81 0 0 1-.625 1.587m-7.136 0a15.806 15.806 0 0 1-.625-1.587c-1.474.443-2.303 1.075-2.303 1.587 0 .513.829 1.145 2.303 1.588.176-.534.387-1.06.625-1.588m6.321 1.588l-.21.358c.217-.035.428-.07.617-.113-.049-.196-.126-.4-.203-.604l-.204.359m-2.03 2.837c1.117 1.053 2.086 1.46 2.522 1.194.45-.246.583-1.278.224-2.781-.54.112-1.11.196-1.685.253-.337.47-.695.92-1.06 1.334m-3.477-6.012l.21-.358c-.217.035-.428.07-.617.113.049.196.126.4.203.604l.204-.359m2.03-2.837c-1.117-1.053-2.086-1.46-2.529-1.194-.442.246-.576 1.278-.217 2.781.54-.112 1.11-.196 1.685-.253.337-.47.695-.92 1.06-1.334z" fill="#b2ebf2" stroke-width=".702"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-react-components-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00bcd4"/><path d="M16.473 13.928c.723 0 1.313.59 1.313 1.327 0 .703-.59 1.3-1.313 1.3a1.318 1.318 0 0 1-1.313-1.3c0-.737.59-1.327 1.313-1.327m-3.252 6.946c.443.267 1.412-.14 2.529-1.194a16.997 16.997 0 0 1-1.06-1.335 15.945 15.945 0 0 1-1.686-.252c-.358 1.502-.225 2.535.217 2.78m.499-4.03l-.204-.359c-.077.204-.154.408-.203.604.19.042.4.078.618.113l-.211-.359m4.593-.533l.569-1.054-.57-1.053c-.21-.372-.434-.702-.638-1.032-.38-.022-.78-.022-1.201-.022-.421 0-.822 0-1.2.022-.205.33-.43.66-.64 1.032l-.569 1.053.569 1.054c.21.372.435.702.64 1.032.378.021.779.021 1.2.021.421 0 .822 0 1.2-.02.205-.33.43-.661.64-1.033m-1.84-4.72c-.133.155-.274.316-.414.506h.828c-.14-.19-.28-.351-.414-.506m0 7.332c.133-.154.274-.316.414-.505h-.828c.14.19.28.35.414.505m3.244-9.284c-.435-.267-1.404.14-2.52 1.194.364.414.723.864 1.06 1.334.575.057 1.144.14 1.685.253.358-1.503.225-2.535-.225-2.78m-.491 4.03l.203.358c.078-.203.155-.407.204-.604-.19-.042-.4-.077-.618-.112l.21.358m1.02-4.95c1.032.589 1.144 2.141.708 3.953 1.784.527 3.07 1.398 3.07 2.584 0 1.187-1.286 2.058-3.07 2.585.436 1.812.323 3.364-.709 3.954-1.025.59-2.423-.085-3.771-1.37-1.348 1.285-2.746 1.96-3.778 1.37-1.026-.59-1.138-2.142-.703-3.954-1.783-.527-3.069-1.398-3.069-2.585s1.286-2.057 3.07-2.584c-.436-1.812-.324-3.364.702-3.954 1.032-.59 2.43.084 3.778 1.37 1.348-1.286 2.746-1.96 3.771-1.37m-.204 6.538c.24.527.45 1.054.625 1.588 1.475-.443 2.304-1.075 2.304-1.588 0-.512-.829-1.144-2.304-1.587a15.81 15.81 0 0 1-.625 1.587m-7.135 0a15.808 15.808 0 0 1-.625-1.587c-1.475.443-2.303 1.075-2.303 1.587 0 .513.828 1.145 2.303 1.588.176-.534.386-1.06.625-1.588m6.32 1.588l-.21.358c.218-.035.428-.07.618-.113a5.56 5.56 0 0 0-.204-.604l-.203.359m-2.03 2.837c1.117 1.053 2.086 1.46 2.521 1.194.45-.246.583-1.278.225-2.781-.54.112-1.11.196-1.685.253-.338.47-.696.92-1.06 1.334m-3.477-6.012l.21-.358c-.217.035-.428.07-.617.112.049.197.126.4.203.604l.204-.358m2.03-2.837c-1.117-1.053-2.086-1.46-2.529-1.194-.442.246-.576 1.278-.217 2.781.54-.112 1.11-.196 1.685-.253.337-.47.695-.92 1.06-1.334z" fill="#b2ebf2" stroke-width=".702"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-actions" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ab47bc" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#e1bee7" stroke="#e1bee7" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-actions-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ab47bc"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#e1bee7" stroke="#e1bee7" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef5350" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#ffcdd2" stroke="#ffcdd2" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-reducer-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef5350"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#ffcdd2" stroke="#ffcdd2" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-store" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#dcedc8" stroke="#dcedc8" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-store-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#dcedc8" stroke="#dcedc8" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-resource" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#fbc02d" fill-rule="nonzero"/><path d="M21.598 12.059h-6.085v-1.217h6.085m-2.434 6.085h-3.65V15.71h3.65m2.434-1.217h-6.085v-1.217h6.085m.608-4.26h-7.301a1.217 1.217 0 0 0-1.217 1.218v7.301a1.217 1.217 0 0 0 1.217 1.217h7.301a1.217 1.217 0 0 0 1.217-1.217v-7.301a1.217 1.217 0 0 0-1.217-1.217m-9.735 2.434h-1.217v8.518a1.217 1.217 0 0 0 1.217 1.217h8.519v-1.217H12.47z" fill="#fff9c4" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-resource-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#fbc02d"/><path d="M21.598 12.059h-6.085v-1.217h6.085m-2.434 6.085h-3.65V15.71h3.65m2.434-1.217h-6.085v-1.217h6.085m.608-4.26h-7.301a1.217 1.217 0 0 0-1.217 1.218v7.301a1.217 1.217 0 0 0 1.217 1.217h7.301a1.217 1.217 0 0 0 1.217-1.217v-7.301a1.217 1.217 0 0 0-1.217-1.217m-9.735 2.433h-1.217v8.519a1.217 1.217 0 0 0 1.217 1.217h8.519v-1.217H12.47z" fill="#fff9c4" stroke-width=".608"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" id="folder-sass" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#f8bbd0" fill-rule="nonzero"/><path d="M23.36 10.506c-.39-1.527-2.922-2.03-5.319-1.178-1.426.507-2.97 1.302-4.08 2.34-1.32 1.235-1.53 2.31-1.444 2.759.306 1.584 2.477 2.62 3.37 3.388v.005c-.264.13-2.19 1.104-2.64 2.1-.476 1.052.075 1.806.44 1.908 1.131.315 2.292-.251 2.916-1.182.602-.897.551-2.056.29-2.633.36-.095.781-.138 1.316-.076 1.508.177 1.804 1.118 1.748 1.513-.057.394-.373.61-.48.676-.105.065-.137.088-.129.137.013.07.062.068.152.053.125-.021.792-.321.821-1.048.037-.924-.849-1.958-2.416-1.93-.646.01-1.052.072-1.345.181-.022-.024-.044-.05-.067-.073-.969-1.034-2.76-1.765-2.684-3.156.027-.505.203-1.835 3.442-3.45 2.653-1.322 4.777-.957 5.145-.151.524 1.152-1.136 3.293-3.891 3.601-1.05.118-1.603-.289-1.74-.44-.145-.16-.166-.167-.22-.137-.088.049-.033.19 0 .274.082.214.42.594.995.782.506.166 1.739.258 3.23-.319 1.669-.646 2.972-2.443 2.59-3.944zm-7.103 7.783a2.2 2.2 0 0 1-.065 1.413 2.405 2.405 0 0 1-.453.704c-.5.546-1.198.752-1.497.579-.323-.188-.161-.956.418-1.568.623-.66 1.52-1.083 1.52-1.083l-.002-.002.079-.043z" fill="#ec407a" fill-rule="nonzero" stroke="#ec407a" stroke-width=".5199012000000001"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" id="folder-sass-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#f8bbd0" fill-rule="nonzero"/><path d="M23.36 10.506c-.39-1.527-2.922-2.03-5.319-1.178-1.426.507-2.97 1.302-4.08 2.34-1.32 1.235-1.53 2.31-1.444 2.759.306 1.584 2.477 2.62 3.37 3.388v.005c-.264.13-2.19 1.104-2.64 2.1-.476 1.052.075 1.806.44 1.908 1.131.315 2.292-.251 2.916-1.182.602-.897.551-2.056.29-2.633.36-.095.781-.138 1.316-.076 1.508.177 1.804 1.118 1.748 1.513-.057.394-.373.61-.48.676-.105.065-.137.088-.129.137.013.07.062.068.152.053.125-.021.792-.321.821-1.048.037-.924-.849-1.958-2.416-1.93-.646.01-1.052.072-1.345.181-.022-.024-.044-.05-.067-.073-.969-1.034-2.76-1.765-2.684-3.156.027-.505.203-1.835 3.442-3.45 2.653-1.322 4.777-.957 5.145-.151.524 1.152-1.136 3.293-3.891 3.601-1.05.118-1.603-.289-1.74-.44-.145-.16-.166-.167-.22-.137-.088.049-.033.19 0 .274.082.214.42.594.995.782.506.166 1.739.258 3.23-.319 1.669-.646 2.972-2.443 2.59-3.944zm-7.103 7.783a2.2 2.2 0 0 1-.065 1.413 2.405 2.405 0 0 1-.453.704c-.5.546-1.198.752-1.497.579-.323-.188-.161-.956.418-1.568.623-.66 1.52-1.083 1.52-1.083l-.002-.002.079-.043z" fill="#ec407a" fill-rule="nonzero" stroke="#ec407a" stroke-width=".5199012000000001"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-scripts" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#546e7a" fill-rule="nonzero"/><path d="M18.466 20.241c.69 0 1.259-.568 1.259-1.258v-8.18H15.32a.632.632 0 0 0-.63.63v6.292h-1.887v-6.922c0-1.036.852-1.888 1.888-1.888h6.921c1.036 0 1.888.852 1.888 1.888v.63h-2.517v8.18a1.896 1.896 0 0 1-1.888 1.887h-6.292a1.896 1.896 0 0 1-1.888-1.888v-.629h6.293c0 .69.568 1.258 1.258 1.258z" fill-rule="nonzero" fill="#cfd8dc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-scripts-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#546e7a" fill-rule="nonzero"/><path d="M18.466 20.241c.69 0 1.259-.568 1.259-1.258v-8.18H15.32a.632.632 0 0 0-.63.63v6.292h-1.887v-6.922c0-1.036.852-1.888 1.888-1.888h6.921c1.036 0 1.888.852 1.888 1.888v.63h-2.517v8.18a1.896 1.896 0 0 1-1.888 1.887h-6.292a1.896 1.896 0 0 1-1.888-1.888v-.629h6.293c0 .69.568 1.258 1.258 1.258z" fill-rule="nonzero" fill="#cfd8dc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-src" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#4caf50" fill-rule="nonzero"/><g fill="#c8e6c9" transform="translate(2.065 -.225) scale(.70678)"><path d="M19.146 30.989a.902.902 0 0 1-.207-.025 1.045 1.045 0 0 1-.726-1.213l2.709-14.431c.049-.279.209-.525.444-.683a.891.891 0 0 1 .7-.122c.519.152.837.684.727 1.213L20.077 30.16a1.032 1.032 0 0 1-.442.681.895.895 0 0 1-.489.148zM24.578 28.944h-.068a.932.932 0 0 1-.668-.377 1.104 1.104 0 0 1 .1-1.419l4.658-4.553-4.638-4.239a1.105 1.105 0 0 1-.141-1.416.938.938 0 0 1 .661-.4.9.9 0 0 1 .709.237l5.47 5c.386.372.448.974.144 1.416a1.05 1.05 0 0 1-.142.163l-5.447 5.324a.913.913 0 0 1-.638.264zM16.423 28.947a.917.917 0 0 1-.639-.267l-5.452-5.327a.874.874 0 0 1-.132-.153 1.097 1.097 0 0 1 .141-1.414l5.471-5a.882.882 0 0 1 .7-.238.939.939 0 0 1 .665.4 1.104 1.104 0 0 1-.14 1.417L12.4 22.6l4.659 4.551c.377.382.42.988.1 1.419a.928.928 0 0 1-.669.377z" fill-rule="nonzero"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-src-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#4caf50"/><g fill="#c8e6c9" fill-rule="evenodd" transform="translate(2.064 -.224) scale(.70678)"><path d="M19.146 30.989a.902.902 0 0 1-.207-.025 1.045 1.045 0 0 1-.726-1.213l2.709-14.431c.049-.279.209-.525.444-.683a.891.891 0 0 1 .7-.122c.519.152.837.684.727 1.213L20.077 30.16a1.032 1.032 0 0 1-.442.681.895.895 0 0 1-.489.148zM24.578 28.944h-.068a.932.932 0 0 1-.668-.377 1.104 1.104 0 0 1 .1-1.419l4.658-4.553-4.638-4.239a1.105 1.105 0 0 1-.141-1.416.938.938 0 0 1 .661-.4.9.9 0 0 1 .709.237l5.47 5c.386.372.448.974.144 1.416a1.05 1.05 0 0 1-.142.163l-5.447 5.324a.913.913 0 0 1-.638.264zM16.423 28.947a.917.917 0 0 1-.639-.267l-5.452-5.327a.874.874 0 0 1-.132-.153 1.097 1.097 0 0 1 .141-1.414l5.471-5a.882.882 0 0 1 .7-.238.939.939 0 0 1 .665.4 1.104 1.104 0 0 1-.14 1.417L12.4 22.6l4.659 4.551c.377.382.42.988.1 1.419a.928.928 0 0 1-.669.377z" fill-rule="nonzero"/></g></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-test" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#1de9b6" fill-rule="nonzero"/><path d="M14 8.097v1.39h.695v9.732A2.794 2.794 0 0 0 17.475 22a2.794 2.794 0 0 0 2.781-2.78V9.486h.695v-1.39H14m2.78 9.732c-.417 0-.695-.278-.695-.695 0-.417.278-.695.696-.695.417 0 .695.278.695.695 0 .417-.278.695-.695.695m1.39-2.78c-.417 0-.695-.278-.695-.696 0-.417.278-.695.695-.695.417 0 .695.278.695.695 0 .418-.278.696-.695.696m.695-3.476h-2.78V9.487h2.78v2.086z" fill="#00897b" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-test-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#1de9b6" fill-rule="nonzero"/><path d="M14 8.097v1.39h.695v9.732A2.794 2.794 0 0 0 17.475 22a2.794 2.794 0 0 0 2.781-2.78V9.486h.695v-1.39H14m2.78 9.732c-.417 0-.695-.278-.695-.695 0-.417.278-.695.696-.695.417 0 .695.278.695.695 0 .417-.278.695-.695.695m1.39-2.78c-.417 0-.695-.278-.695-.696 0-.417.278-.695.695-.695.417 0 .695.278.695.695 0 .418-.278.696-.695.696m.695-3.476h-2.78V9.487h2.78v2.086z" fill="#00897b" fill-rule="nonzero"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-tools" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#1e88e5" fill-rule="nonzero"/><path d="M21.043 15.266a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141-2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-3.569 0a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141 2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m3.925-6.424a6.424 6.424 0 0 0-6.423 6.424 6.424 6.424 0 0 0 6.423 6.424 1.07 1.07 0 0 0 1.071-1.07c0-.28-.107-.53-.278-.715a1.105 1.105 0 0 1-.271-.713 1.07 1.07 0 0 1 1.07-1.071h1.263a3.569 3.569 0 0 0 3.57-3.569c0-3.154-2.877-5.71-6.425-5.71z" fill="#bbdefb" stroke-width=".714"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-tools-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#1e88e5"/><path d="M21.043 15.266a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141-2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-3.569 0a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141 2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m3.925-6.424a6.424 6.424 0 0 0-6.423 6.424 6.424 6.424 0 0 0 6.423 6.424 1.07 1.07 0 0 0 1.071-1.07c0-.28-.107-.53-.278-.715a1.105 1.105 0 0 1-.271-.713 1.07 1.07 0 0 1 1.07-1.071h1.263a3.569 3.569 0 0 0 3.57-3.569c0-3.154-2.877-5.71-6.425-5.71z" fill="#bbdefb" stroke-width=".714"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-views" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ff8a65" fill-rule="nonzero"/><path d="M12.487 21.868L11.384 9.5H23.5l-1.104 12.366-4.961 1.375-4.948-1.373zm4.464-3.2l-3.926-2.36v-.855l3.926-2.361v1.323l-2.504 1.465 2.504 1.465v1.323zm.982-.001v-1.323l2.522-1.464-2.522-1.464v-1.323l3.926 2.35v.874l-3.926 2.35z" fill="#e44d26"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-views-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ff8a65" fill-rule="nonzero"/><path d="M12.487 21.868L11.384 9.5H23.5l-1.104 12.366-4.961 1.375-4.948-1.373zm4.464-3.2l-3.926-2.36v-.855l3.926-2.361v1.323l-2.504 1.465 2.504 1.465v1.323zm.982-.001v-1.323l2.522-1.464-2.522-1.464v-1.323l3.926 2.35v.874l-3.926 2.35z" fill="#e44d26"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vscode" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#42a5f5" fill-rule="nonzero"/><path d="M20.811 8.52l-5.988 5.506-3.346-2.522-1.383.805 3.298 3.03-3.298 3.032 1.383.807 3.346-2.522 5.988 5.503 2.921-1.419V9.94zm0 3.622v6.396l-4.245-3.198z" fill="#bbdefb" stroke-width=".974"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vscode-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#42a5f5" fill-rule="nonzero"/><path d="M20.81 8.52l-5.988 5.506-3.346-2.522-1.384.805 3.3 3.03-3.3 3.032 1.384.807 3.346-2.522 5.988 5.503 2.921-1.419V9.94zm0 3.621v6.397l-4.245-3.198z" fill="#bbdefb" stroke-width=".974"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vue" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#009688" fill-rule="nonzero"/><g transform="translate(8.459 6.362) scale(.69572)"><path d="M1.821 4.15l10.21 17.618L22.239 4.235v-.084h-7.692l-2.434 4.178-2.422-4.178z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179z" fill="#35495e"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vue-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#009688"/><g transform="translate(8.458 6.362) scale(.69572)"><path d="M1.821 4.15l10.21 17.618L22.239 4.235v-.084h-7.692l-2.434 4.178-2.422-4.178z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179z" fill="#35495e"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-webpack" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#03a9f4" fill-rule="nonzero"/><g transform="translate(9.192 7.48) scale(.66328)"><path d="M19.376 15.988l-7.708 4.45-7.709-4.45v-8.9l7.709-4.451 7.708 4.45z" fill="#fff" fill-opacity=".785"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18s.41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.94v2.103h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#1c78c0"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-webpack-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#03a9f4"/><g transform="translate(9.193 7.48) scale(.66328)"><path d="M19.376 15.988l-7.708 4.45-7.709-4.45v-8.9l7.709-4.451 7.708 4.45z" fill="#fff" fill-opacity=".785"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18s.41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.94v2.103h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#1c78c0"/></g></symbol><symbol viewBox="0 0 24 24" id="font" xmlns="http://www.w3.org/2000/svg"><path d="M9.62 12L12 5.67 14.37 12M11 3L5.5 17h2.25l1.12-3h6.25l1.13 3h2.25L13 3h-2z" fill="#f44336"/></symbol><symbol viewBox="0 0 500 500" id="fsharp" xmlns="http://www.w3.org/2000/svg"><path d="M235.906 36.66L21.963 250.601l213.943 213.943v-84.36L106.209 250.487l129.697-129.696z" fill="#378bba" stroke-width="14.706"/><path d="M235.906 156.614l-93.622 93.62 93.622 93.622z" fill="#378bba" stroke-width="15.006"/><path d="M263.417 36.64L477.36 250.583 263.417 464.526v-84.36l129.696-129.697-129.696-129.696z" fill="#30b9db" stroke-width="14.706"/></symbol><symbol viewBox="0 0 152.99 160.01" id="fusebox" xmlns="http://www.w3.org/2000/svg"><defs id="fkdefs4"><style id="fkstyle2">.fkcls-1{fill:#fff}.fkcls-2{fill:#515151}.fkcls-3{fill:#1d79bf}.fkcls-4{fill:#383838}</style></defs><title id="fktitle6">Asset 3</title><g id="fkLayer_2" data-name="Layer 2" transform="matrix(.87285 0 0 .87285 10.17 10.175)"><g id="fkFuse_Box" data-name="Fuse Box"><g id="fkLOGO"><path class="fkcls-1" id="fkpolygon8" fill="#fff" d="M76.56 2.19l74.22 24.93-7.7 87.77-65.41 42.66-69.79-43.93-5.7-86.13z"/><path class="fkcls-2" d="M77.69 160L5.87 114.81 0 26 76.55 0 153 25.67l-7.94 90.4zM9.88 112.43l67.77 42.66 63.45-41.39 7.47-85.13-72-24.18L4.36 28.95z" id="fkpath10" fill="#515151"/><path class="fkcls-3" id="fkpolygon12" fill="#1d79bf" d="M76.4 148.8V61.68l66.93-29.82-5.99 78.77z"/><path id="fkF" class="fkcls-4" fill="#383838" d="M76.4 148.8l-60.35-37.39L9.63 31.8 76.4 61.68z"/><path class="fkcls-1" d="M25.58 52.73l.54 15.93 37.35 18.18.12 14.69-37-18.21 1.64 37.1-14.56-9-5.05-80.55 67.79 30.82v15.46z" id="fkpath15" fill="#fff"/><path class="fkcls-1" d="M135.91 90.77c-.08 13.12-6.33 26.59-16.77 33.12l-42.8 27.93V61.71l42.27-18.84c5.16-2.41 9.51-1.43 12.4 3.11 1.9 3 2.89 7.23 2.86 12.21A35.69 35.69 0 0 1 129.34 76c4.29 2 6.66 6.55 6.57 14.77zM123 63.76c0-4.64-2-6.93-4.92-5.45l-29 14.48L89 90l29.44-15.59c2.5-1.32 4.56-5.91 4.56-10.65zM125.15 96c0-5.71-2.42-8.24-6.55-5.93L89 106.64v19.58l29.34-17.46c4.43-2.64 6.79-7.27 6.81-12.76z" id="fkpath17" fill="#fff"/><path id="fkTOP" class="fkcls-4" fill="#383838" d="M76.4 8.82L9.71 31.77l109.77 2.38-84.02 9.21L76.4 61.68l20.76-9.25-27.73-1.37 49.78-8.46 24.12-10.74z"/></g></g></g></symbol><symbol viewBox="0 0 24 24" id="git" xmlns="http://www.w3.org/2000/svg"><path d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2 2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82z" fill="#e64a19"/></symbol><symbol viewBox="0 0 494 455" id="gitlab" xmlns="http://www.w3.org/2000/svg"><title>logo</title><defs><path id="fma" d="M0 1173.3h2000V0H0v1173.3z"/></defs><g transform="matrix(.88256 0 0 -.88256 -286.767 742.766)" fill="none" fill-rule="evenodd"><mask id="fmb" fill="#fff"><use width="100%" height="100%" xlink:href="#fma"/></mask><g mask="url(#fmb)"><g transform="translate(358.67 358.67)"><path d="M492.532 195.445l-27.559 84.815-54.617 168.1c-2.81 8.648-15.045 8.648-17.856 0l-54.619-168.1h-181.37l-54.62 168.1c-2.81 8.648-15.045 8.648-17.856 0l-54.617-168.1-27.557-84.815a18.775 18.775 0 0 1 6.82-20.992l238.51-173.29 238.51 173.29a18.777 18.777 0 0 1 6.82 20.992" fill="#fc6d26"/><path d="M247.2 1.16l90.684 279.1h-181.37z" fill="#e24329"/><path d="M247.201 1.16l-90.684 279.09H29.427z" fill="#fc6d26"/><path d="M29.422 280.256L1.862 195.44a18.774 18.774 0 0 1 6.822-20.991L247.194 1.16z" fill="#fca326"/><path d="M29.422 280.26h127.09l-54.619 168.1c-2.81 8.65-15.047 8.65-17.856 0z" fill="#e24329"/><path d="M247.2 1.16l90.684 279.09h127.09z" fill="#fc6d26"/><path d="M464.98 280.256l27.559-84.815a18.774 18.774 0 0 0-6.821-20.991L247.208 1.16z" fill="#fca326"/><path d="M464.97 280.26H337.88l54.619 168.1c2.81 8.65 15.047 8.65 17.856 0z" fill="#e24329"/></g></g></g></symbol><symbol viewBox="0 0 24 24" id="go" xmlns="http://www.w3.org/2000/svg"><path d="M10.575 1.695c-2.634 0-4.756 2.453-4.756 5.502v4.6l-.027-.003v4.71c0 3.05 2.123 5.502 4.757 5.502h2.286c2.634 0 4.757-2.453 4.757-5.502v-4.6a5.1 5.1 0 0 0 .026.003v-4.71c0-3.049-2.122-5.502-4.756-5.502h-2.287z" fill="#73cddc"/><rect width="2.289" height="3.335" x="-1.178" y="6.092" ry="1.125" transform="matrix(.4849 -.87457 .85979 .51065 0 0)" fill="#73cddc"/><rect width="2.297" height="3.39" x="10.261" y="-15.076" ry="1.143" transform="matrix(.44646 .8948 -.89204 .45195 0 0)" fill="#73cddc"/><circle cx="9.267" cy="5.13" r="2.054" fill="#fff" stroke="#5e5d5b" stroke-width=".1"/><circle cx="14.214" cy="5.116" r="2.054" fill="#fff" stroke="#5e5d5b" stroke-width=".1"/><ellipse cx="8.039" cy="5.051" rx=".792" ry=".901" fill="#030d18"/><path d="M11.792 9.556l.763.138a.403.689 0 0 1 .008.138.403.689 0 0 1-.402.69.403.689 0 0 1-.404-.69.403.689 0 0 1 .035-.276z" fill="#fff" stroke="#fff" stroke-width=".155"/><ellipse cx="8.51" cy="5.365" rx=".138" ry=".166" fill="#fff"/><ellipse cx="12.945" cy="5.189" rx=".792" ry=".901" fill="#030d18"/><ellipse cx="13.414" cy="5.446" rx=".138" ry=".166" fill="#fff"/><ellipse cx="-12.982" cy="-3.409" rx=".708" ry="1.026" transform="rotate(-129.403)" fill="#f6d2a1" stroke-width=".4"/><path d="M11.772 9.553l-.757.135a.4.672 0 0 0-.008.135.4.672 0 0 0 .4.672.4.672 0 0 0 .4-.672.4.672 0 0 0-.035-.27z" fill="#fff" stroke="#fff" stroke-width=".153"/><ellipse cx="1.841" cy="-21.563" rx=".707" ry="1.026" transform="scale(1 -1) rotate(50.597)" fill="#f6d2a1" stroke-width=".4"/><ellipse cx="-17.281" cy="-21.784" rx=".864" ry="1.27" transform="matrix(.3054 -.95222 -.97065 -.2405 0 0)" fill="#f6d2a1" stroke-width=".4"/><ellipse cx="22.885" cy="2.587" rx=".864" ry="1.27" transform="matrix(.22652 .974 .95652 -.29167 0 0)" fill="#f6d2a1" stroke-width=".4"/><path d="M10.708 8.392a.594.594 0 0 0-.594.597v.115c0 .331.264.598.594.598h.386a.973.772 0 0 1 .697-.235.973.772 0 0 1 .698.235h.334c.33 0 .594-.267.594-.598V8.99a.595.595 0 0 0-.594-.597h-2.115z" fill="#f6d2a1" stroke="#657075" stroke-width=".1"/><ellipse cx="11.734" cy="8.203" rx="1.208" ry=".68" fill="#030d18" stroke="#fff" stroke-width=".162"/></symbol><symbol viewBox="0 0 24 24" id="gradle" xmlns="http://www.w3.org/2000/svg"><path d="M21.718 5.503c-.731-1.315-2.04-1.708-2.963-1.727-1.133-.023-2.065.605-1.888 1.017.037.088.25.55.38.741.19.275.527.064.646 0 .353-.187.73-.248 1.16-.198.409.048.954.3 1.319 1.001.859 1.652-1.794 5.05-5.114 2.697-3.32-2.353-6.548-1.574-8.01-1.1-1.462.475-2.135.952-1.556 2.055.785 1.498.524 1.038 1.285 2.28 1.21 1.97 3.856-.908 3.856-.908-1.972 2.906-3.662 2.204-4.31 1.188a15.864 15.864 0 0 1-1.038-1.97c-4.993 1.76-3.642 9.534-3.642 9.534h2.48c.632-2.862 2.892-2.757 3.28 0h1.892c1.673-5.59 5.914 0 5.914 0h2.466c-.69-3.812 1.388-5.01 2.697-7.246 1.31-2.235 2.551-4.969 1.146-7.364zm-6.362 7.362c-1.304-.426-.837-1.723-.837-1.723s1.139.368 2.68.87c-.09.403-.856 1.175-1.843.853z" fill="#0097a7" stroke-width=".47"/></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 300 300" id="graphcool" xmlns="http://www.w3.org/2000/svg"><path d="M246.886 107.727c-12.237-6.892-27.616 2.1-30.081 3.646l-52.834 29.965c-7.8-6.196-18.914-5.933-26.412.625-7.499 6.558-9.24 17.537-4.14 26.094 5.102 8.556 15.588 12.246 24.923 8.768 9.335-3.478 14.852-13.129 13.111-22.937l52.688-29.9.321-.196c3.464-2.188 11.5-5.462 15.256-3.34 2.706 1.524 4.252 6.629 4.376 14.148h-.066v66.092a17.313 17.313 0 0 1-8.635 14.95l-75.739 43.755a17.312 17.312 0 0 1-17.261 0l-75.74-43.756a17.312 17.312 0 0 1-8.634-14.95V113.22c.01-6.165 3.3-11.86 8.634-14.95l68.549-39.562c6.522 7.482 17.451 9.25 26 4.206s12.283-15.468 8.886-24.794c-3.397-9.327-12.962-14.904-22.751-13.27-9.79 1.636-17.022 10.02-17.204 19.944L59.397 85.632a31.932 31.932 0 0 0-15.978 27.588v87.454a31.933 31.933 0 0 0 15.927 27.602l75.74 43.755a31.934 31.934 0 0 0 31.846 0l75.74-43.755a31.933 31.933 0 0 0 15.927-27.58V137.12h.05c.373-14.913-3.616-24.794-11.762-29.389z" fill="#27ae60" stroke="#27ae60" stroke-width="7.883622079999999"/></symbol><symbol viewBox="0 0 400 400" id="graphql" xmlns="http://www.w3.org/2000/svg"><path d="M67.008 293.022l-13.143-7.588L200.282 31.839l13.143 7.588z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M50.855 265.174H343.69v15.177H50.855z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M203.122 358.269L56.649 273.7l7.589-13.143 146.472 84.568zm127.24-220.407L183.889 53.293l7.589-13.143 146.472 84.568z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M64.278 137.803l-7.588-13.142 146.472-84.568 7.588 13.143z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M327.661 293.025L181.244 39.43l13.143-7.589 146.417 253.596zM62.466 114.597h15.176v169.136H62.466zm254.528 0h15.176v169.136h-15.176z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M200.538 351.845l-6.628-11.481L321.3 266.812l6.629 11.48z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M352.284 288.67c-8.777 15.268-28.342 20.48-43.61 11.703-15.268-8.777-20.48-28.342-11.703-43.61 8.777-15.268 28.342-20.48 43.61-11.703 15.36 8.869 20.57 28.342 11.703 43.61M97.574 141.567c-8.778 15.268-28.343 20.48-43.61 11.703-15.269-8.777-20.48-28.342-11.703-43.61 8.777-15.268 28.342-20.48 43.61-11.703 15.268 8.869 20.479 28.342 11.702 43.61M42.353 288.67c-8.777-15.268-3.566-34.741 11.702-43.61 15.268-8.776 34.741-3.565 43.61 11.703 8.776 15.268 3.565 34.741-11.703 43.61-15.36 8.776-34.833 3.565-43.61-11.703m254.71-147.103c-8.776-15.268-3.565-34.741 11.703-43.61 15.268-8.776 34.742-3.565 43.61 11.703 8.777 15.268 3.566 34.741-11.702 43.61-15.268 8.776-34.833 3.565-43.61-11.703m-99.745 236.608c-17.645 0-31.907-14.262-31.907-31.907s14.262-31.907 31.907-31.907 31.907 14.262 31.907 31.907c0 17.554-14.262 31.907-31.907 31.907m0-294.206c-17.645 0-31.907-14.262-31.907-31.907s14.262-31.907 31.907-31.907 31.907 14.262 31.907 31.907-14.262 31.907-31.907 31.907" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/></symbol><symbol viewBox="0 0 24 24" id="groovy" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.982a10.119 10.119 0 0 0-10.12 10.12A10.119 10.119 0 0 0 12 22.22 10.119 10.119 0 0 0 22.12 12.1 10.119 10.119 0 0 0 12 1.983zm1.254 2.422c.91 0 1.647.261 2.213.78.571.518.857 1.188.857 2.013 0 .889-.319 1.673-.959 2.35-.64.677-1.376 1.015-2.207 1.015-.486 0-.89-.119-1.213-.357-.317-.238-.476-.532-.476-.88 0-.212.06-.4.181-.563.127-.164.274-.246.438-.246.159 0 .238.092.238.277 0 .164.06.29.182.38.121.09.261.136.42.136.423 0 .828-.29 1.215-.866.391-.582.587-1.202.587-1.863 0-.465-.151-.844-.453-1.135-.301-.296-.69-.445-1.166-.445-.714 0-1.406.318-2.078.953-.666.635-1.211 1.47-1.635 2.506-.417 1.031-.627 2.014-.627 2.945 0 .857.185 1.54.555 2.047.37.503.863.754 1.477.754 1.037 0 2.027-.734 2.974-2.2l1.493-.212c.185-.026.277.018.277.135 0 .053-.072.28-.215.681-.143.402-.337 1.074-.586 2.016.82-.476 1.455-1.003 1.904-1.58v.914c-.36.418-1.046.888-2.062 1.412-.212 1.407-.682 2.493-1.406 3.26-.725.772-1.54 1.16-2.444 1.16-.433 0-.775-.102-1.023-.303-.243-.2-.365-.477-.365-.832 0-.984.955-1.94 2.865-2.865.2-.714.395-1.356.586-1.928-.333.482-.817.907-1.451 1.278-.635.37-1.225.554-1.77.554-.889 0-1.628-.383-2.22-1.15-.588-.772-.881-1.748-.881-2.928 0-1.243.333-2.42 1-3.531a7.747 7.747 0 0 1 2.625-2.674c1.084-.672 2.134-1.008 3.15-1.008zM12.03 16.592c-1.375.687-2.062 1.365-2.062 2.031 0 .354.169.533.508.533.666 0 1.184-.856 1.554-2.564z" fill="#26c6da"/></symbol><symbol viewBox="0 0 24 24" id="gulp" xmlns="http://www.w3.org/2000/svg"><path d="M8.37 15.94a596.238 596.238 0 0 1-.482-4.982c.002-.042-.225-.077-.505-.077h-.508V8.95h3.966V5.198l1.871-1.124c1.14-.685 1.978-1.125 2.144-1.125.4 0 .866.506.866.939 0 .19-.057.422-.127.517-.07.095-.722.53-1.45.966l-1.321.792-.029 1.393-.028 1.393h3.972v1.932h-.98l-.495 4.983-.495 4.983H8.854l-.485-4.906z" fill="#e53935"/></symbol><symbol viewBox="0 0 24 24" id="h" xmlns="http://www.w3.org/2000/svg"><path d="M16.745 19.818h-3.007v-5.882q0-2.381-1.736-2.381-.869 0-1.438.663-.56.662-.56 1.718v5.882H6.988V4.533h3.016v6.508h.037q1.186-1.802 3.193-1.802 3.511 0 3.511 4.239z" stroke-width=".478" fill="#0277bd"/></symbol><symbol viewBox="0 0 253.6 253.6" id="hack" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-29.243 -29.515) scale(1.2301)"><path fill="#607d8b" d="M69.496 159.551v52.576l51.77-52.576zM123.507 41.523l-54.01 52.755v55.084l54.01-54.009z"/><path fill="#eceff1" d="M130.023 95.663v51.501l52.128-51.5z"/><path fill="#607d8b" d="M185.465 101.867l-55.442 55.174v55.083l55.442-55.262z"/><path fill="#ffa000" d="M73.068 154.283l50.427.09v-50.248z"/></g></symbol><symbol viewBox="0 0 300 300.00001" id="haml" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 165.6)"><path d="M78.42-132.307c-12.047-.302-26.924 5.998-26.924 5.998l49.195 99.791L74.605 85.005c23.81 20.134 50.07 10.504 50.07 10.504L136.76 9.212c1.526 1.446 3.146 2.77 4.777 3.995 5.244 3.714 10.925 6.553 16.606 8.738 5.68 2.185 11.583 3.933 17.482 5.244 3.933.874 7.645 1.53 11.578 1.967-1.748 3.933-2.84 8.083-2.621 12.672 0 .437.22.873.656 1.092h.217c4.152 2.185 8.521 3.934 13.328 5.027 4.589.874 9.615 1.312 14.422.656 5.026-.655 10.051-2.623 13.984-5.9 3.933-3.278 6.774-7.648 8.522-12.237l.219-.218v-.217l.656-5.899v-.22c2.185-1.311 4.37-2.621 6.555-4.37 2.622-2.184 5.025-4.589 6.773-7.648 1.748-3.059 2.84-6.774 2.621-10.488-.218-3.496-1.53-6.99-3.06-10.049-1.53-3.059-3.495-5.901-5.68-8.523-4.37-5.026-9.614-9.176-15.295-12.454-5.462-3.496-11.581-6.338-17.7-8.304l-2.404-.656-1.962-.655c-1.311-.437-2.406-1.092-3.498-1.53-2.185-1.31-3.717-2.622-4.809-4.37-2.185-3.278-2.403-8.301-1.31-13.545.218-1.311.656-2.623 1.093-3.934a96.064 96.064 0 0 0 1.31-4.152c.314-1.412.51-2.829.598-4.402l29.203-25.553c-2.275-8.404-27.488-17.158-27.488-17.158l-74.931 63.726-43.243-81.584c-1.553-.35-3.218-.527-4.94-.57zm107.682 73.14c-.449 2.336-.647 4.795-.647 7.258.219 3.715 1.311 7.87 3.715 11.366 2.403 3.496 5.68 6.117 8.957 7.646a29.663 29.663 0 0 0 5.027 1.967l2.623.654 2.184.438c5.68 1.53 11.142 3.714 16.168 6.554 5.025 2.84 9.833 6.337 13.766 10.27s6.992 8.959 7.43 13.984c.218 3.496-.22 6.118-1.313 8.303-1.093 2.404-2.84 4.588-4.807 6.555-.874.874-1.966 1.747-2.84 2.402a27.11 27.11 0 0 0-.654-5.898c-.219-1.093-.438-1.966-.875-3.059-.437-.874-.872-1.966-1.965-2.621-.218 0-.44-.001-.44.217-1.31 3.277-3.494 6.12-5.898 8.086-2.403 1.966-5.462 2.84-8.521 3.058-3.06.219-6.338-.436-9.616-1.31-3.277-.874-6.552-1.968-9.83-3.06l-.439-.22c-.656-.218-1.526.002-1.963.44-1.748 2.185-3.06 4.149-4.59 6.334a58.435 58.435 0 0 0-2.84 5.027c-3.933-1.53-7.649-2.841-11.582-4.37-5.462-2.186-10.925-4.37-15.95-6.991-5.245-2.404-10.268-5.246-14.638-8.524-3.15-2.363-6.062-4.845-8.185-7.681l2.404-17.172z" fill="#f4511e" stroke-width="0" stroke-linejoin="round"/></g></symbol><symbol viewBox="0 0 24 24" id="handlebars" xmlns="http://www.w3.org/2000/svg"><path d="M8.55 10.32c-2.753 0-4.202 3.48-5.793 3.48-.98 0-1.126-.677-1.126-.915 0-.332.236-.706.564-.706.59 0 .414.77.414.77s.798-.555.272-1.298c-.42-.595-1.31-.623-1.92-.17-.617.458-1.057 1.146-.853 2.287.1.551.468 1.35 1.233 1.805.764.455 1.925.566 2.335.566 2.194 0 4.342-1.633 6.639-2.322a5.513 5.513 0 0 1 1.497-.222 6.19 6.19 0 0 1 1.92.226c2.296.689 4.444 2.323 6.638 2.323.41 0 1.57-.11 2.335-.566.765-.455 1.132-1.256 1.231-1.807.204-1.14-.235-1.829-.853-2.287-.61-.453-1.497-.423-1.918.172-.526.743.27 1.297.27 1.297s-.176-.77.414-.77c.329 0 .565.373.565.705 0 .238-.147.914-1.126.914-1.592 0-3.04-3.478-5.794-3.478-2.565 0-3.076 1.177-3.462 1.718-.004.005-.005.011-.008.016-.005-.006-.007-.013-.012-.02-.386-.54-.896-1.717-3.461-1.717z" fill="#ff7043" fill-rule="evenodd" stroke-width=".3"/></symbol><symbol viewBox="0 0 300.00001 300" id="haskell" xmlns="http://www.w3.org/2000/svg"><g stroke-width="2.422"><path d="M23.928 240.5l59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z" fill="#ef5350"/><path d="M83.869 240.5l59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z" fill="#ffa726"/><path d="M228.72 188.08l-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924l-19.98-29.953h99.901v29.953z" fill="#ffee58"/></g></symbol><symbol viewBox="0 0 210 210" id="haxe" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -87)"><path fill="#f68712" stroke-width=".221" d="M42.78 191.545l63.431-63.43 63.431 63.43-63.431 63.431z"/><path d="M42.8 191.592L31.193 148.28 19.59 104.97 62.9 116.575l43.311 11.605-31.706 31.706z" fill="#fab20b" stroke-width=".266"/><path d="M105.956 128.111l-43.19-11.544-43.177-11.597 22.927.185 23.228.294 20.264 11.36z" fill="#fbc707" stroke-width=".265"/><path d="M19.59 104.97l11.596 43.176 11.545 43.19-11.303-19.948-11.36-20.263-.294-23.228z" fill="#fff200" stroke-width=".265"/><path d="M106.23 128.133l43.312-11.605 43.311-11.605-11.605 43.31-11.605 43.312-31.706-31.706z" fill="#f47216" stroke-width=".266"/><path d="M169.711 191.289l11.545-43.19 11.597-43.176-.185 22.927-.294 23.228-11.36 20.263z" fill="#f1471d" stroke-width=".265"/><path d="M192.853 104.923l-43.176 11.597-43.19 11.544 19.947-11.303 20.264-11.36 23.228-.293z" fill="#fbc707" stroke-width=".265"/><path d="M169.643 191.545l11.605 43.31 11.605 43.312-43.311-11.605-43.311-11.606 31.706-31.705z" fill="#f25c19" stroke-width=".266"/><path d="M106.487 255.025l43.19 11.544 43.176 11.598-22.927-.185-23.228-.294-20.264-11.36z" fill="#f68712" stroke-width=".265"/><path d="M192.853 278.167l-11.597-43.176-11.545-43.19 11.303 19.947 11.36 20.264.294 23.228z" fill="#f1471d" stroke-width=".265"/><path d="M106.211 254.976l-43.31 11.605-43.312 11.605 11.605-43.31L42.8 191.563l31.706 31.706z" fill="#f89c0e" stroke-width=".266"/><path d="M42.731 191.82l-11.545 43.19-11.597 43.176.185-22.927.294-23.228 11.36-20.263z" fill="#fff200" stroke-width=".265"/><path d="M19.59 278.186l43.175-11.597 43.19-11.544-19.947 11.303-20.264 11.36-23.228.293z" fill="#f25c19" stroke-width=".265"/></g></symbol><symbol viewBox="0 0 144 152" id="heroku" xmlns="http://www.w3.org/2000/svg"><path d="M118.68 13.279H26.865c-6.337 0-11.476 5.139-11.476 11.476V129.32c0 6.338 5.139 11.477 11.476 11.477h91.813c6.338 0 11.477-5.14 11.477-11.477V24.755c0-6.337-5.139-11.476-11.477-11.476zM44.08 121.669V96.165l14.346 12.752zm44.632 0v-38.08c-.063-2.976-1.496-6.551-7.97-6.551-12.966 0-27.51 6.52-27.654 6.586l-9.008 4.08V32.407h12.752v36.201c6.366-2.072 15.266-4.321 23.91-4.321 7.882 0 12.6 3.099 15.17 5.698 5.484 5.547 5.56 12.613 5.551 13.43v38.255zm3.188-68.54H79.149c5.011-6.576 8.158-13.496 9.564-20.723h12.751c-.86 7.243-3.796 14.187-9.563 20.722z" fill="#6963b9"/></symbol><symbol viewBox="0 0 24 24" id="hpp" xmlns="http://www.w3.org/2000/svg"><path d="M9.757 19.818H6.751v-5.882q0-2.381-1.737-2.381-.868 0-1.438.663-.56.662-.56 1.718v5.882H0V4.533h3.016v6.508h.037Q4.24 9.239 6.247 9.239q3.51 0 3.51 4.239z" stroke-width=".478" fill="#0277bd"/><path d="M13.073 11.448v2h-2v2h2v2h2v-2h2v-2h-2v-2zm7 0v2h-2v2h2v2h2v-2h2v-2h-2v-2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="html" xmlns="http://www.w3.org/2000/svg"><path d="M12 17.56l4.07-1.13.55-6.1H9.38L9.2 8.3h7.6l.2-1.99H7l.56 6.01h6.89l-.23 2.58-2.22.6-2.22-.6-.14-1.66h-2l.29 3.19L12 17.56M4.07 3h15.86L18.5 19.2 12 21l-6.5-1.8L4.07 3z" fill="#e44d26"/></symbol><symbol viewBox="0 0 24 24" id="http" xmlns="http://www.w3.org/2000/svg"><path d="M16.046 13.784c.074-.613.13-1.225.13-1.856s-.056-1.244-.13-1.856h3.137c.148.594.241 1.215.241 1.856a7.65 7.65 0 0 1-.241 1.856m-4.78 5.16c.557-1.03.984-2.144 1.281-3.304h2.738a7.452 7.452 0 0 1-4.019 3.304m-.232-5.16H9.828a12.314 12.314 0 0 1-.149-1.856c0-.631.056-1.253.149-1.856h4.343c.084.603.149 1.225.149 1.856 0 .63-.065 1.243-.149 1.856M12 19.315c-.77-1.113-1.393-2.348-1.773-3.675h3.545c-.38 1.327-1.002 2.562-1.773 3.675m-3.712-11.1h-2.71a7.353 7.353 0 0 1 4.01-3.304c-.557 1.03-.975 2.144-1.3 3.304m-2.71 7.425h2.71c.325 1.16.743 2.274 1.3 3.304a7.433 7.433 0 0 1-4.01-3.304m-.761-1.856a7.65 7.65 0 0 1-.241-1.856c0-.64.093-1.262.241-1.856h3.137c-.074.612-.13 1.225-.13 1.856 0 .63.056 1.243.13 1.856m4.046-9.253c.77 1.114 1.393 2.357 1.773 3.684h-3.545c.38-1.327 1.002-2.57 1.773-3.684m6.422 3.684h-2.738a14.523 14.523 0 0 0-1.28-3.304 7.412 7.412 0 0 1 4.018 3.304m-6.423-5.568c-5.132 0-9.28 4.176-9.28 9.28a9.28 9.28 0 0 0 9.28 9.282 9.28 9.28 0 0 0 9.281-9.281A9.28 9.28 0 0 0 12 2.647z" fill="#e53935" stroke-width=".928"/></symbol><symbol viewBox="0 0 24 24" id="image" xmlns="http://www.w3.org/2000/svg"><path d="M13.009 9.202h5.368l-5.368-5.368v5.368M6.177 2.37h7.808l5.856 5.856v11.711a1.952 1.952 0 0 1-1.952 1.952H6.178a1.951 1.951 0 0 1-1.952-1.952V4.322c0-1.083.868-1.952 1.952-1.952m0 17.567h11.71V12.13l-3.903 3.903-1.952-1.951-5.856 5.855M8.13 9.202a1.952 1.952 0 0 0-1.952 1.952 1.952 1.952 0 0 0 1.952 1.952 1.952 1.952 0 0 0 1.952-1.952A1.952 1.952 0 0 0 8.13 9.202z" fill="#26a69a" stroke-width=".976"/></symbol><symbol viewBox="0 0 512 512" id="ionic" xmlns="http://www.w3.org/2000/svg"><g fill="#4f8ff7"><path d="M423.592 132.804A31.855 31.855 0 0 0 429 115c0-17.675-14.33-32-32-32a31.853 31.853 0 0 0-17.805 5.409C344.709 63.015 302.11 48 256 48 141.125 48 48 141.125 48 256c0 114.877 93.125 208 208 208 114.873 0 208-93.123 208-208 0-46.111-15.016-88.71-40.408-123.196zM391.83 391.832c-17.646 17.646-38.191 31.499-61.064 41.174-23.672 10.012-48.826 15.089-74.766 15.089-25.94 0-51.095-5.077-74.767-15.089-22.873-9.675-43.417-23.527-61.064-41.174s-31.5-38.191-41.174-61.064C68.982 307.096 63.905 281.94 63.905 256c0-25.94 5.077-51.095 15.089-74.767 9.674-22.873 23.527-43.417 41.174-61.064s38.191-31.5 61.064-41.174c23.673-10.013 48.828-15.09 74.768-15.09 25.939 0 51.094 5.077 74.766 15.089a191.221 191.221 0 0 1 37.802 21.327A31.853 31.853 0 0 0 365 115c0 17.675 14.327 32 32 32 5.293 0 10.28-1.293 14.678-3.568a191.085 191.085 0 0 1 21.327 37.801c10.013 23.672 15.09 48.827 15.09 74.767 0 25.939-5.077 51.096-15.09 74.768-9.675 22.873-23.527 43.418-41.175 61.064z"/><circle cx="256.003" cy="256" r="96"/></g></symbol><symbol viewBox="0 0 24 24" id="java" xmlns="http://www.w3.org/2000/svg"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="javascript" xmlns="http://www.w3.org/2000/svg"><path d="M3 3h18v18H3V3m4.73 15.04c.4.85 1.19 1.55 2.54 1.55 1.5 0 2.53-.8 2.53-2.55v-5.78h-1.7V17c0 .86-.35 1.08-.9 1.08-.58 0-.82-.4-1.09-.87l-1.38.83m5.98-.18c.5.98 1.51 1.73 3.09 1.73 1.6 0 2.8-.83 2.8-2.36 0-1.41-.81-2.04-2.25-2.66l-.42-.18c-.73-.31-1.04-.52-1.04-1.02 0-.41.31-.73.81-.73.48 0 .8.21 1.09.73l1.31-.87c-.55-.96-1.33-1.33-2.4-1.33-1.51 0-2.48.96-2.48 2.23 0 1.38.81 2.03 2.03 2.55l.42.18c.78.34 1.24.55 1.24 1.13 0 .48-.45.83-1.15.83-.83 0-1.31-.43-1.67-1.03l-1.38.8z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="javascript-map" xmlns="http://www.w3.org/2000/svg"><path d="M18 8v2h2v10H10v-2H8v4h14V8h-4z" fill="#ffca28"/><path d="M2.444 2.506h14.135v14.136H2.444V2.506m3.714 11.811c.315.668.935 1.218 1.995 1.218 1.178 0 1.987-.629 1.987-2.003V8.993H8.805v4.508c0 .675-.275.848-.707.848-.455 0-.644-.314-.856-.683l-1.084.651m4.697-.14c.392.769 1.185 1.358 2.426 1.358 1.257 0 2.199-.652 2.199-1.854 0-1.107-.636-1.602-1.767-2.089l-.33-.141c-.573-.243-.816-.408-.816-.801 0-.322.243-.573.636-.573.377 0 .628.165.856.573l1.028-.683c-.432-.754-1.044-1.045-1.884-1.045-1.186 0-1.948.754-1.948 1.752 0 1.083.636 1.594 1.594 2.002l.33.141c.613.267.974.432.974.888 0 .377-.354.652-.903.652-.652 0-1.029-.338-1.312-.81l-1.083.63z" fill="#ffca28"/></symbol><symbol viewBox="0 0 180 180" id="jenkins" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="gia"><path transform="scale(1 -1)" fill="#37474f" d="M.899-144.42h144.42V0H.899z"/></clipPath></defs><g transform="matrix(1.0691 0 0 -1.0691 9.4 166.143)" clip-path="url(#gia)"><g fill-rule="evenodd"><path d="M107.96 30.661l-12.506-1.876-16.883-1.876-10.943-.312-10.629.312-8.13 2.502-7.19 7.815-5.628 15.945-1.25 3.44-7.504 2.5-4.377 7.191-3.126 10.317 3.44 9.067 8.128 2.814 6.565-3.127 3.127-6.878 3.752.626 1.25 1.563-1.25 7.19-.313 9.068 1.876 12.505-.074 7.143 5.701 9.114 10.005 7.19 17.508 7.504 19.383-2.814 16.883-12.193 7.817-12.505 5.002-9.067 1.25-22.51-3.752-19.384-6.877-17.195-6.566-9.066" fill="#f0d6b7"/><path d="M97.334-23.425l-44.709-1.876v-7.503l3.752-26.262-1.876-2.19-31.264 10.63-2.19 3.752-3.126 35.328-7.19 21.26-1.563 5.002 25.01 17.195 7.818 3.127 6.877-8.441 5.94-5.315 6.88-2.188 3.125-.938L68.57 1.899l2.814-3.44 7.19 2.502-5.002-9.693 27.2-12.818-3.439-1.876" fill="#335061"/><path d="M23.238 85.687l8.128 2.814 6.566-3.127 3.127-6.878 3.751.626.938 3.751-1.876 7.19 1.876 17.197-1.563 9.379 5.627 6.565 12.193 9.692-3.44 4.69-17.194-8.442-7.191-5.627-4.064-8.754-6.253-8.442-1.876-10.005 1.251-10.63" fill="#6d6b6d"/><path d="M36.055 115.07s4.69 11.567 23.448 17.195c18.759 5.628.938 4.065.938 4.065l-20.321-7.817-7.817-7.816-3.438-6.253 7.19.626M26.676 87.875s-6.566 21.886 18.446 25.012l-.938 3.752-17.195-4.065-5.003-16.257 1.251-10.63 3.439 2.188" fill="#dcd9d8"/></g><g fill="#f7e4cd"><path d="M36.681 58.799l4.094 3.966s1.847-.214 2.16-2.402c.312-2.19 1.25-21.886 14.693-32.516 1.227-.97-10.004 1.564-10.004 1.564L37.62 45.042M94.209 64.739s.729 9.477 3.28 8.748c2.553-.729 2.553-3.28 2.553-3.28s-6.198-4.01-5.833-5.468" fill-rule="evenodd"/><path d="M120.16 99.442s-5.153-1.088-5.628-5.628c-.474-4.54 5.628-.938 6.566-.625M82.327 99.129s-6.879-.938-6.879-5.314c0-4.378 7.817-4.065 10.005-2.19"/><g fill-rule="evenodd"><path d="M39.807 78.808s-11.881 7.191-13.131.312c-1.25-6.877-4.065-11.88 1.876-19.07l-4.064 1.25-3.752 9.691-1.25 9.38 7.19 7.504 8.129-.626 4.69-3.751.312-4.69M45.435 98.504s5.315 27.512 32.203 32.827c22.136 4.375 33.765-.938 38.142-5.94 0 0-19.696 23.447-38.455 16.257-18.759-7.191-32.514-20.322-32.202-28.762.532-14.377.313-14.382.313-14.382M117.97 122.27s-9.066.312-9.38-7.817c0 0 0-1.25.625-2.5 0 0 7.192 8.129 11.568 3.751"/><path d="M78.268 111.1s-1.56 12.477-12.199 5.223c-6.878-4.69-6.252-11.255-5.002-12.505s.91-3.77 1.862-2.04c.952 1.728.638 7.356 4.078 8.918 3.439 1.564 9.077 3.31 11.26.404"/></g></g><g fill="#49728b" fill-rule="evenodd"><path d="M48.874 26.597L19.486 13.466s12.193-48.46 5.94-63.467l-4.377 1.563-.313 18.446-8.128 35.015-3.44 9.692 30.639 20.633 9.067-8.753M51.896-.206l4.17-5.087v-18.76h-5.003s-.625 13.132-.625 14.696c0 1.563.624 7.19.624 7.19M52-26.866l-14.069-.625 4.065-2.813L52-31.868"/></g><g fill-rule="evenodd"><path d="M100.15-23.739l11.567.313 2.814-28.764-11.881-1.563-2.5 30.014" fill="#335061"/><path d="M103.27-23.739l17.508.938s7.19 18.133 7.19 19.07c0 .939 6.253 26.263 6.253 26.263l-14.069 14.694-2.813 2.501-7.504-7.503V3.148l-6.565-26.887" fill="#335061"/><path d="M111.09-21.55l-10.942-2.188 1.563-8.755c4.064-1.876 10.943 3.127 10.943 3.127M111.4 33.162l21.885-16.257.626 7.503-16.57 15.32-5.94-6.566" fill="#49728b"/><path d="M62.85-85.332l-6.473 26.266-3.22 19.38-.531 14.385 29.296 1.56 18.226.003-1.658-32.83 2.814-25.324-.312-4.69-23.76-1.876-14.382 3.126" fill="#fff"/><path d="M96.083-23.426s-1.563-32.515 3.127-55.65c0 0-9.38-5.94-23.136-7.503l26.262.938 3.126 1.875-3.752 51.273-.938 10.944" fill="#dcd9d8"/><path d="M115.06-49.691l12.193 3.44 23.135 1.25 3.44 10.629-6.254 18.446-7.19.938-10.005-3.127-9.599-4.686-5.095.935-3.972-1.56" fill="#fff"/><path d="M114.84-43.435s8.128 3.751 9.38 3.438L120.78-22.8l4.065 1.563s2.814-16.257 2.814-18.133c0 0 17.507-.938 19.07-.938 0 0 3.752 7.191 2.814 14.694l3.44-10.005.312-5.628-5.002-7.503-5.627-1.25-9.38.312-3.126 4.064-10.943-1.563-3.44-1.25" fill="#dcd9d8"/></g><path d="M102.56-21.241L95.682-3.733l-7.19 10.317s1.562 4.377 3.75 4.377h7.192l6.878-2.501-.625-11.568-3.127-18.134" fill="#fff"/><path d="M103.9-15.297S95.145 1.585 95.145 4.086c0 0 1.563 3.752 3.752 2.814 2.19-.938 6.879-3.439 6.879-3.439v5.94l-10.63 2.19-7.19-.939 12.193-28.763 2.5-.313" fill="#dcd9d8" fill-rule="evenodd"/><path d="M65.664 25.968l-8.661.942-8.13 2.501v-2.814l3.972-4.38 12.506-5.627" fill="#fff"/><path d="M51.689 25.031s9.693-4.065 12.819-3.127l.311-3.748-8.752 1.872-5.316 3.752.938 1.251" fill="#dcd9d8" fill-rule="evenodd"/><path d="M115.03 9.897c-5.305.156-10.098.786-14.294 1.97.285 1.72-.249 3.408.18 4.647 1.17.843 3.13.83 4.898 1.027-1.529.752-3.677 1.049-5.44.615-.042 1.194-.578 1.934-.902 2.868 2.982 1.064 10.024 8.044 13.984 5.732 1.887-1.099 2.689-7.377 2.835-10.43.122-2.533-.23-5.088-1.261-6.43" fill="#d33833" fill-rule="evenodd"/><path d="M115.03 9.897c-5.305.156-10.098.786-14.294 1.97.285 1.72-.249 3.408.18 4.647 1.17.843 3.13.83 4.898 1.027-1.529.752-3.677 1.049-5.44.615-.042 1.194-.578 1.934-.902 2.868 2.982 1.064 10.024 8.044 13.984 5.732 1.887-1.099 2.689-7.377 2.835-10.43.122-2.533-.23-5.088-1.261-6.43z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M89.66 18.569c-.014-.401-.03-.806-.047-1.21-1.656-1.089-4.33-1.076-6.148-1.99 2.68-.117 4.79-.763 6.614-1.672l-.118-3.033c-3.036-2.078-5.81-5.173-9.384-7.122-1.69-.922-7.622-3.294-9.42-2.875-1.017.236-1.109 1.499-1.516 2.689-.866 2.548-2.861 6.605-3.035 10.44-.222 4.846-.71 12.967 4.51 11.969 4.213-.804 9.113-2.745 12.375-4.527 1.993-1.09 3.146-2.436 6.17-2.669" fill="#d33833" fill-rule="evenodd"/><path d="M89.66 18.569c-.014-.401-.03-.806-.047-1.21-1.656-1.089-4.33-1.076-6.148-1.99 2.68-.117 4.79-.763 6.614-1.672l-.118-3.033c-3.036-2.078-5.81-5.173-9.384-7.122-1.69-.922-7.622-3.294-9.42-2.875-1.017.236-1.109 1.499-1.516 2.689-.866 2.548-2.861 6.605-3.035 10.44-.222 4.846-.71 12.967 4.51 11.969 4.213-.804 9.113-2.745 12.375-4.527 1.993-1.09 3.146-2.436 6.17-2.669z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M92.675 12.788c-.463 2.64-.999 3.393-.792 5.695 7.04 4.693 8.361-8.061.792-5.695" fill="#d33833" fill-rule="evenodd"/><path d="M92.675 12.788c-.463 2.64-.999 3.393-.792 5.695 7.04 4.693 8.361-8.061.792-5.695z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M102.87 10.649s-2.19 3.127-.626 4.065c1.564.938 3.127 0 4.065 1.563s0 2.501.313 4.377 1.877 2.189 3.44 2.501c1.562.313 5.94.938 6.565-.625l-1.876 5.627-3.752 1.25-11.88-6.877-.626-3.44v-6.877M70.041.331c-.376 4.88-.773 9.752-1.215 14.626-.662 7.279 1.748 6.009 8.057 6.009.964 0 5.933-1.15 6.289-1.876 1.705-3.483-2.851-2.709 1.964-5.335 4.065-2.216 11.246 1.346 9.603 6.273-.919 1.095-4.789.341-6.176 1.06l-7.327 3.8c-3.108 1.612-10.29 3.962-13.603 1.709-8.395-5.71.53-19.974 3.524-25.93" fill="#ef3d3a" fill-rule="evenodd"/><g fill="#231f20" fill-rule="evenodd"><path d="M78.268 111.1c-8.521 1.985-12.755-3.566-15.338-9.323-2.306.559-1.389 3.695-.806 5.294 1.525 4.194 7.672 9.778 12.694 9.02 2.161-.325 5.086-2.301 3.45-4.99M119.79 101.4l.404-.016c1.926-4 3.593-8.238 6.022-11.769-1.628-3.79-12.322-7.144-12.157-.338 2.313 1.01 6.305.206 8.356 1.497-1.186 3.254-2.897 6.024-2.625 10.626M82.63 101.29c1.827-3.35 2.422-6.868 5.019-9.4 1.17-1.14 3.444-2.529 2.316-5.698-.263-.747-2.189-2.414-3.3-2.741-4.06-1.2-13.521-.248-10.317 4.814 3.358-.157 7.871-2.18 10.38.257-1.927 3.081-5.363 9.177-4.098 12.768M118.26 67.253c-6.113-3.927-12.93-8.197-22.947-7.207-2.14 1.86-2.956 6.002-.877 8.737 1.082-1.861.402-5.284 3.419-5.799 5.684-.972 12.299 3.477 16.387 5.032 2.535 4.275-.219 5.847-2.503 8.597-4.675 5.636-10.947 12.622-10.72 21.06 1.89 1.37 2.053-2.092 2.325-2.722 2.44-5.714 8.585-13.021 13.07-17.912 1.1-1.205 2.914-2.36 3.115-3.157.582-2.315-1.513-5.09-1.27-6.63M37.668 71.387c-1.916 1.094-2.372 5.91-4.622 6.048-3.215.195-2.629-6.25-2.616-10.018-2.213 2.009-2.602 8.194-.976 11.37-1.853.91-2.68-1.003-3.708-1.677 1.32 9.595 14.036 4.45 11.922-5.723M122.15 63.257c-2.846-5.417-6.871-11.382-15.222-11.555-.17 1.75-.3 4.411.009 5.464 6.384.614 10.325 3.863 15.212 6.091M82.149 59.745c5.326-2.8 15.114-3.102 22.353-2.89.388-1.586.379-3.545.394-5.48-9.305-.463-20.307 1.84-22.747 8.37M81.136 54.523c3.683-9.247 16.341-8.182 27.016-7.927-.47-1.2-1.489-2.62-2.755-3.132-3.42-1.392-12.855-2.448-17.604.074-3.011 1.601-4.946 5.219-6.596 7.34-.797 1.024-4.765 3.64-.06 3.645"/></g><path d="M117.82 3.516c-4.322-7.402-8.457-15.005-13.585-21.534 2.15 6.32 3.07 16.9 3.394 24.965 4.498 2.105 8.349-.474 10.191-3.43" fill="#81b0c4" fill-rule="evenodd"/><g fill="#231f20" fill-rule="evenodd"><path d="M141.07-23.089c-4.839-.969-8.239-5.671-12.959-5.37 2.594 3.658 7.14 5.2 12.959 5.37M143.21-30.661c-3.944-.417-8.576-1.055-12.577-.726 1.894 2.892 9.19 1.894 12.577.726M144.58-37.19c-4.433-.096-9.942-.008-14.155.346 2.492 2.677 11.28.993 14.155-.346"/></g><g fill-rule="evenodd"><path d="M109.48-55.057c.636-5.567 2.843-11.207 2.566-17.304-2.45-.827-3.858-1.55-7.142-1.545-.232 5.181-.925 13.102-.718 18.041 1.615-.107 3.997 1.154 5.294.808" fill="#dcd9d8"/><path d="M102.33 26.985c-2.226-1.453-4.121-3.267-6.259-4.818-4.74-.235-7.327.328-10.81 3.05.057.219.407.121.42.39 5.075-2.262 11.524.92 16.648 1.378" fill="#f0d6b7"/><path d="M75.694-7.603c1.394 6.04 6.857 9.17 11.817 12.497 5.12-6.498 8.234-14.855 11.663-22.92-8.102 2.443-16.38 6.406-23.481 10.423" fill="#81b0c4"/><path d="M104.18-55.865c-.207-4.94.486-12.86.718-18.041 3.283-.004 4.691.718 7.142 1.545.276 6.096-1.93 11.737-2.566 17.304-1.298.346-3.679-.914-5.294-.808zm-51.13 28.09c2.165-19.906 5.301-36.639 11.054-54.266 12.766-3.876 28.157-4.214 39.441-.716-2.072 9.948-1.167 22.06-2.378 32.677-.912 7.98-.447 16.009-1.698 24.15-13.673 2.844-33 .665-46.418-1.845zm49.651 1.72c-.115-8.549.383-16.982 1.036-25.542 3.282.493 5.51.822 8.56 1.49-.99 8.241-.869 17.514-2.886 24.804-2.332-.023-4.385.027-6.71-.752zm16.653 1.378c-1.558.357-3.372.014-4.86-.015.7-6.969 2.397-14.659 2.995-21.974 2.342-.073 3.593 1.032 5.52 1.403.102 6.421-.562 15.268-3.655 20.586zm25.215-23.038c4.882 1.186 7.952 7.165 6.586 13.305-.916 4.127-2.548 11.898-4.295 14.538-1.29 1.953-4.79 4.51-7.584 2.72-4.545-2.91-12.552-3.755-15.867-7.278 1.662-5.534 2.178-13.135 2.864-20.146 5.678-.354 12.665 1.562 17.387-.471-3.297-1.068-7.575-1.077-10.423-2.633 2.328-1.125 7.778-.897 11.332-.035zM99.17-18.025c-3.43 8.063-6.543 16.42-11.663 22.918-4.96-3.327-10.423-6.456-11.817-12.497 7.1-4.017 15.379-7.98 23.481-10.422zm8.453 24.971c-.325-8.065-1.245-18.644-3.395-24.965 5.128 6.53 9.263 14.132 13.585 21.534-1.842 2.957-5.693 5.536-10.19 3.431zm-9.582 3.405c-1.943.21-3.592-2.233-6.117-1.177-.58-.64-1.105-1.333-1.695-1.958 5.579-6.723 8.114-16.262 12.423-24.163 2.312 7.59 2.045 15.904 2.555 24.188-3.177-.201-4.94 2.873-7.166 3.11zm-6.161 8.132c-.208-2.303.328-3.056.791-5.695 7.57-2.367 6.248 10.388-.791 5.695zm-8.394 2.755c-3.261 1.782-8.161 3.723-12.374 4.527-5.222.999-4.732-7.123-4.51-11.968.173-3.836 2.168-7.893 3.035-10.441.406-1.19.498-2.453 1.515-2.69 1.798-.418 7.73 1.954 9.42 2.875 3.575 1.95 6.348 5.045 9.384 7.123.04 1.011.078 2.021.119 3.032-1.826.91-3.935 1.555-6.615 1.673 1.818.914 4.492.901 6.148 1.989.016.405.033.81.047 1.21-3.024.234-4.176 1.58-6.17 2.67zm-31.152 5.659c-2.707-2.748 7.592-6.494 10.871-6.696-.018 1.739.991 3.378.788 4.626-3.895.684-9.013.232-11.66 2.07zm33.345-1.29c-.013-.27-.363-.172-.42-.39 3.482-2.722 6.07-3.285 10.81-3.05 2.137 1.551 4.033 3.365 6.259 4.818-5.124-.458-11.574-3.64-16.648-1.379zm30.606-9.282c-.146 3.053-.948 9.332-2.835 10.431-3.961 2.312-11.002-4.668-13.984-5.732.324-.934.86-1.674.901-2.868 1.764.434 3.912.137 5.44-.615-1.767-.198-3.727-.185-4.897-1.027-.429-1.239.105-2.927-.18-4.647 4.196-1.184 8.989-1.814 14.294-1.97 1.032 1.341 1.383 3.896 1.261 6.429zM47.777 24.24c-.85.606-6.6 8.087-7.388 7.777-10.405-4.103-20.134-11.199-28.828-17.91 8.29-17.787 11.635-39.579 12.227-60.582 9.496-4.441 17.836-10.844 30.722-11.512-1.491 10.55-2.852 19.962-3.699 29.895-3.237 1.365-7.882-.062-10.913.423-.025 3.651 4.628 1.6 5.015 4.054.292 1.858-2.56 1.998-1.631 4.923 2.368-.861 3.612-2.763 6.138-3.477 2.309 5.05-.032 13.985.3 18.205.064.792.397 4.39 2.172 3.759 1.57-.559-.09-9.569.082-13.563.157-3.68-.444-7.242 1.046-9.552a355.817 355.817 0 0 0 38.576 3.16c-2.964 1.272-6.485 2.475-10.345 4.651-2.093 1.18-8.69 3.635-9.293 5.622-.964 3.167 2.528 4.855 3.125 7.57-6.285-3.428-7.511 3.286-8.998 8.042-1.347 4.308-2.114 7.526-2.445 10.01-5.414 2.581-11.203 5.195-15.863 8.505zm63.009 6.872c8.67 4.204 10.232-15.711 6.834-22.127.525-1.914 2.331-2.646 3.069-4.366-4.838-8.667-10.211-16.756-15.148-25.32 3.672 2.286 8.917.409 13.238 2.12 1.58.624 2.722 4.24 3.918 7.133 3.29 7.958 6.743 17.99 8.28 25.586.346 1.73 1.292 5.5 1.08 7.04-.378 2.758-4.12 4.803-6.022 6.508-3.506 3.15-5.714 5.921-9.371 8.866-1.483-2.189-4.666-3.66-5.878-5.44zM27.95 107.99c-4.13-4.545-3.266-13.062-2.766-19.121 7.467 4.697 17.377-.372 17.284-8.36 3.565.094 1.332 4.452.687 7.259-2.107 9.169 3.55 19.13.256 27.516-6.395-.485-11.649-3.097-15.46-7.294zm29.558 26.38c-9.352-2.65-21.337-9.446-25.18-17.847 2.976.432 5.041 1.933 7.977 2.119 1.11.072 2.563-.466 3.838-.148 2.54.63 4.685 6.327 6.602 8.447 1.868 2.07 4.114 2.954 5.651 4.841.988.477 2.448.444 2.504 1.927-.428.457-.879.806-1.392.66zm48.681-2.493c-9.707 5.477-26.136 9.596-36.462 4.449-8.331-4.155-19.593-11.027-23.433-19.737 3.587-8.405-1.062-16.106-1.36-24.64-.157-4.54 2.139-8.504 2.315-13.446-1.228-2.025-4.978-2.275-7.574-2.136-.873 4.372-2.403 9.287-6.906 9.78-6.371.697-11.03-4.576-11.319-10.085-.342-6.48 4.978-17.22 12.517-16.475 2.913.287 3.629 3.207 6.802 3.177 1.72-3.432-2.653-4.51-3.103-6.964-.117-.634.363-3.112.642-4.274 1.37-5.658 4.422-12.982 7.427-17.29 3.814-5.464 11.307-6.288 19.37-6.823 1.44 3.101 6.743 2.846 10.2 2.035-4.143 1.64-7.993 5.617-11.185 9.137-3.665 4.039-7.378 8.371-7.566 13.65 6.927-9.61 12.65-18.003 25.246-22.23 9.53-3.196 20.662 1.465 27.986 6.608 3.039 2.137 4.853 5.529 7.013 8.634 8.082 11.626 11.854 28.219 11.024 44.303-.342 6.633-.327 13.244-2.552 17.706-2.326 4.666-10.193 8.84-14.8 4.62-.853 4.537 3.83 7.344 9.331 5.71-3.922 5.063-8.039 11.145-13.614 14.29zm18.084-149.66c7.585 3.77 21.757 10.149 26.512-.014 1.755-3.746 3.814-10.079 4.723-13.946 1.284-5.456-1.392-16.923-7-18.754-4.953-1.617-10.733-1.518-16.7-.32-.702.585-1.484 1.603-2.03 2.665-4.261.165-8.25-.229-11.615-1.98.319-3.15-1.812-3.656-3.81-4.305-1.48-5.872 2.963-13.541 1.9-18.896-.76-3.815-5.453-4.405-8.902-5.118-.113-2.12.15-3.89.386-5.683-.789-2.907-4.327-4.561-7.679-4.967-11.029-1.326-27.775-1.922-38.384 1.893-2.96 7.261-5.292 16.093-7.758 24.384-10.346-1.105-18.715 4.464-26.603 8.113-2.731 1.266-6.51 1.964-7.53 4.138-.99 2.105-.584 6.14-.83 9.95-.625 9.733-1.16 19.12-3.73 29.086-1.154 4.472-3.165 8.418-4.568 12.727C9.358 5.184 7.092 10.12 6.5 14.1c-.877 5.903 4.681 6.232 8.235 8.79 5.494 3.954 9.806 6.142 15.756 9.711 1.762 1.057 7.077 3.733 7.681 4.966 1.202 2.443-2.062 5.888-2.935 7.803-1.38 3.03-2.1 5.602-2.298 8.59-4.992.789-8.775 3.76-11.06 7.109-3.781 5.543-6.403 15.798-3.132 23.599.257.614 1.536 1.822 1.725 2.765.372 1.858-.7 4.329-.768 6.305-.343 10.14 1.716 18.875 8.541 21.932 2.771 11.038 12.688 14.71 22.032 20.195 3.493 2.05 7.343 3.36 11.32 4.824 14.263 5.25 36.15 4.261 47.987-4.692 5.02-3.797 13.044-11.813 15.914-17.617 7.58-15.323 7.042-40.931 1.74-59.571-.712-2.503-1.746-6.181-3.19-9.187-1.006-2.1-4.134-6.3-3.754-8.153.391-1.916 7.132-7.034 8.577-8.428 2.603-2.51 7.548-5.843 7.948-9.012.43-3.372-1.485-7.984-2.456-11.238-3.245-10.858-6.412-20.895-10.091-30.576" fill="#231f20"/><path d="M73.674 57.38c.411.548 2.674 1.38 5.84-.144 0 0-3.752-.626-3.44-6.881l-1.564.313s-1.615 5.672-.836 6.712" fill="#f7e4cd"/><path d="M101.09 3.617a1.72 1.72 0 1 0-3.44.001 1.72 1.72 0 0 0 3.44-.001M102.81-4.355a1.72 1.72 0 1 0-3.44 0 1.72 1.72 0 0 0 3.44 0" fill="#1d1919"/></g><g><rect transform="matrix(.8 0 0 -.8 0 144)" x="16.854" y="177.38" width="70.412" height="4.12" rx=".983" ry=".983"/><rect transform="scale(1 -1)" x="78.502" y="-2.097" width="50.037" height="3.296" rx=".786" ry=".786"/><rect transform="scale(1 -1)" x="13.483" y="-3.697" width="54.831" height="3.296" rx=".786" ry=".786"/><rect transform="scale(1 -1)" x="83.296" y="-3.697" width="45.243" height="3.296" rx=".786" ry=".786"/></g></g></symbol><symbol viewBox="0 0 24 24" id="json" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#fbc02d"/></symbol><symbol viewBox="0 0 50 50" id="julia" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -247)" stroke-width="5.673"><circle cx="13.497" cy="281.632" r="9.555" fill="#bc342d"/><circle cx="36.081" cy="281.632" r="9.555" fill="#864e9f"/><circle cx="24.722" cy="262.389" r="9.555" fill="#328a22"/></g></symbol><symbol viewBox="0 0 64 64" id="karma" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -233)"><path d="M38.556 288.413l-20.29-26.687 9.532-7.246 20.29 26.686h-.001.002l5.527 7.247z" fill="#359b8b" stroke-width=".173"/><path d="M35.681 241.172L24.92 255.327v-14.13H12.947v13.817l7.84 33.235h4.132v-13.147l.003.003 20.29-26.686-.008-.006 5.504-7.24H35.84v.12z" fill="#3cbeae" stroke-width=".206"/></g></symbol><symbol viewBox="0 0 24 24" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7 14a2 2 0 0 1-2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2 2 2 0 0 1-2 2m5.65-4A5.99 5.99 0 0 0 7 6a6 6 0 0 0-6 6 6 6 0 0 0 6 6 5.99 5.99 0 0 0 5.65-4H17v4h4v-4h2v-4H12.65z" fill="#26a69a"/></symbol><symbol viewBox="0 0 24 24" id="kivy" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.89 0 0 1.89 -12.157 -11.429)" fill="#90a4ae"><path d="M7.026 8.63v4.474l1.928-1.928a.437.437 0 0 0 0-.619zM9.38 16.072v-4.474l-1.927 1.927a.437.437 0 0 0 0 .62zM18.576 10.412l-5.346.564-.017.018 2.39 2.39zM9.922 8.502s.023 3.304-.003 4.452c-.02.856.371 1.114.746 1.507.538.564 1.599 1.57 1.599 1.57a.53.53 0 0 0 .75 0l1.843-1.844a.53.53 0 0 0 0-.75z"/></g></symbol><symbol viewBox="0 0 24 24" id="kl" xmlns="http://www.w3.org/2000/svg"><defs><style>.a{fill:#3aaae1}.b{fill:#fdfeff}</style></defs><title>kl</title><path d="M12.033 1.737c-.25-.003-.5.11-.729.337C8.225 5.15 5.15 8.227 2.078 11.31c-.144.144-.229.346-.341.521v.41c.16.223.294.474.485.666a3259.51 3259.51 0 0 0 8.936 8.937c.193.192.443.325.666.486h.41c.205-.142.436-.256.609-.428 3.046-3.041 6.09-6.083 9.133-9.127.47-.47.472-1.005.006-1.472l-9.218-9.217c-.23-.23-.48-.347-.731-.35zm-1.062 4.545l1.386.832c.702.422 1.403.846 2.109 1.262a.544.544 0 0 1 .04.026l.016.013.017.013c.061.056.089.123.088.224a510.281 510.281 0 0 0 0 3.794.463.463 0 0 1-.007.094c-.015.069-.054.103-.142.109a.464.464 0 0 1-.044.002c-.045-.002-.09-.002-.136-.003-.323-.006-.648-.001-.998-.001v-.527-1.34-.671-.003l.004-.668c0-.147-.039-.231-.17-.308-.893-.528-1.78-1.066-2.67-1.6-.051-.03-.101-.065-.173-.111l.001-.003h-.001zm.362 3.39c.068-.003.119.043.173.138.085.148.174.293.264.44l.015.025c.096.154.194.31.292.47l-1.915 1.176c-.337.207-.673.417-1.014.617-.113.067-.154.143-.154.277.01.977.01 1.955.014 2.932V16H7.7V16h-.002c-.004-.053-.014-.112-.014-.17-.005-1.25-.006-2.501-.015-3.751 0-.142.045-.222.164-.294a467.13 467.13 0 0 0 3.353-2.054l.016-.01a.606.606 0 0 1 .032-.017l.016-.008a.308.308 0 0 1 .033-.013l.012-.004a.157.157 0 0 1 .028-.005l.01-.001zm5.677 3.126l.314.54.346.594v.001c-.158.094-.298.178-.438.259l-3.097 1.798c-.106.062-.189.071-.3.01l-.893-.496-1.524-.843-.895-.493c-.035-.02-.068-.044-.129-.085h.001l.137-.25.495-.902 1.446.795c.442.243.886.483 1.323.734.121.07.212.072.334 0 .894-.525 1.792-1.043 2.689-1.563.057-.034.118-.061.191-.1z" fill="#29b6f6" stroke-width=".041"/></symbol><symbol viewBox="0 0 24 24" id="kotlin" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="gpb"><stop offset="0" stop-color="#cb55c0"/><stop offset="1" stop-color="#f28e0e"/></linearGradient><linearGradient id="gpa"><stop offset="0" stop-color="#0296d8"/><stop offset="1" stop-color="#8371d9"/></linearGradient><linearGradient xlink:href="#gpa" id="gpc" x1="1.725" y1="22.67" x2="22.185" y2="1.982" gradientUnits="userSpaceOnUse" gradientTransform="translate(1.638 1.155) scale(.89324)"/><linearGradient xlink:href="#gpb" id="gpd" x1="1.869" y1="22.382" x2="22.798" y2="3.377" gradientUnits="userSpaceOnUse" gradientTransform="translate(1.638 1.155) scale(.89324)"/></defs><path d="M3.307 3.003v18.048h18.05v-.03L16.88 16.51l-4.48-4.515 4.48-4.515 4.443-4.477H3.307z" fill="url(#gpc)"/><path d="M12.538 3.003l-9.23 9.23v8.818h.083l9.032-9.032-.025-.024 4.48-4.515 4.444-4.477h-8.784z" fill="url(#gpd)"/></symbol><symbol viewBox="0 0 240 240" id="laravel" xmlns="http://www.w3.org/2000/svg"><path d="M216.05 119.036c-1.433.343-24.945 6.673-24.945 6.673l-19.227-28.622c-.537-.828-.99-1.656.359-1.849 1.345-.196 23.195-4.477 24.182-4.723.99-.245 1.837-.536 3.053 1.267 1.21 1.8 17.836 24.626 18.464 25.506.627.877-.447 1.41-1.883 1.748m-4.101 49.326c.588 1.003 1.176 1.64-.67 2.367-1.843.73-62.243 22.847-63.418 23.39-1.173.546-2.092.73-3.607-1.637-1.51-2.362-21.16-39.264-21.16-39.264l64.03-18.075c1.876-.644 2.317-.405 3.103.822 1.074 1.68 21.143 31.403 21.726 32.4m-103.7-21.087c-.78.202-37.566 9.733-39.525 10.22-1.965.485-1.965.246-2.188-.49-.226-.727-43.728-98.053-44.333-99.271-.605-1.214-.574-2.177 0-2.177.571 0 34.734-3.313 35.944-3.383 1.207-.07 1.08.205 1.526 1.033l49.025 91.818c.84 1.58 1.239 1.81-.452 2.248m94.588-59.77c-3.5-4.58-5.2-3.751-7.357-3.41-2.154.336-27.277 4.915-30.194 5.449-2.918.536-4.758 1.803-2.963 4.53 1.597 2.422 18.113 27.824 21.751 33.42l-65.663 17.066L66.18 49.832c-2.075-3.342-2.507-4.514-7.236-4.28-4.735.23-40.969 3.495-43.55 3.731-2.58.233-5.416 1.479-2.835 8.09 2.583 6.612 43.734 102.82 44.88 105.62 1.149 2.803 4.128 7.345 11.11 5.527 7.157-1.871 31.969-8.894 45.52-12.742 7.163 14.07 21.77 42.619 24.473 46.707 3.607 5.459 6.089 4.56 11.626 2.738 4.325-1.42 67.65-26.129 70.502-27.4 2.855-1.273 4.613-2.184 2.685-5.275-1.419-2.28-18.124-26.558-26.876-39.26 5.993-1.733 27.305-7.888 29.575-8.557 2.646-.779 3.008-2.19 1.572-3.94-1.436-1.755-21.293-28.72-24.79-33.296z" fill="#ff5722" stroke="#ff5722" stroke-width="8.852" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" id="less" xmlns="http://www.w3.org/2000/svg"><path d="M13.696 2.999V5h2.002v5a2 2 0 0 0 1.999 2 2 2 0 0 0-2 2v5h-2v2h2a2 2 0 0 0 2-2v-4a2 2 0 0 1 2-2h1V11h-1a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2.001zm-.03 12.766v.47a1 1 0 0 0 .03-.236 1 1 0 0 0-.03-.234zM10.566 21v-2.001H8.565v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2.001v-2H8.565a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-.999V13h1a2 2 0 0 1 2 2v3.999A2 2 0 0 0 8.564 21zm.03-12.766v-.47a1 1 0 0 0-.03.236 1 1 0 0 0 .03.234z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="lib" xmlns="http://www.w3.org/2000/svg"><path d="M19 7H9V5h10m-4 10H9v-2h6m4-2H9V9h10m1-7H8a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2M4 6H2v14a2 2 0 0 0 2 2h14v-2H4V6z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 40 40" id="livescript" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -257)" fill="#317eac"><path stroke-width="3.299" d="M5.419 260.18h3.685v34.207H5.419z"/><path stroke-width="3.299" d="M37.074 288.197v3.685H2.867v-3.685z"/><path stroke-width="2.894" d="M29.612 265.658l2.004 2.005L7.428 291.85l-2.004-2.005z"/><path stroke-width="2.325" d="M10.73 262.471h2.835v22.08H10.73z"/><path stroke-width="2.063" d="M15.36 262.519h2.835v17.382H15.36z"/><path stroke-width="1.77" d="M19.99 262.471h2.835v12.802H19.99z"/><path stroke-width="1.422" d="M24.526 262.491h2.835v8.254h-2.835z"/><path stroke-width="1.128" d="M28.783 262.463h2.835v5.197h-2.835z"/><path stroke-width="2.325" d="M34.801 286.545v-2.835h-22.08v2.835z"/><path stroke-width="2.063" d="M34.753 281.914v-2.835H17.371v2.835z"/><path stroke-width="1.77" d="M34.801 277.284v-2.835H21.999v2.835z"/><path stroke-width="1.422" d="M34.781 272.749v-2.835h-8.254v2.835z"/><path stroke-width="1.128" d="M34.809 268.492v-2.835h-5.197v2.835z"/></g></symbol><symbol viewBox="0 0 24 24" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M12 17a2 2 0 0 0 2-2 2 2 0 0 0-2-2 2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3z" fill="#ffd54f"/></symbol><symbol viewBox="0 0 24 24" id="lua" xmlns="http://www.w3.org/2000/svg"><circle cx="12.203" cy="12.102" r="10.322" fill="none" stroke="#42a5f5"/><path d="M12.33 5.746a6.483 6.381 0 0 0-6.482 6.381 6.483 6.381 0 0 0 6.482 6.38 6.483 6.381 0 0 0 6.484-6.38 6.483 6.381 0 0 0-6.484-6.38zm1.86 1.916a2.329 2.292 0 0 1 2.33 2.293 2.329 2.292 0 0 1-2.33 2.291 2.329 2.292 0 0 1-2.329-2.29 2.329 2.292 0 0 1 2.328-2.294z" fill="#42a5f5" fill-rule="evenodd"/><ellipse cy="4.615" cx="19.631" rx="2.329" ry="2.292" fill="#42a5f5" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" id="markdown" xmlns="http://www.w3.org/2000/svg"><path d="M2 16V8h2l3 3 3-3h2v8h-2v-5.17l-3 3-3-3V16H2m14-8h3v4h2.5l-4 4.5-4-4.5H16V8z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" preserveAspectRatio="xMidYMid" id="markojs" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -120.96)" stroke-width=".984"><path d="M4.002 126.482c-.655 1.07-1.32 2.14-1.976 3.21-.655 1.06-1.308 2.142-1.963 3.212l.002.002-.002.002c.655 1.07 1.308 2.15 1.963 3.211.655 1.07 1.32 2.141 1.976 3.211h3.33c-.664-1.07-1.318-2.14-1.974-3.21-.653-1.069-1.307-2.145-1.961-3.214.654-1.068 1.308-2.146 1.961-3.215a601.93 601.93 0 0 1 1.974-3.209z" fill="#2196f3"/><path d="M3.999 126.482l-.002.002c.655 1.07 1.31 2.15 1.964 3.212.655 1.07 1.32 2.14 1.974 3.21h3.331c-.664-1.07-1.319-2.14-1.974-3.21-.653-1.068-1.306-2.146-1.96-3.214z" fill="#26a69a"/><path d="M15.203 126.482l.002.002c-.655 1.07-1.31 2.15-1.965 3.212-.655 1.07-1.319 2.14-1.974 3.21h-3.33c.664-1.07 1.318-2.14 1.973-3.21.654-1.069 1.307-2.146 1.961-3.214z" fill="#8bc34a"/><path d="M11.874 126.484c.664 1.07 1.318 2.14 1.974 3.21.653 1.068 1.307 2.146 1.961 3.214-.654 1.069-1.308 2.145-1.961 3.213-.656 1.07-1.31 2.14-1.974 3.21h3.33c.655-1.07 1.319-2.14 1.974-3.21.655-1.06 1.31-2.14 1.966-3.21l-.002-.003.002-.002c-.656-1.07-1.311-2.152-1.966-3.213-.655-1.07-1.319-2.138-1.974-3.209z" fill="#ffc107"/><path d="M16.74 126.482c.665 1.07 1.319 2.14 1.974 3.21.654 1.068 1.306 2.146 1.96 3.214-.654 1.069-1.306 2.145-1.96 3.213-.655 1.07-1.31 2.141-1.974 3.211h3.33c.656-1.07 1.32-2.14 1.974-3.21.655-1.062 1.31-2.141 1.966-3.212l-.002-.002.002-.002c-.655-1.07-1.31-2.152-1.966-3.213-.655-1.07-1.318-2.138-1.973-3.209z" fill="#f44336"/></g></symbol><symbol viewBox="0 0 23 24" id="mathematica" xmlns="http://www.w3.org/2000/svg"><path d="M11.512 1.523l-.073.025-.46.794-.454.763-1.217 2.09H9.29L5.435 3.5l-.1-.047h-.018v.092l.025.163v.086l.132 1.226v.082l.032.252v.082l.22 2.137v.075l.018.082v.06l-2.348.507-.04.015-.457.1-.025.01h-.042l-1.096.244-.04.007-.17.036v.082l.018.01 1.859 2.086.053.052.114.132.804.909v.005l-.053.05-.22.257-2.564 2.875-.01.007v.082l.071.006.295.075 1.697.366v.006l2.139.472h.015v.047l-.036.252v.08l-.046.412v.082l-.036.244v.082l-.045.412v.08l-.05.41v.08l-.036.244v.082l-.046.412v.082l-.05.407v.082l-.032.248V20l-.05.407v.104h.037l3.642-1.6.294-.134h.018l.177.312.539.911.015.032.854 1.465.16.262.404.695.007.022h.092l.005-.022.017-.025.56-.947.014-.042.6-1.033.316-.539.644-1.091.05.013 3.906 1.721h.035v-.085l-.138-1.32v-.082l-.032-.244v-.082l-.035-.245v-.085l-.033-.244v-.081l-.032-.245v-.082l-.032-.244v-.085l-.035-.245v-.082l-.032-.245v-.082l-.033-.244v-.085l-.025-.17v-.053l1.632-.354.043-.008.458-.107h.028v-.01l.23-.05.03-.01h.042l.382-.09.025-.01h.043l.194-.05h.033l1.015-.23.07-.007v-.064l-.015-.013-1.19-1.342-.028-.028-.197-.22-1.428-1.604v-.006l.295-.323.4-.457 2.148-2.408.015-.01v-.065l-.035-.008-1.288-.28-.372-.084-.047-.01-2.481-.544v-.045l.432-4.265v-.02h-.042l-.302.135-.01.014h-.025l-3.307 1.45-.297.135h-.015l-2.028-3.483-.099-.145-.014-.045zm-.001 1.114l1.365 2.323.34.592-.008.025-1.18 1.511-.517.66-.012-.01-.258-.335-.04-.05-1.397-1.787.03-.063 1.378-2.365.287-.491zm4.908 2.039l-.007.025-.168.225-.538.066zm-9.817.004l.053.02.677.3h-.499l-.224-.3zM16.947 5l-.123 1.248-.113-.928.226-.307zm-9.26.156l.053.024.705.309-.757-.175zm7.388.116l.02.168-1.318.403.003-.003.16-.071 1.015-.444zM9.669 6.388l.944 1.204v.01L9.483 7.2zm3.55.172l.21.682-.234.084-.089.022-.702.255.008-.022.776-.982zm-5 .836l.986.356.898.312.048.02 1.054.373.011 3.086-.362-.117-.67-.224-.081-.038-.735-.245-.77-.256-.29-.1-.011-.255-.032-1.195-.01-.287-.015-.894-.013-.297zm6.583 0l-.011.227-.028.9-.008.303-.032 1.475-.01.262-.337.117-.734.245-.77.256-.712.245-.355.117.01-3.086 1.632-.578zm.585.437l.09.735.79-.097-.915 1.302-.018.006.01-.183.018-.877zm-9.451.536l.152.22 1.447 2.049-2.607.968-.05.015-1.972-2.214-.28-.312.003-.01.115-.018.424-.1.14-.021.337-.078.042-.01zm11.146.003l3.284.713.029.01-.022.025-1.954 2.192-.277.312-.092-.036-2.564-.95.475-.681.152-.216zM6.787 8.52h.86l.036 1.258-.013-.006-.763-1.078zm1.358 2.625l.152.06.77.252.712.245.746.247.49.167-.065.092-1.723 2.334-1.015-.302-.082-.017-.035-.015-1.902-.56.938-1.22.981-1.277zm6.73 0l.033.006 1.787 2.327.132.17-.128.036-.032.014-2.196.642-.105.032-.564.17-.018-.003-1.053-1.44-.174-.239-.547-.726-.007-.018.469-.16.769-.254.713-.245.77-.252zm-7.766.305l-.007.02-.405.523-.291-.291.657-.245zm8.802 0l.043.007.578.212.714.27-.661.394-.375-.479-.03-.042-.262-.342zm-10.843.75l-.67.668.355-.397.207-.23zm12.911.016l.068.025.045.042.554.627.042.043.204.228-.255.135zm-6.473.265l.022.015 1.38 1.872.032.05.343.465.008.031-.088.117-.422.629-.047.074-.245.343-.97 1.43-.013.007-1.18-1.72-.096-.16-.493-.708-.008-.037 1.618-2.191.007-.01zm7.827 1.194l.565.633.063.082-.272-.093-.037-.013zm-15.785.148l.297.299-.637.218-.152.05.038-.058zm13.224.47l-.855.448.346.66-.185-.058-.27-.088-1.092-.348.012-.01zm-9.687.255l1.222.356-.006.007-.458.145-.443.135-.032.01-.49.157zm-2.765.048l.318.32 2.007.517-.567.18-.055.004-2.103-.469-.744-.156.007-.006zm14.966.205l.548.188v.003l-.457.1-.043.014-1.069.23zm-10.23.507l.007.227.01.347.025 1.363.025.691-.007.255-.24.107-2.863 1.255.032-.372.033-.255.017-.227.031-.256.037-.407.045-.42.018-.23.032-.251.032-.412.05-.414.013-.14 1.455-.457.003-.014.301-.098zm4.908 0l1.245.39v.014l.312.1 1.146.362.022.23.03.255.043.408.04.42.017.23.033.251.032.412.042.325.078.848-.078-.04-3.025-1.322-.004-.305.06-2.368zm-4.295.617l.015.007.067.107.6.875-.64.531-.034-1.438zm3.671 0h.008l-.005.06-.02.678-.005.214-.479-.223zm-2.888 3.605l.763.915.001.37-.017-.006-.025-.05-.464-.791-.012-.018zm1.53.61l.184.083-.343.586-.018.007.002-.532z" fill="#f44336" fill-rule="evenodd" stroke="#f44336" stroke-width=".7747499999999999" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 720 720" id="matlab" xmlns="http://www.w3.org/2000/svg"><title>Layer 1</title><path d="M209.247 329.98L52.368 387.638l121.325 85.822 96.752-95.804-61.198-47.674z" fill="#4db6ac" fill-rule="evenodd" stroke-width=".3"/><path d="M480.193 71.446c-13.123 1.784-9.565 1.013-28.4 16.09-18.008 14.418-69.925 100.347-97.673 129.256-24.688 25.722-34.46 12.199-60.102 33.661-25.68 21.494-65.273 64.464-65.273 64.464l63.978 47.32L394.15 222.754c23.948-32.932 23.694-37.266 36.744-71.82 6.384-16.907 17.76-29.9 27.756-45.809 12.488-19.874 30.186-34.855 21.543-33.68z" fill="#00897b" fill-rule="evenodd" stroke-width=".3"/><path d="M478.206 69.796c-31.268-.189-62.068 137.245-115.56 242.691-54.543 107.519-162.235 176.82-162.235 176.82 18.156 8.243 34.681 4.91 54.236 23.394 13.375 16.164 52.09 95.976 75.174 146.117 0 0 18.964-10.297 42.994-27.695 24.03-17.397 53.124-41.896 73.384-70.3 26.883-37.692 47.897-61.043 65.703-75.271 17.806-14.23 32.404-19.336 46.458-20.54 50.238-4.305 124.582 85.792 124.582 85.792S527.267 70.09 478.206 69.796z" fill="#ffb74d" fill-rule="evenodd" stroke-width=".3"/></symbol><symbol viewBox="0 0 24 24" id="merlin" xmlns="http://www.w3.org/2000/svg"><text style="line-height:1.25;-inkscape-font-specification:'Century Gothic Bold'" x="1.953" y="21.178" transform="scale(.99582 1.0042)" font-weight="700" font-size="30.255" font-family="Century Gothic" letter-spacing="0" word-spacing="0" fill="#42a5f5" stroke-width=".756"><tspan x="1.953" y="21.178" style="-inkscape-font-specification:'Century Gothic Bold'" font-size="22.745">M</tspan></text></symbol><symbol viewBox="0 0 192 191.99999" id="mocha" xmlns="http://www.w3.org/2000/svg"><title>Mocha Logo</title><g transform="translate(-354.75 -262.42) scale(4.835)" fill="#a1887f"><path d="M103.6 69.6c0-.5-.4-1-1-1H83.8c-.5 0-1 .4-1 1 0 3.4.5 15.1 5.5 20.8.2.2.4.3.7.3h8.4c.3 0 .5-.1.7-.3 5-5.6 5.5-17.3 5.5-20.8zm-7.4 18.2h-5.9c-.3 0-.5-.1-.7-.3-3.4-4-3.8-12-3.9-14.8 0-.5.4-1 1-1h13.2c.5 0 1 .4 1 1 0 2.8-.5 10.7-3.9 14.8-.3.2-.5.3-.8.3zM95.1 66.6s3.6-2.1 1.4-5.9c-1.3-2-1.9-3.7-1.4-4.4-1.3 1.6-3.5 3.3-1.1 6.9.8.9 1.2 2.8 1.1 3.4zM91.1 66.9s2.4-1.4.9-4c-.9-1.3-1.3-2.5-.9-2.9-.9 1.1-2.3 2.2-.7 4.7.5.5.7 1.8.7 2.2z"/><path d="M99.3 78.5c-.4 2.7-1.2 5.8-2.9 7.8-.2.2-.4.3-.6.3h-5c-.2 0-.5-.1-.6-.3-1.2-1.5-2-3.5-2.5-5.6 0 0 5.8.8 9.1-.4 2.4-.9 2.5-1.8 2.5-1.8z"/></g></symbol><symbol viewBox="0 0 24 24" id="movie" xmlns="http://www.w3.org/2000/svg"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V4h-4z" fill="#ff9800"/></symbol><symbol viewBox="0 0 24 24" id="music" xmlns="http://www.w3.org/2000/svg"><path d="M16 9V7h-4v5.5c-.42-.31-.93-.5-1.5-.5A2.5 2.5 0 0 0 8 14.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5V9h3m-4-7a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2z" fill="#ef5350"/></symbol><symbol viewBox="0 0 24 24" id="mxml" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5l3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41-3.74 3.74m11.16 0l-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41 3.74-3.74z" fill="#ffa726"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-actions" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#ab47bc" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-effects" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#26c6da" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#e53935" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-state" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#9ccc65" stroke-width="12.914"/></symbol><symbol viewBox="0 0 24 24" id="nim" xmlns="http://www.w3.org/2000/svg"><path d="M4.464 15.75L2.288 3.78l5.985 7.617L12.08 3.78l3.809 7.617 5.985-7.617-2.177 11.97H4.464m15.234 3.264a1.088 1.088 0 0 1-1.088 1.088H5.553a1.088 1.088 0 0 1-1.089-1.088v-1.089h15.234z" stroke-width="1.088" fill="#ffca28"/></symbol><symbol viewBox="0 0 500 500" id="nix" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-1.965 36.302)" stroke-width=".395"><path d="M135.59 415.7c0-.295-2.752-5.283-6.116-11.084-3.364-5.801-6.116-10.776-6.116-11.055s9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694 0 .14-11.836.21-26.302.154-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.365.96h-13.229c-10.896 0-13.229-.095-13.229-.538zm167.58-125.61c-.134-.216 1.188-2.863 2.938-5.882 6.924-11.944 84.291-145.75 96.491-166.88 7.143-12.371 13.142-22.465 13.333-22.433.363.062 25.861 43.105 25.861 43.655 0 .174-6.761 11.952-15.026 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.564.564 30.213.564h29.996l.958 1.48c.526.814 3.296 5.547 6.155 10.518 2.859 4.971 5.45 9.29 5.756 9.597.706.705.704.724-.16 1.572-.395.388-3.36 5.323-6.587 10.965-3.228 5.643-6.056 10.387-6.285 10.543-.23.156-19.695.171-43.256.034l-42.84-.249-.804 1.15c-.441.632-7.504 12.736-15.696 26.897l-14.892 25.747H339.03c-8.517 0-20.015.116-25.55.259-6.55.168-10.15.121-10.309-.135zM169.42 132.23c-56.373-.055-102.5-.182-102.5-.282 0-.1 5.617-10.132 12.481-22.294l12.481-22.112h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427-8.139-14.199-14.797-25.917-14.797-26.04 0-.123 2.682-4.853 5.96-10.51s6.003-10.578 6.055-10.934c.086-.586 1.376-.648 13.572-.648 7.413 0 13.463.143 13.446.317-.017.174.222.707.531 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.118.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z" fill="#5075c1"/><path d="M25.289 203.14c-6.098 10.563-6.69 11.711-6.225 12.078.283.224 3.18 5.044 6.44 10.712 3.261 5.668 6.017 10.355 6.124 10.417.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.472.273-1.08 3.094-14.637 26.574L62.06 289.793l12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956.086.05 23.257-39.831 51.492-88.624 11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.097-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.024.037c-.011.003-1.62-.001-1.825 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.568.03-1.147-.03-1.29-.132-.142-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.199 21.131 89.393 155.03 96.276 167 1.502 2.613 2.92 4.803 3.443 5.348.9-1.249 3.531-5.63 7.954-13.219a1342.88 1342.88 0 0 1 10.049-17.76l6.606-11.443c.692-1.403.754-1.818.653-2.117-.162-.48-6.904-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.991-26.32a.73.73 0 0 1-.009-.366l-.426-.913L359.42 72.5c3.69-6.307 6.425-11.042 9.47-16.29 9.159-15.948 12.037-21.189 11.896-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.145.035-.42.112-5.454.195-11.189.185-5.734-.01-11.22.024-12.188.073l-1.76.089-14.997 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.272.05-.255-.148-7.143-11.902-15.306-26.119l-14.36-25.016c-.115-.186-.444-.744-.457-.752-.477-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.042.48.329 1.05 25.338 43.735 25.772 43.985.207.119 14.178.239 31.05.266 26.651.044 30.75.152 31.234.832.308.43 9.988 17.214 21.513 37.296s21.152 36.627 21.394 36.767c.242.14 5.927.243 12.633.23 6.706-.013 12.401.099 12.657.246.132.076.382-.141.852-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.441-9.774-.195-.712-.065-.822 1.156-.98 1.956-.252 57.397-.057 58.07.205.238.092.79-.569 2.594-3.497 1.866-3.067 5.03-8.524 11-18.866 7.22-12.505 13.044-22.784 12.942-22.843-.102-.059-.771-.051-1.489.016l-.046.001c-4.452.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.121.01-2.095.076-2.66.172a.25.25 0 0 0-.062.083z" fill="#7db7e1"/></g></symbol><symbol viewBox="0 0 24 24" id="nodejs" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.85c-.27 0-.55.07-.78.2l-7.44 4.3c-.48.28-.78.8-.78 1.36v8.58c0 .56.3 1.08.78 1.36l1.95 1.12c.95.46 1.27.47 1.71.47 1.4 0 2.21-.85 2.21-2.33V8.44c0-.12-.1-.22-.22-.22H8.5c-.13 0-.23.1-.23.22v8.47c0 .66-.68 1.31-1.77.76L4.45 16.5a.26.26 0 0 1-.11-.21V7.71c0-.09.04-.17.11-.21l7.44-4.29c.06-.04.16-.04.22 0l7.44 4.29c.07.04.11.12.11.21v8.58c0 .08-.04.16-.11.21l-7.44 4.29c-.06.04-.16.04-.23 0L10 19.65c-.08-.03-.16-.04-.21-.01-.53.3-.63.36-1.12.51-.12.04-.31.11.07.32l2.48 1.47c.24.14.5.21.78.21s.54-.07.78-.21l7.44-4.29c.48-.28.78-.8.78-1.36V7.71c0-.56-.3-1.08-.78-1.36l-7.44-4.3c-.23-.13-.5-.2-.78-.2M14 8c-2.12 0-3.39.89-3.39 2.39 0 1.61 1.26 2.08 3.3 2.28 2.43.24 2.62.6 2.62 1.08 0 .83-.67 1.18-2.23 1.18-1.98 0-2.4-.49-2.55-1.47a.226.226 0 0 0-.22-.18h-.96c-.12 0-.21.09-.21.22 0 1.24.68 2.74 3.94 2.74 2.35 0 3.7-.93 3.7-2.55 0-1.61-1.08-2.03-3.37-2.34-2.31-.3-2.54-.46-2.54-1 0-.45.2-1.05 1.91-1.05 1.5 0 2.09.33 2.32 1.36.02.1.11.17.21.17h.97c.05 0 .11-.02.15-.07.04-.04.07-.1.05-.16C17.56 8.82 16.38 8 14 8z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 300 300" id="nodemon" xmlns="http://www.w3.org/2000/svg"><title>nodemon</title><path d="M149.868 20.62c-2.124 0-4.25.55-6.154 1.648L41.899 81.083a12.306 12.306 0 0 0-6.15 10.652v117.633a12.29 12.29 0 0 0 6.152 10.646l101.815 58.766h.001a12.282 12.282 0 0 0 12.291 0l101.84-58.766a12.29 12.29 0 0 0 6.153-10.652V91.738a12.31 12.31 0 0 0-6.146-10.652L156.015 22.27a12.302 12.302 0 0 0-6.153-1.648zM83.303 70.93s11.789 33.031 35.477 31.934l27.74-15.961a7.348 7.348 0 0 1 3.414-.99h.641a7.233 7.233 0 0 1 3.404.99l27.738 15.961c23.69 1.094 35.475-31.934 35.475-31.934 5.233 23.154 1.06 38.641-5.924 48.942l4.541 2.614h.002c2.321 1.327 3.734 3.795 3.737 6.49l-.12 95.811a3.724 3.724 0 0 1-1.855 3.227 3.624 3.624 0 0 1-3.735 0L177.1 206.971c-2.311-1.363-3.742-3.818-3.742-6.48v-44.763a7.44 7.44 0 0 0-3.737-6.465l-15.642-9.01a7.28 7.28 0 0 0-3.715-1.01 7.378 7.378 0 0 0-3.742 1.01l-15.648 9.01c-2.316 1.323-3.729 3.798-3.729 6.467v44.762c0 2.663-1.413 5.1-3.738 6.48l-36.748 21.041a3.571 3.571 0 0 1-3.71 0c-1.173-.65-1.864-1.887-1.864-3.224l-.137-95.812a7.483 7.483 0 0 1 3.74-6.49l4.541-2.615c-6.982-10.302-11.16-25.79-5.925-48.942z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 990 990" id="npm" xmlns="http://www.w3.org/2000/svg"><defs><style>.hncls-1{fill:#cb3837}.cls-2{fill:#fff}</style></defs><title>n</title><path class="hncls-1" d="M113.26 876.74V113.27h763.47v763.47zm143.59-620.4v476.18h240.61V355.63h140.21v376.96h95.457V256.34z" fill="#e53935" stroke-width=".771"/></symbol><symbol id="nunjucks" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.host0{fill:#388e3c}</style><path class="host0" d="M11.2 21.1H8.1l-2.3-7.9v7.9H2.7V2.9h3.1l2.3 7.4V2.9h3.1zM21.3 19.2c0 1-.8 1.9-1.9 1.9h-4.8c-1 0-1.9-.8-1.9-1.9v-3.8l3.2-.7V18h2.3V7.2h3.1v12z"/></symbol><symbol viewBox="0 0 150 150.00001" id="ocaml" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.76136 0 0 .76136 11.616 19.98)"><path d="M83.02 101.645l.023-.062c-.035-.159-.047-.195-.024.062z" fill="none" stroke-width="1.028"/><linearGradient id="hpa" gradientUnits="userSpaceOnUse" x1="-696.735" y1="97.7" x2="-696.735" y2="142.997" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M82.313 138.79c-.471-1.004-1.904-3.621-2.624-4.46-1.562-1.828-1.927-1.966-2.386-4.275-.799-4.02-2.913-11.31-5.405-16.341-1.286-2.596-3.426-4.777-5.385-6.66-1.71-1.652-5.565-4.431-6.237-4.294-6.296 1.257-8.249 7.432-11.21 12.323-1.638 2.705-3.374 5.007-4.665 7.885-1.192 2.646-1.087 5.577-3.128 7.849-2.093 2.333-3.454 4.814-4.48 7.829-.194.574-.747 6.596-1.348 8.015l9.357-.659c8.719.594 6.2 3.936 19.81 3.208l21.487-.665c-.666-1.97-1.584-4.25-1.938-4.991-.599-1.248-1.352-3.69-1.848-4.763z" fill="url(#hpa)" stroke-width="1.028"/><linearGradient id="hpb" gradientUnits="userSpaceOnUse" x1="-666.972" y1="142.12" x2="-666.972" y2="142.12" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><linearGradient id="hpc" gradientUnits="userSpaceOnUse" x1="-675.228" y1="-1.28" x2="-675.228" y2="142.967" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M109.553 94.296c-1.652 1.193-4.88 4.06-11.902 5.145-3.152.487-6.1.527-9.335.365-1.584-.076-3.077-.157-4.665-.177-.936-.008-4.074-.107-3.919.193l-.349.871c.054.287.169 1.004.2 1.177.129.704.165 1.265.192 1.912.048 1.331-.11 2.719-.043 4.062.141 2.787 1.175 5.326 1.306 8.137.143 3.13 1.69 6.442 3.188 8.998.569.973 1.434 1.084 1.811 2.283.442 1.373.024 2.83.239 4.293.842 5.675 2.477 11.606 5.032 16.728.018.043.038.09.06.128 3.156-.53 6.318-1.665 10.418-2.271 7.517-1.115 17.972-.54 24.688-1.17 16.993-1.597 26.216 6.97 41.478 3.459V22.459c0-11.84-9.594-21.438-21.435-21.438H19.239C7.4 1.021-2.197 10.62-2.197 22.458v46.774c3.067-1.11 7.479-7.635 8.861-9.222 2.419-2.775 2.858-6.315 4.062-8.544 2.743-5.078 3.215-8.57 9.451-8.57 2.907 0 4.061.67 6.027 3.31 1.368 1.834 3.731 5.224 4.837 7.49 1.277 2.615 3.357 6.153 4.272 6.867.677.53 1.35.928 1.976 1.163 1.012.38 1.848-.316 2.525-.855.863-.687 1.235-2.088 2.035-3.957 1.152-2.696 2.408-5.926 3.122-7.054 1.237-1.949 1.658-4.261 2.993-5.381 1.97-1.652 4.54-1.768 5.246-1.908 3.957-.781 5.755 1.906 7.704 3.645 1.276 1.138 3.019 3.432 4.256 6.507.967 2.4 2.199 4.622 2.714 6.008.497 1.339 1.725 3.484 2.453 6.055.661 2.336 2.43 4.125 3.102 5.235 0 0 1.029 2.882 7.285 5.516 1.357.572 4.1 1.501 5.736 2.096 2.718.988 5.351.86 8.704.458 2.391 0 3.686-3.462 4.772-6.234.643-1.639 1.259-6.334 1.678-7.667.406-1.297-.544-2.3.265-3.437.946-1.327 1.508-1.399 2.054-3.129 1.172-3.704 7.95-3.89 11.761-3.89 3.176 0 2.772 3.083 8.16 2.028 3.086-.605 6.059.398 9.335 1.265 2.758.732 5.352 1.566 6.906 3.385 1.005 1.178 3.5 7.08.958 7.331.244.3.423.84.88 1.135-.566 2.226-3.03.64-4.4.355-1.845-.383-3.147.057-4.952.856-3.085 1.374-7.598 1.214-10.286 3.452-2.281 1.898-2.277 6.133-3.34 8.507-.002-.001-2.955 7.6-9.402 12.248z" fill="url(#hpc)" stroke-width="1.028"/><linearGradient id="hpd" gradientUnits="userSpaceOnUse" x1="-735.137" y1="90.833" x2="-735.137" y2="141.967" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M38.247 105.09c-1.467-.15-2.83-.317-4.256-.605-2.662-.536-5.57-1.06-8.193-1.688-1.592-.385-6.895-2.263-8.048-2.792-2.702-1.246-4.496-4.63-6.609-4.282-1.348.22-2.662.682-3.5 2.042-.685 1.11-.917 3.016-1.391 4.294-.55 1.485-1.5 2.87-2.331 4.284-1.53 2.595-4.282 4.941-5.468 7.469-.239.52-.45 1.101-.649 1.708V144.415a48.57 48.57 0 0 1 4.45.96c11.955 3.19 14.872 3.46 26.598 2.119l1.1-.146c.897-1.867 1.59-8.227 2.171-10.195.454-1.51 1.077-2.712 1.313-4.253.223-1.463-.02-2.858-.146-4.188-.329-3.332 2.427-4.522 3.742-7.384 1.186-2.589 1.871-5.535 2.853-8.181.941-2.54 2.41-6.13 4.918-7.408-.305-.355-5.237-.518-6.554-.65z" fill="url(#hpd)" stroke-width="1.028"/></g></symbol><symbol viewBox="0 0 24 24" id="pdf" xmlns="http://www.w3.org/2000/svg"><path d="M14 9h5.5L14 3.5V9M7 2h8l6 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m4.93 10.44c.41.9.93 1.64 1.53 2.15l.41.32c-.87.16-2.07.44-3.34.93l-.11.04.5-1.04c.45-.87.78-1.66 1.01-2.4m6.48 3.81c.18-.18.27-.41.28-.66.03-.2-.02-.39-.12-.55-.29-.47-1.04-.69-2.28-.69l-1.29.07-.87-.58c-.63-.52-1.2-1.43-1.6-2.56l.04-.14c.33-1.33.64-2.94-.02-3.6a.853.853 0 0 0-.61-.24h-.24c-.37 0-.7.39-.79.77-.37 1.33-.15 2.06.22 3.27v.01c-.25.88-.57 1.9-1.08 2.93l-.96 1.8-.89.49c-1.2.75-1.77 1.59-1.88 2.12-.04.19-.02.36.05.54l.03.05.48.31.44.11c.81 0 1.73-.95 2.97-3.07l.18-.07c1.03-.33 2.31-.56 4.03-.75 1.03.51 2.24.74 3 .74.44 0 .74-.11.91-.3m-.41-.71l.09.11c-.01.1-.04.11-.09.13h-.04l-.19.02c-.46 0-1.17-.19-1.9-.51.09-.1.13-.1.23-.1 1.4 0 1.8.25 1.9.35M8.83 17c-.65 1.19-1.24 1.85-1.69 2 .05-.38.5-1.04 1.21-1.69l.48-.31m3.02-6.91c-.23-.9-.24-1.63-.07-2.05l.07-.12.15.05c.17.24.19.56.09 1.1l-.03.16-.16.82-.05.04z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="perl" xmlns="http://www.w3.org/2000/svg"><path d="M12 14c-1 0-3 1-3 2 0 2 3 2 3 2v-1a1 1 0 0 1-1-1 1 1 0 0 1 1-1v-1m0 5s-4-.5-4-2.5c0-3 3-3.75 4-3.75V11.5c-1 0-5 1.5-5 4.5 0 4 5 4 5 4v-1M10.07 7.03l1.19.53c.43-2.44 1.58-4.06 1.58-4.06-.43 1.03-.71 1.88-.89 2.55C13.16 3.55 15.61 2 15.61 2a15.916 15.916 0 0 0-2.64 3.53c1.58-1.68 3.77-2.78 3.77-2.78-2.69 1.72-3.9 4.45-4.2 5.21l.55.08c0 .52 0 1 .25 1.38C14.1 11.31 18 11.47 18 16s-4.03 6-6.17 6C9.69 22 5 21.03 5 16s4.95-5.07 5.83-7.08c.12-.38-.76-1.89-.76-1.89z" fill="#9575cd"/></symbol><symbol viewBox="0 0 24 24" id="php" xmlns="http://www.w3.org/2000/svg"><path d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09-.28.22-.71.33-1.29.33h-.87l.53-2.76h.99m-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59l-1.28 6.58m7.25-8.33l-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18.11.12.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35h-1.44M18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76h1m-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75l-1.29 6.58z" fill="#1E88E5"/></symbol><symbol viewBox="0 0 79 78" id="postcss" xmlns="http://www.w3.org/2000/svg"><title>postcss-logo-symbol</title><g transform="translate(5.48 5.52) scale(.85425)" fill="#e53935" fill-rule="evenodd" stroke="#e53935" stroke-width="1.519"><path d="M15.447 32.623c.106.08.29.132.106.29-.132.184-.29.342-.395.553-.105.185-.184.237-.342.106.21-.343.42-.66.63-.95zM68.342 60.24c0 .078.026.13.026.21.053-.105.053-.158.08-.21zm0 .236v-.026zm-5.368 10.277l-4.58-25.402c-.078-.025-.183-.077-.368-.13.053.105.08.184.106.263.13-.026.184-.026.236-.052 0-.026 0-.052.027-.08l4.58 25.404zm-4.737-31.12c-.026.078-.026.158-.026.237 0-.08 0-.16.028-.238zm.026.526c-.026 0-.026 0-.052-.028v.026c.028.026.028.026.054 0zm-.052.21v-.185c-.077.026-.156.026-.262.053.132.05.264.078.264.13z"/><path d="M78.71 33.967c-.052-1.028-.078-2.056-.184-3.083-.184-1.397-.368-2.82-.684-4.19-.237-1.133-.63-2.214-1.026-3.294-.5-1.265-1-2.556-1.632-3.768-1.026-1.95-2.368-3.69-3.605-5.508-.818-1.16-1.87-2.108-2.66-3.294-.447-.685-1.105-1.264-1.763-1.79-1.053-.845-2.158-1.61-3.263-2.347a32.525 32.525 0 0 0-2.58-1.634c-.71-.397-1.473-.713-2.21-1.056-.842-.395-1.658-.87-2.605-1.054-.238-.05-.448-.13-.685-.21-.605-.21-1.184-.447-1.79-.632-.92-.29-1.815-.632-2.763-.87C50.342 1 49.394.843 48.446.71 47.394.555 46.316.5 45.262.397a26.83 26.83 0 0 0-2.026-.184C42.236.16 41.21.16 40.21.134c-.5-.027-1.026-.08-1.526-.053-.763.026-1.526.105-2.29.21-.736.08-1.473.21-2.183.317-.867.105-1.735.158-2.604.264-.816.106-1.658.264-2.473.396-.29.053-.58.158-.87.21-.63.132-1.288.185-1.92.396-1.13.344-2.263.74-3.368 1.16-1.027.422-2.027.87-3 1.397-1 .552-1.948 1.21-2.895 1.844a45.325 45.325 0 0 0-2.66 1.923c-.84.66-1.63 1.397-2.394 2.135-.42.42-.763.922-1.158 1.396-.657.765-1.315 1.502-1.947 2.293-.524.66-1 1.344-1.5 2.03-.893 1.21-1.656 2.502-2.366 3.794-.29.527-.553 1.054-.816 1.58-.395.79-.816 1.555-1.184 2.372-.264.554-.474 1.16-.632 1.766-.367 1.292-.736 2.61-1.078 3.9-.316 1.16-.395 2.372-.42 3.558-.027 1.054.078 2.082.183 3.136.027.264-.13.58.184.79-.105.29-.026.45.13.5-.182.29.08.476-.024.74-.027.052.08.157.13.236 0 .08-.025.185 0 .264.028.237.133.474.133.738 0 .184.157.395.21.58.026.078 0 .21-.053.263-.158.184-.132.342.105.448.133.342.08.5.054.66.052.236-.027.315 0 .368.21.422.29.896.315 1.37 0 .106.053.212.106.343.026 0 0 .5 0 .5.13-.078.237-.104.368-.157.08.342.158.66.263.95.132.21.132.314.08.34.105.474.157.922.34 1.37 0-.5-.05-1-.13-1.475.368.132.684.263.895.263.027-.08.053-.184.08-.237-.158-.157-.29-.394-.448-.552.053.21 0 .29 0 .37-.105-.054-.237-.107-.368-.16.105-.13.21-.263.368-.42 0-.238-.13-.45-.5-.423.158-.052.316-.13.5-.184.29-.157-.026-.447-.026-.816.026-.447-.237-.895-.316-1.37-.132-.737-.105-1.844-.184-2.582-.158-.132-.29.21-.316.237.08.632.158 1.264.21 1.897-.157-.527-.263-1.107-.394-1.74-.027.185-.053.264-.053.37-.13.13-.026.29.053.474-.184-.08-.395-.052-.395-.052v.738c-.262-.264-.34-.474-.473-.66-.052-.21-.08-.42-.13-.63.05-.133 0-.212 0-.29a15.968 15.968 0 0 1-.08-.634c.026-.026-.026-.42-.026-.42.21.025.343.05.474.05-.263-.34-.08-.552.027-.763.053-.106.237-.13.29-.238.21-.395.553-.71.553-1.212 0-.237.08-.5.105-.738.053-.448.105-.896.13-1.344.054-.58 0-1.16.133-1.713.212-.92.475-1.843.764-2.766.21-.66.448-1.29.71-1.95.395-1.028.764-2.056 1.264-3.03.71-1.424 1.526-2.794 2.316-4.19.5-.87 1.026-1.687 1.58-2.53.525-.817 1.05-1.66 1.657-2.425a21.452 21.452 0 0 1 2.79-2.978c1.053-.948 2.053-1.923 3.184-2.793a32.218 32.218 0 0 1 4.685-3.005c1.343-.71 2.737-1.266 4.132-1.793.895-.342 1.868-.5 2.79-.79 1.052-.343 2.105-.5 3.21-.527.71-.027 1.395-.106 2.105-.185.632-.05 1.263-.104 1.948-.183-.08.105-.106.158-.132.21-.288.422-.604.844-.894 1.265-.237.343-.5.712-.737 1.054-.422.555-.87 1.108-1.264 1.688-.605.87-1.158 1.766-1.79 2.635-.63.843-1.315 1.634-1.973 2.45-.868 1.134-1.684 2.293-2.552 3.426-.79 1.08-1.63 2.11-2.394 3.19-.684.947-1.29 1.95-1.948 2.923-.973 1.45-1.947 2.872-2.92 4.322a271.93 271.93 0 0 1-2.316 3.294c-.053.08-.132.104-.21.157-.21.342-.21.527-.29.685-.21.395-.42.79-.658 1.16-.132.21-.316.394-.474.605-.026-.316.42-.474.21-.87-.13.212-.263.396-.394.607l-.316.63c.105.08.29.133.105.29-.08.133-.158.29-.237.423a.954.954 0 0 0 .29-.264c0 .29-.158.526-.29.763-.105.21-.368.37-.552.527.026.027.21.106.237.132.237-.08.316-.21.343-.132.08-.105.158-.184.184-.263.104-.264.262-.474.525-.58.106-.053.184-.132.263-.21.79-.818 1.606-1.608 2.316-2.478 1.106-1.345 2.106-2.74 3.16-4.11.446-.58.973-1.16 1.446-1.714.078.606.026 1.185 0 1.74-.08.974-.132 1.95-.21 2.95-.027.395 0 .79-.027 1.186 0 .105-.08.184-.08.29 0 .263.08.553.08.817-.08.975-.186 1.923-.265 2.898-.027.21.078.422.13.607-.13 1.422.16 2.925-.078 4.427.184-.29.237-.474.237-.658.025-.158 0-.316 0-.5v-.264c.025-.475.13-.975.078-1.45-.053-.527-.053-1.027.053-1.528.053-.21-.026-.474.106-.738v.395c-.026 1.5.027 3.003-.183 4.505-.027.132.08.37-.21.343-.238.474.052.817-.21 1.08-.054.053.05.29.077.448-.106.317-.106.317.052.343.026.58.08 1.106.105 1.66.42-1 .21-2.03.396-3.058.026.422.053.844.026 1.29 0 .687-.026 1.345-.052 2.03 0 .132-.027.264-.053.396-.08.37-.105.738-.237 1.08-.105.264-.052.66-.052.975v1.003c.105.448-.027.685.052.948-.08.265-.105.344-.08.423l.08.395c.527-.053.29.343.5.553-.158.212-.105.29-.105.397 0 .237-.025.448-.052.685 0 .606-.026 1.212-.026 1.792 0 .08.026.157.026.236 0 .054-.026.74-.026.74.053.078 0 .157-.08.236-.025 0-.104-3.347-.104-3.347h-.395c-.052 1.58.08 3.003-.21 4.48-.316.025-.42.078-.764.078-.816 0-1.632 0-2.448.026-.974 0-1.92.026-2.895.026-.472 0-.972.054-1.446.054-.632 0-1.29-.08-1.92-.08-.975 0-1.922.08-2.896.106-.71.026-1.42.026-2.13.053-.475.025-.95.05-1.422.104-.21.026-.395.105-.658.184-.08 0-.263-.026-.42 0-.265.053-.5.21-.765.264-.395.08-.5.184-.448.58v.263c-.026.052.58-.08.58-.08-.054 0-.08.158-.16.29.212-.08.343-.132.475-.184.395.185.737.08 1.052.16 1.026.262 2.078.37 3.13.473.685.053 1.343.08 2.027.105.973.053 1.947.106 2.92.106.816 0 1.606-.08 2.42-.08 1.13 0 2.264.052 3.395.08.237 0 .5-.028.763-.028h1.92c1.712-.052 3.422-.08 5.133-.13.975-.028 1.975-.08 2.948-.107l3-.08c1.158-.026 2.316-.026 3.448-.05.868 0 1.71-.03 2.58-.055.972-.026 1.972-.105 2.946-.157.527-.027 1.054-.08 1.58-.132.632-.052 1.29-.13 1.92-.157.948-.054 1.922-.08 2.87-.133 1.184-.078 2.368-.183 3.578-.21 1.106-.052 2.237-.026 3.343-.052.974-.027 1.948-.08 2.948-.106l1.66-.08s1.104-.026 1.657-.08c.947-.052 1.894-.157 2.842-.183.604-.027 1.21 0 1.815-.027.973-.026 1.973-.08 2.947-.08.367 0 .762.054 1.236.08-.21.185-.342.29-.5.422.105.026.21.08.316.132a.71.71 0 0 1-.42.13c-.054.133-.107.186-.16.45h.474c-.184 0-.342.237-.526.395-.21-.054-.395 0-.5.29.184.104.158.183.132.29-.316.104-.553.21-.42.552-.107.052-.238.105-.37.184-.13.21-.368.263-.316.553.106.025.21.08.29.104-.132.053-.263.132-.395.184-.473.29-.262.422-.157.554-.08.053-.158.105-.237.132.052.237.13.29.157.29a9.3 9.3 0 0 0-.395.316c-.08.237-.185.342-.29.5s-.158.37-.29.527c-.552.607-.947 1.32-1.657 1.793-.264.185-.5.422-.737.66-.474.447-.895.948-1.395 1.37a29.595 29.595 0 0 1-2.052 1.554 151.56 151.56 0 0 1-2.604 1.792c-.474.315-1 .552-1.5.842s-.974.554-1.474.843c-.316.21-.606.5-.948.66-.868.37-1.79.685-2.684 1.028-.87.37-1.5.685-2.158.922-.605.21-1.237.37-1.868.5-.21.054-.448 0-.685.027-.448.08-.895.186-1.343.238-1.158.158-2.316.264-3.473.422-.685.08-1.343.21-2.027.29-.473.026-.973-.026-1.447-.026-.342 0-.71.08-1.053.027-.552-.08-1.105-.21-1.658-.316-.13-.026-.316-.08-.42-.026-.21.106-.396-.052-.607 0-.13.027-.262-.08-.394-.08-.106-.025-.238.028-.37 0-.29-.078-.552-.183-.87-.157-.313.026-.63-.132-.97-.21-.475-.106-.92-.21-1.396-.317a2.38 2.38 0 0 1-.525-.237c-.685 0-1.133-.026-1.554-.185-.368-.13-.71-.315-1.105-.262-.104.026-.183-.026-.29-.026-.08-.106-.157-.317-.235-.317-.526.027-.842-.42-1.29-.553-.236-.08-.42-.343-.657-.422-.58-.237-1.052-.737-1.71-.816-.21-.027-.42-.132-.658-.21.08.104.13.183.21.262-.763-.37-1.473-.79-2.184-1.186-.104-.026-.183-.13-.262-.184l-.71-.474c-.395.08-.553-.08-.66-.132-.71-.5-1.525-.817-2.21-1.37-.29-.238-.63-.396-.84-.686-.37-.448-.817-.764-1.317-1.027-.394-.21-.762-.448-1.13-.685-.185-.132-.37-.29-.37-.58 0-.185-.078-.37-.315-.264-.105-.158-.21-.342-.342-.395-.316-.13-.526-.37-.763-.58s-.42-.5-.71-.605c-.527-.21-.843-.658-1.158-1.027-.738-.87-1.396-1.82-2.08-2.74-.053-.08-.158-.133-.237-.212.105.29.237.527.368.79-.262-.105-.446-.29-.604-.474-.027.027 1.815 3.057 1.815 3.057.16.237.29.475.448.712a.813.813 0 0 1-.79-.422c-.236-.42-.5-.684-1.026-.63a4.588 4.588 0 0 1-.13-.58c-.107 0-.185 0-.37-.027.37.58.685 1.08 1.027 1.66-.133-.08-.21-.132-.265-.158.473.5.815 1.133 1.42 1.45.132.605.816.895.974 1.475-.13-.027-.238-.053-.37-.08-.21-.263-.447-.526-.683-.816.052.184.13.342.236.474.316.395.606.79.974 1.133.132.134.316.187.316.424.21.105.29.13.368.13.054.16-.025.397.29.344.21.395.42.395.71.264.343.343.528.37.764.16 0 .13.026.262.026.368.105-.053.08-.132.08-.264.13.105.21.158.262.21.263.37.5.712.868 1.002.5.422.948.87 1.42 1.265.922.765 1.95 1.398 2.975 1.977 1.264.712 2.475 1.476 3.764 2.16 1.552.818 3.21 1.372 4.92 1.767.632.132 1.237.263 1.87.42.55.16 1.104.397 1.657.528.842.185 1.71.343 2.552.5.183.027.37.054.58.08.235.053.524-.053.577.027.132.21.237.104.395.078.184-.053.395-.053.605-.053.737.026 1.447.184 2.184.132.16 0 .396-.133.528.13.236-.105.368-.105.473-.13.028.236 0 .236-.05.262-.054.026-.133.053-.238.132.947.184 1.842.21 2.63 0 1.37.105 2.554-.053 3.686-.448.105.132.184.316.342.053.052-.08.184-.107.29-.133.236-.053.526-.158.736-.08.238.08.317-.13.5-.13.317 0 .606-.027.896-.08.158-.026.316-.105.5-.158a1.285 1.285 0 0 0-.58-.133c.317-.158.606-.29.896-.42-.053.078-.106.183-.21.183h.367c-.08 0-.185.237-.316.395.946-.237 1.814-.448 2.657-.66-.29-.552.315-.367.526-.684-.263.08-.526.158-.79.21.895-.447 1.816-.842 2.71-1.237-.13.158-.29.237-.525.37.158.025.263.025.342.05.42.133.316-.262.447-.5.5 0 .71-.078.947-.158.263-.08.526-.158.79-.263.42-.184.815-.42 1.236-.63.08-.028.21 0 .316 0 .29-.186.394-.344.473-.318.37.053.63-.08.736-.42.184-.133.316-.238.447-.318.578-.316 1.13-.632 1.71-.948.21 0 .316 0 .368-.027.344-.16.66-.342.975-.527a2.258 2.258 0 0 1-.263-.13c.262-.054.34-.08.5-.133.63-.74 1.5-1.24 2.157-1.82.29-.026.29-.105.29-.157.104-.132.21-.29.34-.396.58-.527 1.21-.975 1.737-1.528a37.16 37.16 0 0 0 2.184-2.374c.63-.738 1.264-1.475 1.79-2.292.737-1.133 1.368-2.293 2.026-3.48.474-.842.895-1.685 1.37-2.528.05-.08.157-.185.236-.185.71-.08 1.422-.13 2.106-.21.158-.026.342-.13.5-.21-.08-.132-.132-.29-.21-.422-.106-.16-.264-.29-.37-.45-.104-.13-.183-.29-.262-.447-.08-.13-.158-.236-.237-.37a9.7 9.7 0 0 1-.45-.894c-.026-.08-.08-.21-.052-.29.474-1.027.658-2.134 1.105-3.162.447-1.054.58-2.24.79-3.373.184-1.08.29-2.16.42-3.24.08-.764.185-1.502.21-2.266.16-1.212.106-2.346.08-3.48-.026-1-.08-2.028-.13-3.03zM12.685 66.405c-.184-.21-.342-.448-.526-.658l.08-.08c.287.317.577.633.866.976-.158-.08-.342-.132-.42-.238zm.42.238c.08-.027.16-.027.238-.053.08.132.132.29.21.448-.368-.027-.552-.185-.447-.395zm27.37 10.883v-.08c.5-.052.973-.105 1.473-.157v.077c-.5.08-.973.13-1.473.158zm6.63-.685c-.367.08-.762.133-1.13.186-.132.026-.29.158-.342-.08-.053.027-.106.027-.158.054.13.394.447.078.71.236-.58.08-1.13.132-1.684.21v-.052c.16-.026.343-.053.5-.08v-.078a7.743 7.743 0 0 0-.79-.053c-.077 0-.183.106-.262.132-.105.026-.21.053-.342.053-.447.026-.894.026-1.316.052-.027 0-.08-.026-.106-.026v-.08c1.763-.236 3.5-.473 5.263-.71.027.052.027.105.053.157-.158 0-.263.055-.395.08zm.396-.262c.606-.08 1.16-.132 1.738-.21-1.21.342-1.605.394-1.737.21zM24.58 23.374c.84-1.16 1.71-2.32 2.552-3.505.263-.345.473-.714.736-1.056.08-.106.185-.158.316-.264l-.026-.05c.105-.133.21-.24.263-.344.134-.21.213-.448.318-.685a.385.385 0 0 1 .105-.103c.37.184.37-.21.5-.343.237-.264.474-.553.684-.817.158-.21.316-.395.448-.632.026-.08-.053-.21-.08-.317h-.078c.08-.052.158-.13.237-.184.026 0 .026 0 .052-.026.158-.238.316-.475.474-.686.315-.42.657-.842 1.025-1.21-.052.13-.105.263-.158.368.027 0 .027.027.053.027.316-.422.658-.817.974-1.24-.027-.025-.053-.052-.08-.052-.13.132-.236.264-.368.396-.026-.027-.052-.053-.08-.053.265-.343.528-.685.79-1.08.053.08.106.184.21.395.107-.263.212-.447.29-.632-.078.08-.183.158-.262.238l-.08-.08.474-.71c.5-.712 1-1.45 1.5-2.162.185-.263.42-.474.58-.738.5-1 1.29-1.792 1.894-2.714.132-.184.316-.342.474-.5.13-.16.237-.106.342.026.71.896 1.42 1.818 2.13 2.714.528.66 1.054 1.29 1.554 1.976.605.844 1.184 1.687 1.79 2.53.684.975 1.368 1.95 2.026 2.95 1 1.477 1.947 2.953 2.947 4.428.737 1.08 1.474 2.135 2.184 3.215h-1.344c-1.236-.025-2.5-.13-3.736-.078-1.684.08-3.394.264-5.078.396-2.132.185-4.29.21-6.42.21-.765 0-1.528.107-2.29.16-.922.052-1.817.105-2.738.13-1.08.054-2.13.08-3.21.107-.606.026-1.237 0-1.895 0zm30.183 12.12v.238c-.026 0-.052.027-.105.027-.105-.37-.21-.766-.342-1.135-.263-.765-.553-1.53-1.027-2.214-.528-.737-1-1.5-1.528-2.265-.13-.185-.316-.343-.474-.5-.553-.607-1.106-1.24-1.816-1.687a21.485 21.485 0 0 0-3.29-1.688 7.374 7.374 0 0 1-.92-.474h.63l4.5-.08c.974-.025 1.922-.025 2.895-.078.236 0 .368.08.5.29.236.395.473.79.736 1.186.027.052.08.13.08.21 0 .58 0 1.186.026 1.766.025.606.08 1.186.104 1.792 0 .606-.053 1.238-.026 1.87.027.897.053 1.82.053 2.74zM26.447 26.67c1.237-.053 2.42-.132 3.632-.185.945-.053 1.92-.08 2.866-.132.395-.025.764-.05 1.158 0-.42.212-.842.423-1.21.686-.474.316-.92.737-1.395 1.08-.475.342-.896.764-1.29 1.212-.5.605-1.053 1.132-1.58 1.712-.37.422-.79.817-1.105 1.265-.447.58-.842 1.21-1.263 1.87.132-2.504.29-4.98.184-7.51zm17.185 25.35c-.843.21-1.71.448-2.58.553-.736.106-1.5.08-2.263.08a25.42 25.42 0 0 1-2.028-.08c-.763-.078-1.526-.157-2.263-.5-.633-.29-1.29-.553-1.92-.87-.634-.316-1.265-.684-1.74-1.264-.34-.423-.815-.765-1.236-1.134.08.316.263.58.553.764-.132.158-.316.08-.58-.343-.078.053-.157.08-.21.106.08-.185.158-.37.237-.527-.105-.21-.237-.448-.342-.66-.21-.342-.42-.71-.605-1.053-.053-.08-.053-.158-.105-.237a5.893 5.893 0 0 1-.37-.475c-.21-.315-.394-.657-.657-.974 0 .08.027.158.027.264-.027 0-.053.026-.053.026l-.554-1.344c-.026 0-.026 0-.052.026l.473 1.74c-.026 0-.052.025-.08.025-.077-.104-.156-.21-.21-.34-.052-.212-.21-.212-.34-.133-.08.053-.133.237-.106.316.185.448.395.896.606 1.344.052.158.105.29.184.448.027.053.106.105.106.184.106.21.185.42.316.606.237.316.5.632.737.948.235.316.445.66.656.975.026.053.105.053.13.08.133.395.58.684.896.526.08.606.737.817 1 1.397a11.957 11.957 0 0 1-.763-.343c-.027.026-.027.052-.054.105.316.158.632.316.92.5.265.16.528.317.765.5.316.29.685.45 1.13.554a.282.282 0 0 0-.05-.107c.736.343 1.5.712 2.078 1-2.737.054-5.658.107-8.685.16 0-.5-.026-.975-.026-1.476 0-.21.052-.395.025-.606-.08-1.21-.08-2.424-.237-3.61-.157-1.264-.157-2.503-.13-3.77.025-.683-.027-1.394-.054-2.08 0-.922 0-1.82.028-2.74 0-.132.053-.237.106-.37h.08c.025.054 0 .133.05.16.08.08.212.21.265.184.157-.106.394-.21.447-.37.13-.315.184-.658.184-.974 0-.236.106-.394.21-.553.054-.08.08-.158.133-.263-.105-.08-.21-.132-.342-.237.106-.29.08-.633.475-.79.052-.027.052-.16.08-.238.025-.213.05-.45.078-.66.052.08.08.105.13.157a.42.42 0 0 1 .054-.08c0-.104-.026-.315 0-.315.316-.053.184-.395.342-.553.025-.028-.027-.107-.027-.16 0-.052 0-.13.026-.13.367-.08.315-.475.552-.66.08-.053.105-.13.21-.263.21.368-.158.553-.184.816.446-.263.578-.895.315-1.08.105-.08.21-.184.29-.29.29-.316.604-.606.868-.922.185-.236.29-.526.474-.763.106-.132.316-.237.474-.317.474-.262.92-.552 1.21-1 .053-.053.132-.105.21-.158.08-.053.238-.053.264-.132.027-.052-.052-.184-.105-.263.104-.053.21-.158.42-.264-.08.158-.105.264-.158.37l.13.13c.238-.184.606-.394.843-.552 0-.025-.132-.13-.132-.13-.157.08-.394.21-.63.316.05-.08.05-.132.08-.158.367-.237.735-.474 1.13-.66.92-.42 1.842-.842 2.763-1.237.158-.08.37-.026.553-.026.078 0 .13 0 .21-.026.42-.132.842-.264 1.263-.37.183-.052.393-.078.58-.078.787.025 1.577.025 2.366.078.342.026.658.105.974.21a9.88 9.88 0 0 1 1.184.5c.447.24.868.502 1.29.792.763.5 1.473 1.054 2.236 1.502.737.448 1.316 1.054 1.79 1.74.58.816 1.237 1.554 1.5 2.555l.394 1.74c.08.316.264.632.185 1-.133.66-.238 1.345-.343 2.004-.052.265-.105.53-.078.79.05.82-.265 1.53-.58 2.268-.106.237-.264.475-.395.738a.798.798 0 0 0 .21.106l.237-.474c.027 0 .027 0 .053.027-.132.368-.237.764-.37 1.133-.314.817-.63 1.66-1.025 2.45-.21.448-.58.817-.842 1.24-.262.368-.473.763-.736 1.106-.237.29-.473.58-.79.79-.71.527-1.447 1.054-2.21 1.476-.473.29-1.026.448-1.552.58zm-14.027-1.4l-.026.027c-.055-.026-.134-.052-.186-.105l-.632-.95c-.052-.078-.08-.157-.052-.262.29.448.58.87.895 1.29zm16.37 3.61c1.183-.5 2.157-1.21 3.05-2.028.133-.132.264-.263.422-.37 1.106-.684 1.92-1.633 2.658-2.687.842-1.212 1.395-2.582 2.08-3.873a2.73 2.73 0 0 1 .157-.29c-.053 3.004.29 5.955.684 8.933-2.973.105-6 .21-9.052.316zm26.683-.79c-.026.053-.08.106-.105.16-.027-.054-.027-.133-.053-.24-.158.423-.5.212-.737.212-1.42.027-2.868.027-4.29.027-1.368 0-2.762 0-4.13.024-.448 0-.922.105-1.37.132-1.078.052-2.157.08-3.236.105-.08 0-.158-.13-.29-.236a1.81 1.81 0 0 1-.158.237c-.028-.052-.08-.104-.133-.183-.026.08-.053.158-.08.21H58c-.053-.368-.158-.71-.158-1.08 0-.79.08-1.58.105-2.372.027-.368 0-.71 0-1.054.106.08.185.133.29.21.052-.103.105-.182.158-.26 0 0-.053-.028-.106-.08.05-.027.104-.08.104-.106.026-.08.08-.158.08-.21 0-.185-.054-.343-.08-.5.026 0 .052 0 .08-.028l.157.79h.08c-.106-.183.236-.342-.053-.552-.026-.027.026-.185.026-.264-.08-.157-.13-.315-.21-.526.026-.026.105-.053.184-.08-.105-.052-.184-.104-.263-.13.263-.238.263-.37.026-.633.054-.025.106-.025.106-.05 0-.238 0-.475-.052-.71-.053-.266.08-.58-.316-.74a.79.79 0 0 0 .105.21s-.08.027-.158.08c-.342-.317-.13-.74-.21-1.213.184.053.316.106.447.16-.053-.186-.184-.397-.263-.634h-.107v-1.74c0 .027.184.027.29.054 0-.027.025-.053.025-.08-.08-.105-.185-.21-.29-.342l.053-.053c-.21-.262-.105-.63-.105-.71V39.4c.264.264-.13.606.264.764v-.263h-.027c-.026-.395-.026-.79-.052-1.186h-.052c-.027.054-.027.08-.054.133h-.052l.158-6.298c.263.342.552.66.736 1 .606 1.108 1.395 2.057 2.132 3.058.632.87 1.21 1.818 1.79 2.714.71 1.08 1.394 2.16 2.105 3.24a81.41 81.41 0 0 0 1.63 2.426c.5.71 1.028 1.396 1.554 2.082.446.606.92 1.212 1.367 1.818.527.738 1.053 1.475 1.58 2.187.262.368.552.737.84 1.106.16.21.396.37.554.5-.025 0-.052 0-.104-.026.08.105.13.184.184.237.29.158.316.316.158.554zM74 46.854v-.185c0 .052.026.13 0 .184zm.895-11.62c-.027 0-.184-.16-.21-.186-.027.08 0 .158-.053.264-.027-.078-.21-.052-.21-.13-.027.368.157.737.13 1.106.08-.053.395-.08.474-.158.027.026.08.052.106.052-.527.396-.395.79-.158 1.24.052.104.21.315.052.526-.052.053.027.21.053.343h.077v.05l-.237.08c-.052-.08-.367-.236-.367-.37v1.346c.263.08.263.448.368.633a.768.768 0 0 0 .107-.21l.027.024c-.027.158-.053.316-.106.475-.052.236-.105.447-.13.684 0 .026.05.08.05.105-.288.66-.13 1.396-.235 2.08-.08.5 0 1.03-.053 1.556-.054.448-.16.922-.264 1.37-.027.08-.08.105-.21.158.052-.316.026-.527-.027-.817-.028 0-.37-.184-.397-.184 0 .37.21.87.29 1.29-.08-.026-.395-.21-.42-.21-.054.316-.054.738-.08 1.08-.027.264-.263.5-.29.79 0 .16.184.264.158.528h.21c0-.526.238-1 .238-1.554h.078c.027.053.106.106.08.132-.053.29-.16.606-.132.896 0 .158.13.316.08.5-.054.16-.08.317-.107.554-.027-.132-.053-.184-.053-.263-.026 0-.263-.027-.29-.027-.026.158.185.316.158.448-.026.026-.052.026-.105.053l-.868-1.266c-.686-1-1.37-2.003-2.054-3.03a6.312 6.312 0 0 1-.475-.79 37.09 37.09 0 0 0-2.71-4.033c-.762-.974-1.37-2.03-2.08-3.055-.656-.975-1.314-1.924-1.972-2.9-.237-.315-.526-.605-.737-.948-.683-1.08-1.29-2.187-1.972-3.267-.58-.897-1.21-1.767-1.816-2.636-.21-.29-.42-.607-.632-.923a.37.37 0 0 1-.052-.182c-.053-.58-.106-1.16-.132-1.713 0-.527.053-1.054.053-1.608v-.474c0-.132.025-.237.025-.37.025-.025.052-.078.078-.104-.763 0-1.553-.028-2.316 0-.5.025-.763-.186-1.105-.555-1-1.133-1.737-2.424-2.605-3.636a162.42 162.42 0 0 0-2.5-3.427c-.685-.922-1.37-1.818-2.053-2.74-.764-1.054-1.5-2.108-2.29-3.162a381.983 381.983 0 0 0-2.895-3.794c-.45-.58-.95-1.133-1.45-1.74.343.054.66.106.975.133l1.264.08c.947.077 1.894.13 2.84.26.79.107 1.58.265 2.396.396 1.738.29 3.448.765 5.106 1.318.974.316 1.92.738 2.87 1.133 2.13.87 4.157 1.924 6.157 3.03.63.343 1 .896 1.472 1.397.685.712 1.37 1.423 2.027 2.16.762.87 1.472 1.766 2.21 2.662.657.79 1.34 1.58 2 2.372.21.237.37.527.552.79.42.633.895 1.24 1.263 1.924.262.502.42 1.082.604 1.635.262.817.526 1.607.79 2.424.183.606.34 1.24.472 1.87.106.423.08.87.21 1.29.16.556 0 1.16.16 1.715.025.053.05.132.078.185.105.104.184.21.026.368-.025.026-.025.13 0 .21.054-.052.08-.105.133-.184 0 .053.025.08.025.105 0 .104-.027.21 0 .315 0 .052.052.13.078.184.053-.054.105-.08.21-.16.237.897.264 1.793.264 2.715 0 .87.157 1.74-.21 2.583.078-.29-.106-.555-.027-.818z"/><path d="M58.08 45.482c.025 0 .052.027.052.027l-.027-.03c0-.025 0-.025-.026 0zm4.157 26.036c-.29.21-.58.395-.948.474-.028-.026-.028-.053-.054-.08.29-.184.605-.368.895-.553.027.05.08.104.106.157zM12.895 35.81c.29-.367.58-.736.894-1.105.025.026.235.08.262.105-.29.37-.685.87-.974 1.265-.054-.053-.133-.237-.185-.264zM5.42 48.725c-.21-.448-.42-.923-.63-1.37a.91.91 0 0 1 .236-.106c.29.42.42.92.632 1.37 0 0-.21.105-.237.105zm6.712-12.65c-.158.238-.316.502-.474.74-.026-.028-.316.104-.342.078.158-.237.552-.66.71-.896.027.026.053.053.106.08zM59.422 72.6c.025 0 .025-.026.052-.026.184.026.394.052.605.052-.344.237-.555.21-.66-.026zm-47.24-35.418c.028-.08.08-.158.133-.237.052 0 .13-.027.13-.027.107-.184.107-.316.212-.474-.026-.026-.053-.026-.08-.053-.157.108-.315.24-.473.345.053.052.053.08.053.132-.21-.027-.29.08-.395.368-.026.08-.158.106-.29.21-.026.054-.052.186-.105.317l.027.028c-.053.053-.132.08-.132.08-.158.157-.342.29-.5.447-.026.08-.052.158-.052.237.185-.184.5-.527.737-.738l.027.027c.105-.158.184-.316.29-.474.025.026.025.052.052.08-.08.21-.158.446-.237.657-.055.026-.134.08-.134.053-.105.08-.184.184-.29.263l-.473.316c-.263.237-.526.447-.816.685-.184.29-.368.553-.58.896.317-.08.396.053.37.317.368.052.395-.237.5-.448.026-.054.053-.16.105-.186.237-.21.5-.394.763-.605.053-.053.053-.16.053-.238 0-.026-.133-.026-.212-.053.237-.264.58-.71.816-1 .132-.08.263-.186.263-.265-.026-.29.158-.368.37-.474-.106-.08-.133-.157-.133-.183z"/><path d="M12.71 36.892c-.105.184-.21.342-.315.527l-.158-.08c-.105.605-.474 1.132-.842 1.237.105.053.21.106.29.08.078-.027.13-.16.183-.238l.71-1.028.238-.396-.105-.105zM3.948 48.46c.132 0 .264.026.42.026 0-.105.133-.08.133-.184h.08c0 .132.026.237.026.37h-.552c-.027-.027-.132-.186-.106-.212zm-.21-1.212c-.08-.08-.21-.158-.21-.237-.027-.104.052-.235.13-.367.054.184.08.342.132.527-.027.025-.053.052-.053.078zm.658-1.687c.105.266.21.556.316.82a.798.798 0 0 0-.21.105c-.105-.264-.237-.554-.342-.817a.652.652 0 0 1 .237-.106zm58.58 25.194c.13-.052.288-.08.5-.13-.238.183-.422.315-.58.473-.027-.026-.053-.053-.08-.053.053-.105.106-.184.16-.29zM30.63 15.074c.157-.106.29-.185.447-.29l.052.052c-.16.21-.29.42-.475.685-.026-.183-.026-.29-.053-.42-.026 0 0 0 .027-.026zm7.71 13.333c.237-.106.474-.21.763-.343-.026.158-.026.264-.026.37a.927.927 0 0 0-.264-.054c-.158.027-.448.238-.58.264-.025 0 .106-.21.106-.237zm19.74 22.346c.052.263.552.395.052.658.08.055.157.08.236.134a.2.2 0 0 1-.052.106c-.053.025-.158.078-.21.05-.027 0-.08-.104-.08-.157 0-.237.027-.474.053-.79z"/></g></symbol><symbol viewBox="0 0 24 24" id="powerpoint" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5M8 11v2h1v6H8v1h4v-1h-1v-2h2a3 3 0 0 0 3-3 3 3 0 0 0-3-3H8m5 2a1 1 0 0 1 1 1 1 1 0 0 1-1 1h-2v-2h2z" fill="#d14524"/></symbol><symbol viewBox="0 0 67.47 70" id="powershell" xmlns="http://www.w3.org/2000/svg"><path d="M18.545 12.4c-3.014 0-6.08 2.34-6.873 5.248L1.91 53.438c-.793 2.908.996 5.248 4.01 5.248h42.887c3.014 0 6.08-2.34 6.873-5.248l9.761-35.79c.794-2.908-.993-5.248-4.007-5.248h-42.89zm4.848 6.243c.652.04 1.29.33 1.76.86l7.96 9.013-3.957 3.246 3.957-3.244 4.832 5.47c.037.042.06.088.094.131.026.034.057.06.082.096.02.028.032.057.05.086.057.087.105.176.15.267.028.06.055.117.08.178a2.546 2.546 0 0 1 .171.764c.005.073.01.146.008.219-.002.09-.01.178-.021.267a2.53 2.53 0 0 1-.036.217 2.56 2.56 0 0 1-.07.252c-.024.076-.048.15-.08.224a2.547 2.547 0 0 1-.111.22 2.503 2.503 0 0 1-.133.218 2.546 2.546 0 0 1-.147.187c-.058.07-.118.137-.185.202-.027.026-.048.057-.076.082-.037.032-.077.054-.116.084-.038.03-.07.065-.11.093L16.8 52.271a2.552 2.552 0 0 1-3.563-.626 2.553 2.553 0 0 1 .63-3.563l18.349-12.853-3.06-3.467-7.839-8.873a2.549 2.549 0 0 1 .225-3.608 2.546 2.546 0 0 1 1.85-.638zm22.441 28.214c1.377 0 2.255 1.083 1.969 2.43-.287 1.347-1.627 2.433-3.004 2.434l-9.957.006c-1.378 0-2.256-1.083-1.969-2.43.287-1.347 1.626-2.433 3.004-2.434l9.957-.006z" fill="#03a9f4" stroke-width="5.342" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 210 210" id="prettier" xmlns="http://www.w3.org/2000/svg"><title>prettier-icon-dark</title><g transform="matrix(.9 0 0 .9 10.5 10.5)" fill="none" fill-rule="evenodd"><rect fill="#56B3B4" x="165" y="40" width="20" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="200" width="60" height="10" rx="5"/><rect fill="#BF85BF" x="135" y="120" width="40" height="10" rx="5"/><rect fill="#EA5E5E" x="75" y="120" width="50" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="120" width="50" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="160" width="60" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="80" width="60" height="10" rx="5"/><rect fill="#F7BA3E" x="65" y="20" width="110" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="20" width="40" height="10" rx="5"/><rect fill="#F7BA3E" x="55" y="180" width="20" height="10" rx="5"/><rect fill="#56B3B4" x="55" y="60" width="20" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="180" width="30" height="10" rx="5"/><rect fill="#F7BA3E" x="15" y="60" width="30" height="10" rx="5"/><rect fill="#56B3B4" x="95" y="100" width="90" height="10" rx="5"/><rect fill="#F7BA3E" x="45" y="100" width="40" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="100" width="20" height="10" rx="5"/><rect fill="#BF85BF" x="105" y="40" width="50" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="40" width="80" height="10" rx="5"/><rect fill="#F7BA3E" x="45" y="140" width="100" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="140" width="20" height="10" rx="5"/><rect fill="#EA5E5E" x="135" y="60" width="60" height="10" rx="5"/><rect fill="#F7BA3E" x="135" y="80" width="60" height="10" rx="5"/><rect fill="#56B3B4" x="15" width="130" height="10" rx="5"/></g></symbol><symbol viewBox="0 0 80 80" id="protractor" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="hxa"><path transform="scale(1 -1)" fill="#564b55" stroke-width="27.224" d="M-2.983-69.251h69.412v67.108H-2.983z"/></clipPath></defs><g transform="matrix(1.13039 0 0 -1.13039 5.714 82.137)" clip-path="url(#hxa)"><g transform="scale(.1)"><path d="M1180.54 92.324c-5.53 0-9.93-1.797-13.23-5.39-3.29-3.614-5.22-8.594-5.81-14.97h36.02c0 6.583-1.47 11.622-4.4 15.126-2.93 3.496-7.12 5.234-12.58 5.234zm2.84-62.656c-10.19 0-18.22 3.086-24.11 9.297-5.88 6.21-8.83 14.824-8.83 25.84 0 11.101 2.73 19.922 8.21 26.464 5.45 6.524 12.81 9.805 22.02 9.805 8.63 0 15.46-2.851 20.48-8.523 5.03-5.676 7.55-13.157 7.55-22.461v-6.613h-47.45c.21-8.086 2.26-14.22 6.12-18.418 3.89-4.18 9.34-6.29 16.38-6.29 7.42 0 14.76 1.563 22 4.669V34.14c-3.68-1.602-7.18-2.746-10.48-3.438-3.28-.684-7.24-1.035-11.89-1.035M1272.34 30.918v44.57c0 5.606-1.28 9.805-3.82 12.559-2.56 2.773-6.56 4.16-12.02 4.16-7.2 0-12.49-1.953-15.84-5.851-3.34-3.895-5.03-10.32-5.03-19.286V30.918h-10.42v68.887h8.47l1.71-9.422h.5c2.14 3.387 5.14 6.023 8.99 7.887 3.85 1.867 8.15 2.804 12.88 2.804 8.29 0 14.54-2.011 18.73-6.015 4.19-3.985 6.28-10.391 6.28-19.192V30.918h-10.43M1328.96 38.406c7.1 0 12.27 1.938 15.48 5.813 3.22 3.879 4.81 10.129 4.81 18.758v2.199c0 9.765-1.62 16.726-4.87 20.898-3.25 4.18-8.44 6.25-15.56 6.25-6.11 0-10.79-2.383-14.04-7.129-3.26-4.746-4.88-11.472-4.88-20.136 0-8.797 1.61-15.45 4.84-19.93 3.23-4.484 7.97-6.723 14.22-6.723zm20.85 1.762h-.56c-4.83-7.004-12.02-10.5-21.62-10.5-9.01 0-16.03 3.066-21.04 9.238-5 6.153-7.5 14.922-7.5 26.27 0 11.355 2.51 20.176 7.54 26.465 5.03 6.289 12.03 9.433 21 9.433 9.34 0 16.5-3.398 21.49-10.195h.81l-.43 4.96-.25 4.845v28.039h10.43V30.918h-8.49l-1.38 9.25M1434.91 38.27c1.85 0 3.63.136 5.34.421 1.72.274 3.09.547 4.1.84v-7.976c-1.15-.559-2.81-.996-5.01-1.36-2.18-.351-4.17-.527-5.94-.527-13.32 0-19.97 7.012-19.97 21.055V91.71h-9.88v5.027l9.88 4.336 4.38 14.707h6.04V99.805h20V91.71h-20V51.16c0-4.15.98-7.333 2.96-9.56 1.97-2.206 4.67-3.331 8.1-3.331M1463.81 65.43c0-8.809 1.76-15.508 5.27-20.118 3.53-4.609 8.69-6.906 15.53-6.906s12.01 2.297 15.56 6.875c3.53 4.602 5.3 11.301 5.3 20.149 0 8.75-1.77 15.41-5.3 19.953-3.55 4.539-8.77 6.824-15.69 6.824-6.82 0-11.99-2.246-15.47-6.73-3.46-4.48-5.2-11.16-5.2-20.047zm52.47 0c0-11.23-2.83-20-8.48-26.309-5.66-6.309-13.47-9.453-23.44-9.453-6.17 0-11.64 1.445-16.42 4.336-4.78 2.89-8.46 7.031-11.06 12.45-2.59 5.401-3.88 11.73-3.88 18.976 0 11.23 2.8 19.968 8.41 26.242 5.61 6.258 13.4 9.402 23.38 9.402 9.64 0 17.3-3.222 22.97-9.62 5.69-6.415 8.52-15.087 8.52-26.024M1591.71 92.324c-5.54 0-9.94-1.797-13.23-5.39-3.3-3.614-5.24-8.594-5.81-14.97h36c0 6.583-1.46 11.622-4.39 15.126-2.93 3.496-7.13 5.234-12.57 5.234zm2.83-62.656c-10.19 0-18.22 3.086-24.11 9.297-5.89 6.21-8.83 14.824-8.83 25.84 0 11.101 2.74 19.922 8.2 26.464 5.46 6.524 12.81 9.805 22.04 9.805 8.62 0 15.45-2.851 20.48-8.523 5.03-5.676 7.54-13.157 7.54-22.461v-6.613h-47.45c.21-8.086 2.25-14.22 6.13-18.418 3.87-4.18 9.33-6.29 16.36-6.29 7.43 0 14.77 1.563 22.01 4.669V34.14c-3.69-1.602-7.17-2.746-10.46-3.438-3.3-.684-7.27-1.035-11.91-1.035M1683.5 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12.01 4.16-7.2 0-12.48-1.953-15.83-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.48l1.69-9.422h.51c2.14 3.387 5.14 6.023 8.99 7.887 3.84 1.867 8.15 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M1740.11 38.406c7.12 0 12.28 1.938 15.49 5.813 3.21 3.879 4.81 10.129 4.81 18.758v2.199c0 9.765-1.62 16.726-4.87 20.898-3.25 4.18-8.43 6.25-15.56 6.25-6.12 0-10.8-2.383-14.05-7.129-3.24-4.746-4.88-11.472-4.88-20.136 0-8.797 1.64-15.45 4.85-19.93 3.22-4.484 7.96-6.723 14.21-6.723zm20.87 1.762h-.57c-4.82-7.004-12.03-10.5-21.62-10.5-9.01 0-16.02 3.066-21.03 9.238-5 6.153-7.52 14.922-7.52 26.27 0 11.355 2.52 20.176 7.55 26.465 5.02 6.289 12.02 9.433 21 9.433 9.34 0 16.5-3.398 21.48-10.195h.83l-.44 4.96-.25 4.845v28.039h10.43V30.918h-8.49l-1.37 9.25M1846.07 38.27c1.85 0 3.64.136 5.36.421 1.7.274 3.07.547 4.08.84v-7.976c-1.13-.559-2.8-.996-5-1.36-2.2-.351-4.18-.527-5.94-.527-13.33 0-19.99 7.012-19.99 21.055V91.71h-9.86v5.027l9.86 4.336 4.4 14.707h6.04V99.805H1855V91.71h-19.98V51.16c0-4.15.98-7.333 2.95-9.56 1.97-2.206 4.68-3.331 8.1-3.331M1894.26 92.324c-5.53 0-9.94-1.797-13.22-5.39-3.31-3.614-5.25-8.594-5.83-14.97h36.01c0 6.583-1.45 11.622-4.38 15.126-2.95 3.496-7.13 5.234-12.58 5.234zm2.83-62.656c-10.19 0-18.22 3.086-24.1 9.297-5.9 6.21-8.84 14.824-8.84 25.84 0 11.101 2.73 19.922 8.2 26.464 5.47 6.524 12.81 9.805 22.03 9.805 8.63 0 15.46-2.851 20.49-8.523 5.03-5.676 7.55-13.157 7.55-22.461v-6.613h-47.46c.22-8.086 2.26-14.22 6.13-18.418 3.87-4.18 9.33-6.29 16.37-6.29 7.42 0 14.75 1.563 22 4.669V34.14c-3.7-1.602-7.17-2.746-10.47-3.438-3.28-.684-7.25-1.035-11.9-1.035M1983.36 49.727c0-6.426-2.4-11.368-7.18-14.844-4.77-3.477-11.47-5.215-20.11-5.215-9.13 0-16.26 1.445-21.37 4.336v9.687a51.32 51.32 0 0 1 10.65-3.964c3.79-.977 7.45-1.457 10.97-1.457 5.46 0 9.64.87 12.57 2.609 2.95 1.738 4.41 4.394 4.41 7.95 0 2.694-1.17 4.98-3.5 6.894-2.32 1.914-6.85 4.152-13.6 6.757-6.41 2.383-10.97 4.473-13.67 6.25-2.71 1.778-4.72 3.81-6.04 6.067-1.31 2.254-1.98 4.96-1.98 8.113 0 5.606 2.29 10.04 6.86 13.281 4.57 3.25 10.84 4.883 18.79 4.883 7.42 0 14.66-1.515 21.74-4.531l-3.71-8.496c-6.9 2.851-13.17 4.277-18.79 4.277-4.94 0-8.67-.77-11.18-2.324-2.52-1.543-3.78-3.691-3.78-6.406 0-1.844.48-3.418 1.42-4.707.95-1.309 2.46-2.54 4.56-3.711 2.09-1.184 6.11-2.871 12.07-5.086 8.16-2.98 13.69-5.98 16.55-8.996 2.87-3.02 4.32-6.809 4.32-11.367M2021.28 38.27c1.85 0 3.64.136 5.35.421 1.71.274 3.09.547 4.09.84v-7.976c-1.14-.559-2.81-.996-5.01-1.36-2.18-.351-4.18-.527-5.93-.527-13.33 0-19.99 7.012-19.99 21.055V91.71h-9.87v5.027l9.87 4.336 4.4 14.707h6.02V99.805h20V91.71h-20V51.16c0-4.15 1-7.333 2.97-9.56 1.98-2.206 4.67-3.331 8.1-3.331M2053.61 30.918h-10.42v68.887h10.42zm-11.31 87.559c0 2.39.59 4.14 1.76 5.253 1.18 1.106 2.65 1.661 4.42 1.661 1.67 0 3.1-.567 4.32-1.7 1.22-1.132 1.82-2.871 1.82-5.214 0-2.344-.6-4.09-1.82-5.247-1.22-1.16-2.65-1.726-4.32-1.726-1.77 0-3.24.566-4.42 1.726-1.17 1.157-1.76 2.903-1.76 5.247M2121.59 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12 4.16-7.21 0-12.49-1.953-15.84-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.49l1.69-9.422h.5c2.15 3.387 5.14 6.023 8.99 7.887 3.85 1.867 8.16 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M2159.29 77.742c0-4.812 1.35-8.465 4.08-10.926 2.72-2.48 6.51-3.71 11.37-3.71 10.19 0 15.28 4.953 15.28 14.831 0 10.344-5.16 15.532-15.47 15.532-4.9 0-8.67-1.32-11.31-3.965-2.63-2.649-3.95-6.555-3.95-11.762zm-5.67-58.387c0-3.73 1.58-6.55 4.72-8.488 3.14-1.922 7.65-2.879 13.52-2.879 8.75 0 15.24 1.309 19.45 3.926 4.21 2.617 6.31 6.172 6.31 10.652 0 3.723-1.15 6.32-3.45 7.754-2.31 1.457-6.65 2.168-13.01 2.168h-12.51c-4.74 0-8.43-1.12-11.06-3.386-2.65-2.266-3.97-5.508-3.97-9.747zm54.94 80.45v-6.582l-12.76-1.512c1.18-1.477 2.23-3.39 3.15-5.754.91-2.371 1.37-5.039 1.37-8.02 0-6.746-2.29-12.128-6.91-16.152-4.61-4.012-10.93-6.023-18.98-6.023-2.05 0-3.98.156-5.78.5-4.45-2.356-6.67-5.305-6.67-8.871 0-1.883.77-3.282 2.34-4.176 1.54-.902 4.21-1.36 7.97-1.36h12.2c7.46 0 13.19-1.574 17.19-4.707 4-3.144 6-7.714 6-13.71 0-7.618-3.06-13.426-9.17-17.43C2192.38 2.004 2183.46 0 2171.72 0c-9 0-15.95 1.68-20.82 5.027-4.88 3.352-7.34 8.079-7.34 14.211 0 4.18 1.35 7.813 4.03 10.88 2.68 3.046 6.45 5.116 11.32 6.21-1.77.8-3.24 2.031-4.44 3.711-1.19 1.68-1.78 3.633-1.78 5.84 0 2.52.66 4.707 2.01 6.602 1.34 1.882 3.44 3.71 6.34 5.468-3.56 1.465-6.46 3.953-8.71 7.48-2.23 3.516-3.35 7.54-3.35 12.06 0 7.55 2.26 13.37 6.79 17.452 4.52 4.082 10.93 6.133 19.22 6.133 3.6 0 6.86-.429 9.75-1.27h23.82M2284.61 91.71h-17.54V30.919h-10.43v60.793h-12.31v4.707l12.31 3.766v3.839c0 16.922 7.4 25.391 22.19 25.391 3.65 0 7.93-.73 12.82-2.195l-2.7-8.364c-4.03 1.301-7.46 1.946-10.31 1.946-3.93 0-6.85-1.309-8.73-3.926-1.89-2.617-2.84-6.816-2.84-12.598v-4.472h17.54V91.71M2302.87 65.43c0-8.809 1.76-15.508 5.28-20.118 3.52-4.609 8.7-6.906 15.52-6.906 6.84 0 12.02 2.297 15.57 6.875 3.54 4.602 5.3 11.301 5.3 20.149 0 8.75-1.76 15.41-5.3 19.953-3.55 4.539-8.78 6.824-15.69 6.824-6.83 0-11.99-2.246-15.46-6.73-3.48-4.48-5.22-11.16-5.22-20.047zm52.48 0c0-11.23-2.82-20-8.47-26.309-5.67-6.309-13.48-9.453-23.46-9.453-6.15 0-11.62 1.445-16.4 4.336-4.77 2.89-8.47 7.031-11.06 12.45-2.59 5.401-3.9 11.73-3.9 18.976 0 11.23 2.81 19.968 8.43 26.242 5.6 6.258 13.4 9.402 23.38 9.402 9.63 0 17.28-3.222 22.97-9.62 5.68-6.415 8.51-15.087 8.51-26.024M2403.79 101.074c3.07 0 5.8-.254 8.22-.761l-1.43-9.676c-2.86.633-5.37.933-7.55.933-5.58 0-10.33-2.261-14.3-6.785-3.95-4.531-5.94-10.156-5.94-16.902V30.918h-10.43v68.887h8.62l1.19-12.754h.5c2.56 4.48 5.63 7.949 9.23 10.37 3.61 2.423 7.56 3.653 11.89 3.653M2500.33 69.766l-10.68 28.476c-1.39 3.594-2.81 8.028-4.28 13.262-.93-4.024-2.24-8.438-3.96-13.262l-10.81-28.476zm14.77-38.848l-11.44 29.227h-36.83l-11.32-29.227h-10.81l36.34 92.273h8.98l36.13-92.273h-11.05M2583.07 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12 4.16-7.21 0-12.49-1.953-15.84-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.48l1.69-9.422h.51c2.14 3.387 5.14 6.023 8.99 7.887 3.84 1.867 8.15 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M2620.76 77.742c0-4.812 1.36-8.465 4.08-10.926 2.73-2.48 6.53-3.71 11.37-3.71 10.2 0 15.28 4.953 15.28 14.831 0 10.344-5.15 15.532-15.45 15.532-4.91 0-8.68-1.32-11.32-3.965-2.64-2.649-3.96-6.555-3.96-11.762zm-5.66-58.387c0-3.73 1.57-6.55 4.71-8.488 3.15-1.922 7.65-2.879 13.53-2.879 8.75 0 15.23 1.309 19.44 3.926 4.21 2.617 6.31 6.172 6.31 10.652 0 3.723-1.14 6.32-3.45 7.754-2.31 1.457-6.64 2.168-13 2.168h-12.51c-4.74 0-8.43-1.12-11.07-3.386-2.63-2.266-3.96-5.508-3.96-9.747zm54.94 80.45v-6.582l-12.76-1.512c1.18-1.477 2.22-3.39 3.14-5.754.92-2.371 1.38-5.039 1.38-8.02 0-6.746-2.3-12.128-6.92-16.152-4.61-4.012-10.92-6.023-18.97-6.023-2.05 0-3.99.156-5.78.5-4.46-2.356-6.67-5.305-6.67-8.871 0-1.883.78-3.282 2.33-4.176 1.55-.902 4.21-1.36 7.98-1.36h12.2c7.46 0 13.18-1.574 17.18-4.707 4.01-3.144 6-7.714 6-13.71 0-7.618-3.06-13.426-9.17-17.43C2653.87 2.004 2644.94 0 2633.2 0c-9 0-15.95 1.68-20.83 5.027-4.88 3.352-7.33 8.079-7.33 14.211 0 4.18 1.35 7.813 4.02 10.88 2.69 3.046 6.47 5.116 11.32 6.21-1.77.8-3.23 2.031-4.43 3.711-1.19 1.68-1.79 3.633-1.79 5.84 0 2.52.66 4.707 2.01 6.602 1.35 1.882 3.45 3.71 6.35 5.468-3.56 1.465-6.47 3.953-8.71 7.48-2.23 3.516-3.35 7.54-3.35 12.06 0 7.55 2.25 13.37 6.79 17.452 4.52 4.082 10.92 6.133 19.21 6.133 3.62 0 6.86-.429 9.75-1.27h23.83M2692.7 99.805V55.117c0-5.605 1.27-9.805 3.83-12.566 2.56-2.766 6.57-4.145 12.01-4.145 7.2 0 12.47 1.965 15.81 5.903 3.33 3.945 4.99 10.379 4.99 19.304v36.192h10.44V30.918h-8.62l-1.5 9.25h-.58c-2.13-3.41-5.1-5.988-8.88-7.793-3.8-1.809-8.13-2.707-12.99-2.707-8.37 0-14.65 1.992-18.81 5.977-4.18 3.964-6.26 10.351-6.26 19.101v45.059h10.56M2760.61 30.918h10.43v97.805h-10.43zM2810.67 38.27c6.5 0 11.6 1.789 15.31 5.343 3.71 3.575 5.56 8.555 5.56 14.961v6.23l-10.44-.448c-8.3-.286-14.27-1.583-17.94-3.868-3.66-2.273-5.5-5.82-5.5-10.644 0-3.781 1.14-6.64 3.42-8.613 2.29-1.973 5.48-2.961 9.59-2.961zm23.57-7.352l-2.07 9.805h-.51c-3.44-4.305-6.86-7.227-10.27-8.77-3.42-1.523-7.68-2.285-12.8-2.285-6.83 0-12.17 1.758-16.05 5.273-3.87 3.528-5.81 8.536-5.81 15.032 0 13.906 11.12 21.199 33.37 21.875l11.7.359v4.277c0 5.418-1.17 9.395-3.5 11.985-2.32 2.566-6.03 3.855-11.15 3.855-5.74 0-12.24-1.758-19.49-5.273l-3.21 7.988c3.4 1.836 7.11 3.281 11.16 4.324a47.81 47.81 0 0 0 12.16 1.575c8.23 0 14.3-1.817 18.27-5.461 3.96-3.66 5.93-9.5 5.93-17.54V30.919h-7.73M2893.6 101.074c3.07 0 5.8-.254 8.25-.761l-1.46-9.676c-2.84.633-5.35.933-7.54.933-5.56 0-10.33-2.261-14.3-6.785-3.96-4.531-5.93-10.156-5.93-16.902V30.918h-10.44v68.887h8.61l1.19-12.754h.5c2.57 4.48 5.65 7.949 9.25 10.37 3.6 2.423 7.56 3.653 11.87 3.653M2901.63 6.727c-3.94 0-7.04.558-9.31 1.691v9.121c2.97-.84 6.08-1.25 9.31-1.25 4.14 0 7.3 1.25 9.45 3.77 2.16 2.507 3.24 6.132 3.24 10.859v91.895h10.69V31.797c0-7.95-2.01-14.121-6.04-18.496-4.02-4.383-9.8-6.574-17.34-6.574M2999.96 55.371c0-8.086-2.93-14.394-8.8-18.918-5.87-4.52-13.83-6.785-23.88-6.785-10.9 0-19.27 1.406-25.14 4.219v10.3c3.77-1.59 7.88-2.847 12.31-3.765 4.45-.93 8.85-1.399 13.21-1.399 7.12 0 12.49 1.36 16.09 4.063 3.59 2.695 5.4 6.465 5.4 11.277 0 3.196-.63 5.805-1.91 7.832-1.29 2.024-3.42 3.907-6.42 5.625-2.99 1.711-7.56 3.664-13.67 5.84-8.55 3.059-14.66 6.692-18.32 10.871-3.66 4.2-5.51 9.668-5.51 16.407 0 7.089 2.68 12.714 7.99 16.914 5.32 4.191 12.36 6.289 21.12 6.289 9.13 0 17.54-1.68 25.2-5.032l-3.32-9.304c-7.59 3.183-14.96 4.785-22.13 4.785-5.66 0-10.07-1.223-13.26-3.652-3.19-2.43-4.78-5.809-4.78-10.118 0-3.191.59-5.8 1.76-7.832 1.17-2.031 3.14-3.886 5.95-5.597 2.78-1.688 7.04-3.563 12.79-5.625 9.63-3.426 16.26-7.118 19.89-11.063 3.62-3.937 5.43-9.043 5.43-15.332M741.648 375.406h30c28.965 0 50.227 5.039 63.774 15.117 13.531 10.079 20.32 25.821 20.32 47.247 0 19.832-6.074 34.628-18.191 44.402-12.141 9.758-31.028 14.641-56.692 14.641h-39.211zm172.192 64.246c0-36.062-11.809-63.691-35.434-82.898-23.621-19.219-57.234-28.82-100.847-28.82h-35.911V198.73h-56.445v345.329h99.438c43.14 0 75.457-8.829 96.961-26.465 21.496-17.637 32.238-43.614 32.238-77.942M1099.26 464.691c11.17 0 20.39-.789 27.63-2.371l-5.43-51.718c-7.88 1.894-16.07 2.832-24.57 2.832-22.2 0-40.19-7.246-53.97-21.731-13.78-14.48-20.66-33.301-20.66-56.453V198.73h-55.514v261.227h43.464l7.32-46.055h2.83c8.66 15.594 19.96 27.95 33.9 37.09 13.93 9.141 28.93 13.699 45 13.699M1206.88 329.82c0-60.308 22.28-90.465 66.85-90.465 44.08 0 66.13 30.157 66.13 90.465 0 59.688-22.21 89.512-66.61 89.512-23.31 0-40.2-7.707-50.67-23.144-10.47-15.43-15.7-37.54-15.7-66.368zm190.13 0c0-42.672-10.95-75.972-32.83-99.898-21.89-23.945-52.35-35.918-91.41-35.918-24.41 0-45.97 5.508-64.7 16.543-18.75 11.016-33.16 26.836-43.23 47.48-10.08 20.625-15.11 44.551-15.11 71.793 0 42.364 10.86 75.43 32.58 99.2 21.73 23.777 52.36 35.671 91.89 35.671 37.79 0 67.7-12.156 89.75-36.492 22.05-24.328 33.06-57.121 33.06-98.379M1558.11 238.887c13.54 0 27.07 2.129 40.62 6.386v-41.816c-6.13-2.676-14.05-4.922-23.73-6.738-9.69-1.797-19.73-2.715-30.12-2.715-52.59 0-78.88 27.715-78.88 83.144v140.778h-35.68v24.558l38.26 20.325 18.9 55.261h34.26v-58.113h74.39v-42.031h-74.39v-139.84c0-13.379 3.34-23.242 10.03-29.629 6.69-6.387 15.48-9.57 26.34-9.57M1783.44 464.691c11.17 0 20.38-.789 27.62-2.371l-5.43-51.718c-7.88 1.894-16.06 2.832-24.56 2.832-22.2 0-40.2-7.246-53.97-21.731-13.78-14.48-20.66-33.301-20.66-56.453V198.73h-55.52v261.227h43.46l7.34-46.055h2.82c8.66 15.594 19.95 27.95 33.9 37.09 13.92 9.141 28.93 13.699 45 13.699M1925.05 236.523c20.15 0 36.32 5.625 48.52 16.895 12.21 11.25 18.31 27.051 18.31 47.344v22.676l-33.54-1.407c-26.13-.937-45.16-5.312-57.04-13.105-11.89-7.793-17.82-19.727-17.82-35.781 0-11.661 3.45-20.665 10.39-27.051 6.91-6.387 17.32-9.571 31.18-9.571zm82.66-37.793l-11.11 36.387h-1.87c-12.62-15.918-25.29-26.738-38.04-32.48-12.74-5.742-29.13-8.633-49.13-8.633-25.67 0-45.7 6.934-60.1 20.801-14.41 13.847-21.62 33.457-21.62 58.808 0 26.934 10 47.246 30 60.934 19.99 13.691 50.45 21.172 91.41 22.441l45.09 1.414v13.938c0 16.699-3.88 29.16-11.68 37.441-7.79 8.262-19.88 12.383-36.25 12.383-13.39 0-26.23-1.953-38.5-5.891a294.638 294.638 0 0 1-35.44-13.933l-17.94 39.668c14.17 7.41 29.68 13.035 46.52 16.894 16.85 3.868 32.77 5.789 47.72 5.789 33.22 0 58.31-7.246 75.22-21.726 16.94-14.492 25.4-37.246 25.4-68.262V198.73h-39.68M2220.04 194.004c-39.52 0-69.55 11.543-90.1 34.609-20.55 23.067-30.82 56.172-30.82 99.321 0 43.925 10.74 77.707 32.23 101.339 21.5 23.614 52.56 35.418 93.18 35.418 27.56 0 52.35-5.117 74.41-15.359l-16.78-44.641c-23.46 9.133-42.82 13.704-58.1 13.704-45.19 0-67.79-29.993-67.79-89.981 0-29.293 5.63-51.305 16.89-66.031 11.26-14.707 27.76-22.09 49.48-22.09 24.72 0 48.11 6.152 70.15 18.437v-48.417c-9.92-5.84-20.5-10-31.76-12.52-11.26-2.52-24.93-3.789-40.99-3.789M2451.52 238.887c13.54 0 27.08 2.129 40.63 6.386v-41.816c-6.15-2.676-14.05-4.922-23.73-6.738-9.69-1.797-19.73-2.715-30.12-2.715-52.6 0-78.9 27.715-78.9 83.144v140.778h-35.66v24.558l38.26 20.325 18.9 55.261h34.26v-58.113h74.39v-42.031h-74.39v-139.84c0-13.379 3.34-23.242 10.03-29.629 6.69-6.387 15.47-9.57 26.33-9.57M2585.92 329.82c0-60.308 22.28-90.465 66.84-90.465 44.09 0 66.15 30.157 66.15 90.465 0 59.688-22.22 89.512-66.62 89.512-23.31 0-40.2-7.707-50.67-23.144-10.47-15.43-15.7-37.54-15.7-66.368zm190.13 0c0-42.672-10.94-75.972-32.83-99.898-21.89-23.945-52.36-35.918-91.4-35.918-24.42 0-45.98 5.508-64.72 16.543-18.74 11.016-33.14 26.836-43.22 47.48-10.07 20.625-15.12 44.551-15.12 71.793 0 42.364 10.87 75.43 32.59 99.2 21.74 23.777 52.36 35.671 91.89 35.671 37.79 0 67.7-12.156 89.75-36.492 22.04-24.328 33.06-57.121 33.06-98.379M2972.33 464.691c11.18 0 20.38-.789 27.63-2.371l-5.43-51.718c-7.87 1.894-16.05 2.832-24.57 2.832-22.2 0-40.19-7.246-53.96-21.731-13.78-14.48-20.67-33.301-20.67-56.453V198.73h-55.51v261.227h43.46l7.33-46.055h2.83c8.66 15.594 19.96 27.95 33.89 37.09 13.94 9.141 28.94 13.699 45 13.699" fill="#100f0d"/><path d="M610.11 372.83c0-170.584-138.257-308.862-308.846-308.862-170.602 0-308.846 138.278-308.846 308.863 0 170.576 138.244 308.846 308.846 308.846 170.59 0 308.846-138.27 308.846-308.846" fill="#e53935" stroke-width="1.029"/><path d="M460.694 521.792l-105.04.958-61.415 61.415-72.096-47.883 12.445-12.438-29.207.26-99.129-166.817H67.357l24.39-24.402-24.57-41.363L294.66 64.049c2.192-.04 4.399-.08 6.603-.08 170.416 0 308.585 138.055 308.846 308.408L460.694 521.792" fill="#d51c2f" stroke-width="1.029"/><path d="M149.093 350.258c0 84.048 68.13 152.151 152.171 152.151 84.028 0 152.139-68.103 152.139-152.151zm342.063-7.017v14.046h44.015c-1.75 59.337-25.556 113.104-63.54 153.419L438.75 477.81l-9.925 9.94 32.875 32.887c-40.314 37.983-94.081 61.79-153.41 63.527l-.015-44.003h-14.035v44.003c-59.34-1.737-113.096-25.556-153.41-63.527l32.887-32.887-9.945-9.92-32.883 32.875c-37.975-40.315-61.781-94.082-63.53-153.419h44.002l-.008-14.034H67.176v-51.511h468.176v51.5h-44.196" fill="#f5f5f5" stroke-width="1.029"/></g></g></symbol><symbol id="pug" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:#c1272d}.hyst1{fill:#efcca3}.st2{fill:#ed1c24}.hyst3{fill:#ccac8d}.hyst4{fill:#fff}.st5{fill:#ff931e}.st6{fill:#ffb81e}.hyst7{fill:#56332b}.hyst8{fill:#442823}.hyst9{fill:#7f4a41}.hyst10{fill:#331712}.st11{fill:#fc6}.st12{fill:#ccc}.st13{fill:#b3b3b3}.st14{fill:#989898}.st15{fill:#323232}.st16{fill:#1e1e1e}.st17{fill:#4c4c4c}.st18{fill:#e6e6e6}.st19{fill:#606060}</style><path class="hyst1" d="M107.4 50.9c-.2-4.4.4-8.3-1.6-11.6-4.8-8.2-16.8-13-40.8-13v.7h-.5.5v-.7c-24 0-36.6 4.8-41.4 13.1-1.9 3.4-1.7 7.2-2 11.6-.2 3.5-1.8 7.2-1.1 11.2.8 5.2 1.1 10.4 1.9 15.2.6 3.9 6 7.2 6.5 10.9 1.4 10.2 12 14.9 36 14.9v.8h-.6.7v-.8c24 0 34.2-4.7 35.5-14.9.5-3.8 5.5-7 6.1-10.9.8-4.8 1.1-10 1.9-15.2.7-4-.9-7.8-1.1-11.3z"/><path class="hyst3" d="M64.6 54.5c4.3.1 7.3 2.8 10.1 5.3 3.3 2.9 8.9 4.9 11.2 7.4 2.3 2.5 5.3 5 6.4 8.9 1.1 3.9 1.4 8.9 1.4 10.2 0 1.3.7 1 2.7 0 4.7-2.3 9.9-8.5 9.9-8.5-.6 3.9-5.7 7.4-6.2 11.1C98.9 99.1 89 104 64.5 104h-.1.6"/><path class="hyst3" d="M80.4 46.7c.9 3.1 4.1 13.6-2.1 10.1 0 0 2.6 1.5 4.2 7.2 1.7 5.7 5.8 6.4 5.8 6.4s6.7 1.3 11.7-3c4.2-3.6 4.9-10 3.1-14.9-1.8-4.8-5-6.3-9.7-7.3-4.7-1.1-14.1-2-13 1.5z"/><circle cx="92.3" cy="58.1" r="8.8"/><circle class="hyst4" cx="90" cy="54.2" r="2.3"/><path class="hyst1" d="M78.9 57.7s7.9 5.4 12.2 10.7c4.3 5.3 4.2 6.3 4.2 6.3l-3.1 1.4s-4.4-8.3-9.8-11.4c-5.5-3.1-6.1-5.7-6.1-5.7l2.6-1.3z"/><path class="hyst3" d="M64.9 54.5c-4.3.1-7.5 2.8-10.4 5.3-3.3 2.9-9.1 4.9-11.4 7.4-2.3 2.5-5.4 5-6.5 8.9-1.1 3.9-1.5 8.9-1.5 10.2 0 1.3.2 1.4-2.7 0-4.7-2.2-9.9-8.5-9.9-8.5.6 3.9 5.7 7.4 6.2 11.1C30.1 99.1 40 104 64.5 104h.5"/><path class="hyst7" d="M88.1 71.4C83.3 65.5 75.6 60 64.9 60h-.1c-10.7 0-18.4 5.5-23.2 11.4-5 6.1-4.6 8.5-4.6 14.3 0 21 7.4 15 12.3 17.6 5 2.5 10.2 1.7 15.5 1.7h.1c5.4 0 10.5.7 15.5-1.8 4.9-2.5 12.3 3.7 12.3-17.3.1-5.8.4-8.4-4.6-14.5z"/><path class="hyst8" d="M64.4 65.2s-.7 9.7-2.1 11.6l2.6-.6-.5-11z"/><path class="hyst8" d="M65.1 65.2s.7 9.7 2.1 11.6l-2.6-.6.5-11z"/><path class="hyst7" d="M56.7 62.9c-1-2.3 2.6-6 8.3-6.1 5.7 0 9.3 3.7 8.3 6.1-1 2.4-4.6 3.1-8.3 3.2-3.6-.1-7.3-.8-8.3-3.2z"/><path d="M65 65.2c0-.4 3.4-.5 5.2-1.7 0 0-3.7 1.2-4.5.7-.8-.4-1-1.6-1-1.6s-.3 1.2-.9 1.6c-.7.4-4.9-.7-4.9-.7s5.6 1.4 5.6 1.7c0 .3-.1 1.3-.1 2 0 2.5 0 8.7.4 9.2.6.9.4-6.7.4-9.2-.1-.8-.1-1.6-.2-2z"/><path class="hyst9" d="M65.2 78.6c1.7 0 4.7 1.2 7.4 3.1-2.6-2.9-5.7-4.9-7.4-4.9-1.8 0-5.6 2.2-8.3 5.4 2.8-2.2 6.4-3.6 8.3-3.6z"/><path class="hyst8" d="M64.5 96.3c-3.8 0-7.5-1.2-10.9-2.1-.7-.2-1.4.3-2.1.1-6.3-2-11.4-5.4-14.5-9.7v1c0 21 7.4 15.1 12.3 17.6 5 2.5 10.2 1.7 15.5 1.7h.1c5.4 0 10.5.7 15.5-1.8 4.9-2.5 12.3 3.6 12.3-17.4 0-.8 0-1.6.1-2.3-2.9 4.7-8.2 8.4-14.8 10.6-.6.2-2-.3-2.6-.2-3.6 1.2-6.8 2.5-10.9 2.5z"/><path class="hyst8" d="M55 85s-2.5 7.5-.8 10.8l-2.3-1s1.7-7.6 3.1-9.8zM74.8 85s2.5 7.5.8 10.8l2.3-1s-1.8-7.6-3.1-9.8z"/><path class="hyst3" d="M48.6 46.7c-.9 3.1-4.1 13.6 2.1 10.1 0 0-2.6 1.5-4.2 7.2s-5.8 6.4-5.8 6.4-6.7 1.3-11.7-3c-4.2-3.6-4.9-10-3.1-14.9s5-6.3 9.7-7.3c4.7-1.1 14-2 13 1.5z"/><path d="M64.9 76.8c2.7 0 11.1 5.8 11.2 12.9v-.4c0-7.4-6.8-13.3-11.2-13.3-4.4 0-11.2 6-11.2 13.3v.4c.1-7.1 8.5-12.9 11.2-12.9z"/><ellipse transform="rotate(-14.465 66.712 61.468)" class="hyst10" cx="66.7" cy="61.5" rx=".8" ry="1.5"/><ellipse transform="rotate(17.235 62.371 61.462)" class="hyst10" cx="62.4" cy="61.5" rx=".8" ry="1.5"/><circle cx="37.2" cy="58.1" r="8.8"/><circle class="hyst4" cx="39.5" cy="54.2" r="2.3"/><path class="hyst9" d="M67.5 58.2c0-.1-2.3 1-2.9 1.1-.6-.1-2.9-1.2-2.9-1.1h5.8z"/><path class="hyst1" d="M50 57.7s-7.9 5.4-12.2 10.7c-4.3 5.3-4.2 6.3-4.2 6.3l3.1 1.4s4.4-8.3 9.8-11.4 6.1-5.7 6.1-5.7L50 57.7z"/><path class="hyst3" d="M32.7 41.7S30 49.1 24 52.2c0 0 9.4-1.1 8.7-10.5zM95.8 41.7s2.7 7.4 8.7 10.5c0 0-9.4-1.1-8.7-10.5zM78.7 55.5s-5.9-6.2-13.8-6.4h.1.1c-8 .2-13.8 6.4-13.8 6.4 6.9-4.8 12.8-4.7 13.8-4.7-.1 0 6.7-.1 13.6 4.7zM71.8 42.5s-3-4.2-7-4.3h.2c-3 .1-6.9 4.3-6.9 4.3 3.4-3.3 6.9-3.2 6.9-3.2s3.3-.1 6.8 3.2zM37.2 73.2s-4.7 2.3-8.1.9H29c-3-1.7-4.5-6.8-4.5-6.8s3 9 12.7 5.9zM92 73.2s4.7 2.3 8.1.9c4-1.7 4.6-6.8 4.6-6.8s-3 9-12.7 5.9z"/><path class="hyst3" d="M42.6 41.2c2.6-.5 6.9-.6 10.3.5 4.3 1.5.8 7 1.7 7.3.9.3 2.1-3.8 10.1-3.4 8.1.4 9 4 10.1 3.4s-1.1-10 11-7.8c0 0-12.7-3.4-12.1 5.8 0 0-7.3-5.6-17.5-.6.1 0 2.7-8.6-13.6-5.2zM86.9 41.2c.2 0 .3.1.4.1.1 0-.1-.1-.4-.1zM86.9 41.2zM39.1 28.9S28.3 42.5 26.7 47.7c-1.6 5.3-2.8 27-4.2 30.1l-5-21.4 9.2-22.3 12.4-5.2zM89.9 28.9s10.8 13.6 12.4 18.8c1.6 5.3 2.8 27 4.2 30.1l5-21.4-9.2-22.3-12.4-5.2z"/><path class="hyst7" d="M89.4 28.9s11.6 9.7 15 20.9c3.4 11.2 2 24.8 4.6 26.5 3.7 2.4 7.9-11.9 9.3-13.4 2.2-2.4 9.5-8.5 10-9.6.5-1.1-14.8-17.8-21.5-21.1-8.1-3.8-18.1-4.1-17.4-3.3z"/><path class="hyst8" d="M99.3 34.9s13.7 17.5 13.5 39.3l5.5-11.2c-.1 0-4.9-14.3-19-28.1z"/><path class="hyst7" d="M39.1 28.9s-11.6 9.7-15 20.9-2 24.8-4.6 26.5c-3.7 2.4-7.9-11.9-9.3-13.4C8 60.5.7 54.4.2 53.3-.3 52.2 15 35.5 21.7 32.2c8.1-3.8 18.1-4.1 17.4-3.3z"/><path class="hyst8" d="M29.2 34.9S15.5 52.4 15.7 74.2L10.3 63s4.8-14.3 18.9-28.1z"/><path class="hyst3" d="M21.8 74.6s1 5.4 2.6 7.1.5-1.3.5-1.3-1.7-.9-1.4-7.8-1.7 2-1.7 2zM107.1 74.6s-1 5.4-2.6 7.1-.5-1.3-.5-1.3 1.7-.9 1.4-7.8 1.7 2 1.7 2z"/><g><circle class="hyst8" cx="54.5" cy="70.5" r=".8"/><circle class="hyst8" cx="49.9" cy="75.3" r=".8"/><circle class="hyst8" cx="48.4" cy="70.5" r=".8"/></g><g><circle class="hyst8" cx="74" cy="70.5" r=".8"/><circle class="hyst8" cx="78.6" cy="75.3" r=".8"/><circle class="hyst8" cx="80.1" cy="70.5" r=".8"/></g></symbol><symbol viewBox="0 0 50 50" id="puppet" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -247)" fill="#fbc02d"><path stroke-width=".283" d="M11.559 249.467h13.587v13.587H11.559zM27.435 265.056h13.587v13.587H27.435zM11.559 281.074h13.587v13.587H11.559z"/><path stroke-width=".256" d="M16.62 251.615l18.305 18.305-3.236 3.236-18.305-18.305z"/><path stroke-width=".256" d="M37.834 271.331L19.53 289.636l-3.237-3.237 18.305-18.304z"/></g></symbol><symbol viewBox="0 0 100 99.999997" id="purescript" xmlns="http://www.w3.org/2000/svg"><path clip-path="url(#SVGID_2_)" d="M98.079 38.548L79.22 19.68l-5.087 5.088L90.447 41.09 74.134 57.41l5.087 5.087 18.858-18.86a3.59 3.59 0 0 0 1.055-2.55 3.578 3.578 0 0 0-1.055-2.54M25.483 42.794l-5.09-5.089L1.53 56.568a3.566 3.566 0 0 0-1.05 2.545c0 .961.373 1.863 1.05 2.542L20.394 80.52l5.089-5.086L9.162 59.113z" fill="#42a5f5" stroke-width="1.192"/><path clip-path="url(#SVGID_2_)" transform="matrix(1.19175 0 0 1.19175 -306.84 -629.047)" fill="#42a5f5" d="M281.841 551.736l6.461 6.037h28.379l-6.461-6.037zM288.302 566.861l-6.463 6.035h28.381l6.463-6.035zM281.838 581.982l6.464 6.035h28.381l-6.463-6.035z"/></symbol><symbol viewBox="0 0 24 24" id="python" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 7.5A2.86 2.86 0 0 1 22 10.36v3.78A2.86 2.86 0 0 1 19.14 17H12c0 .39.32.96.71.96H17v1.68a2.86 2.86 0 0 1-2.86 2.86H9.86A2.86 2.86 0 0 1 7 19.64v-3.75a2.85 2.85 0 0 1 2.86-2.85h5.25a2.85 2.85 0 0 0 2.85-2.86V7.5h1.18m-4.28 11.79c-.4 0-.72.3-.72.89 0 .59.32.71.72.71a.71.71 0 0 0 .71-.71c0-.59-.32-.89-.71-.89m-10-1.79A2.86 2.86 0 0 1 2 14.64v-3.78A2.86 2.86 0 0 1 4.86 8H12c0-.39-.32-.96-.71-.96H7V5.36A2.86 2.86 0 0 1 9.86 2.5h4.28A2.86 2.86 0 0 1 17 5.36v3.75a2.85 2.85 0 0 1-2.86 2.85H8.89a2.85 2.85 0 0 0-2.85 2.86v2.68H4.86M9.14 5.71c.4 0 .72-.3.72-.89 0-.59-.32-.71-.72-.71-.39 0-.71.12-.71.71s.32.89.71.89z"/><path d="M9.264 22.379c-.895-.24-1.581-.799-1.947-1.582-.228-.489-.237-.606-.238-2.957-.001-2.745.057-3.074.666-3.785.193-.226.568-.517.833-.648.47-.23.579-.239 3.839-.288 3.131-.048 3.386-.065 3.814-.264.626-.291 1.07-.687 1.4-1.247.27-.46.278-.522.311-2.29l.034-1.82.932.051c1.075.058 1.504.211 2.098.748.853.77.869.841.869 3.957 0 2.434-.02 2.783-.18 3.075a3.365 3.365 0 0 1-1.337 1.33l-.517.273-3.95.031-3.951.031.068.274c.037.151.164.377.282.503.209.224.262.229 2.433.229h2.22v1.05c0 1.653-.394 2.437-1.54 3.072l-.545.302-2.644.018c-1.455.01-2.782-.018-2.95-.063zm6.12-1.692c.22-.222.253-.325.206-.675-.07-.523-.278-.73-.732-.73-.467 0-.672.217-.735.78-.042.372-.012.496.163.672.3.3.77.28 1.097-.047z" fill="#fc0" stroke="#fc0" stroke-width=".102"/><path d="M9.349 22.38c-.911-.15-1.936-1.074-2.176-1.963-.073-.273-.101-1.279-.079-2.868.033-2.317.047-2.473.27-2.926.13-.263.401-.623.603-.8.674-.592.87-.63 3.484-.675 4.399-.076 4.927-.166 5.705-.967.642-.662.706-.9.774-2.883l.061-1.784.951.055c.523.031 1.11.122 1.304.204.54.225 1.358 1.042 1.472 1.47.153.572.243 3.18.16 4.617-.071 1.23-.093 1.327-.395 1.78-.193.288-.577.647-.966.903l-.647.425-3.922.008c-2.157.004-3.942.028-3.966.052-.115.115.354.82.587.883.14.038 1.181.073 2.314.079l2.06.01v.91c0 1.739-.326 2.446-1.454 3.162l-.631.4-2.543-.011c-1.398-.007-2.733-.043-2.966-.081zm5.98-1.718c.285-.256.313-.328.251-.658-.09-.483-.301-.682-.722-.682-.436 0-.625.193-.715.73-.065.384-.044.453.2.663.358.308.595.295.985-.053z" fill="#fdd835" stroke-width=".102"/><path d="M4.281 17.396c-.88-.215-1.714-.935-2.024-1.747-.149-.389-.168-.804-.142-3.041.027-2.26.054-2.638.215-2.962.259-.519.851-1.092 1.392-1.346.437-.206.632-.217 4.408-.245l3.95-.03-.067-.275a1.367 1.367 0 0 0-.282-.504c-.21-.224-.263-.23-2.433-.23h-2.22l.002-1.143c.003-1.338.157-1.795.84-2.493.746-.763 1.103-.838 4.025-.838 2.961 0 3.28.06 4.067.768.37.333.572.621.728 1.037.201.539.213.735.183 3.072-.035 2.777-.045 2.824-.78 3.598-.787.829-.76.824-4.59.883-3.812.06-3.797.057-4.61.806-.765.706-.917 1.2-.964 3.133l-.04 1.653-.677-.01c-.371-.007-.813-.045-.98-.086zM9.59 5.551c.237-.204.286-.326.286-.72 0-.547-.201-.763-.71-.763-.502 0-.765.248-.765.724 0 .492.141.782.439.902.345.14.444.12.75-.143z" fill="#3c78aa"/></symbol><symbol viewBox="0 0 24 24" id="r" xmlns="http://www.w3.org/2000/svg"><path d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1.081 1.081 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z" fill="#1976d2" stroke-width="1.035"/></symbol><symbol viewBox="0 0 24 24" id="raml" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="razor" xmlns="http://www.w3.org/2000/svg"><path d="M15.45 11.91c-.11-2.21-1.75-3.54-3.73-3.54h-.08c-2.29 0-3.55 1.8-3.55 3.84 0 2.29 1.53 3.74 3.54 3.74 2.25 0 3.72-1.65 3.83-3.59m-3.81-5.97c1.53 0 2.97.68 4.02 1.74 0-.51.33-.89.83-.89h.11c.74 0 .89.7.89.92v7.9c-.04.52.54.78.87.44 1.27-1.29 2.78-6.69-.79-9.81-3.33-2.92-7.8-2.44-10.18-.8-2.52 1.74-4.14 5.61-2.57 9.22 1.71 3.95 6.61 5.13 9.52 3.95 1.48-.59 2.15 1.4.65 2.05-2.34.99-8.77.89-11.78-4.32-2.03-3.52-1.93-9.71 3.46-12.92C10.81 1.42 16.24 2.1 19.5 5.5c3.45 3.6 3.25 10.3-.1 12.91-1.51 1.18-3.76.03-3.74-1.7l-.02-.56a5.611 5.611 0 0 1-3.99 1.66C8.63 17.81 6 15.15 6 12.13c0-3.05 2.63-5.74 5.65-5.74z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="react" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.11c1.03 0 1.87.84 1.87 1.89 0 1-.84 1.85-1.87 1.85-1.03 0-1.87-.85-1.87-1.85 0-1.05.84-1.89 1.87-1.89M7.37 20c.63.38 2.01-.2 3.6-1.7-.52-.59-1.03-1.23-1.51-1.9a22.7 22.7 0 0 1-2.4-.36c-.51 2.14-.32 3.61.31 3.96m.71-5.74l-.29-.51c-.11.29-.22.58-.29.86.27.06.57.11.88.16l-.3-.51m6.54-.76l.81-1.5-.81-1.5c-.3-.53-.62-1-.91-1.47C13.17 9 12.6 9 12 9c-.6 0-1.17 0-1.71.03-.29.47-.61.94-.91 1.47L8.57 12l.81 1.5c.3.53.62 1 .91 1.47.54.03 1.11.03 1.71.03.6 0 1.17 0 1.71-.03.29-.47.61-.94.91-1.47M12 6.78c-.19.22-.39.45-.59.72h1.18c-.2-.27-.4-.5-.59-.72m0 10.44c.19-.22.39-.45.59-.72h-1.18c.2.27.4.5.59.72M16.62 4c-.62-.38-2 .2-3.59 1.7.52.59 1.03 1.23 1.51 1.9.82.08 1.63.2 2.4.36.51-2.14.32-3.61-.32-3.96m-.7 5.74l.29.51c.11-.29.22-.58.29-.86-.27-.06-.57-.11-.88-.16l.3.51m1.45-7.05c1.47.84 1.63 3.05 1.01 5.63 2.54.75 4.37 1.99 4.37 3.68 0 1.69-1.83 2.93-4.37 3.68.62 2.58.46 4.79-1.01 5.63-1.46.84-3.45-.12-5.37-1.95-1.92 1.83-3.91 2.79-5.38 1.95-1.46-.84-1.62-3.05-1-5.63-2.54-.75-4.37-1.99-4.37-3.68 0-1.69 1.83-2.93 4.37-3.68-.62-2.58-.46-4.79 1-5.63 1.47-.84 3.46.12 5.38 1.95 1.92-1.83 3.91-2.79 5.37-1.95M17.08 12c.34.75.64 1.5.89 2.26 2.1-.63 3.28-1.53 3.28-2.26 0-.73-1.18-1.63-3.28-2.26-.25.76-.55 1.51-.89 2.26M6.92 12c-.34-.75-.64-1.5-.89-2.26-2.1.63-3.28 1.53-3.28 2.26 0 .73 1.18 1.63 3.28 2.26.25-.76.55-1.51.89-2.26m9 2.26l-.3.51c.31-.05.61-.1.88-.16-.07-.28-.18-.57-.29-.86l-.29.51m-2.89 4.04c1.59 1.5 2.97 2.08 3.59 1.7.64-.35.83-1.82.32-3.96-.77.16-1.58.28-2.4.36-.48.67-.99 1.31-1.51 1.9M8.08 9.74l.3-.51c-.31.05-.61.1-.88.16.07.28.18.57.29.86l.29-.51m2.89-4.04C9.38 4.2 8 3.62 7.37 4c-.63.35-.82 1.82-.31 3.96a22.7 22.7 0 0 1 2.4-.36c.48-.67.99-1.31 1.51-1.9z" fill="#00bcd4"/></symbol><symbol viewBox="0 0 24 24" id="readme" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="reason" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18V3H3zm5.119 8.993h2.798c.382 0 .71.025.985.075.275.05.534.159.774.326.244.168.435.386.577.654.145.265.218.598.218 1 0 .552-.112 1.001-.335 1.35-.22.348-.536.638-.947.87l2.16 3.203H12.31l-1.763-2.742h-.77v2.742H8.12v-7.478zm6.594 0h4.676v1.447h-3.018v1.29h2.802v1.447h-2.802v1.848h3.018v1.446h-4.676v-7.478zM9.778 13.37v2.014h.513c.266 0 .49-.014.67-.044.18-.03.329-.1.45-.207a.96.96 0 0 0 .253-.34c.055-.128.082-.297.082-.508 0-.187-.034-.35-.1-.483a.698.698 0 0 0-.343-.317 1.086 1.086 0 0 0-.395-.095 6.012 6.012 0 0 0-.526-.02h-.604z" fill="#f44336" stroke-width="1.067"/></symbol><symbol viewBox="0 0 172 193" id="restql" xmlns="http://www.w3.org/2000/svg"><title>Group</title><g transform="translate(14.767 16.713) scale(.82795)" fill="none"><path d="M171.39 55.799c-.975-6.147-4.673-11.642-10.15-14.805L96.381 3.546C93.217 1.72 89.615.756 85.964.756s-7.253.964-10.415 2.788L10.69 40.992A20.896 20.896 0 0 0 .272 59.035v74.89a20.894 20.894 0 0 0 10.416 18.042l64.859 37.446c3.165 1.827 6.767 2.791 10.417 2.791s7.252-.964 10.415-2.79l64.859-37.445c5.479-3.166 9.178-8.66 10.152-14.808zm-16.516 85.147L90.017 178.39a8.104 8.104 0 0 1-8.108 0l-64.857-37.444a8.109 8.109 0 0 1-4.053-7.021v-74.89a8.109 8.109 0 0 1 4.053-7.021l64.857-37.446c1.254-.725 2.654-1.086 4.054-1.086s2.8.361 4.054 1.086l64.857 37.446a8.106 8.106 0 0 1 4.053 7.021v74.89a8.109 8.109 0 0 1-4.053 7.021z" fill="#83e8c2"/><path d="M158.93 59.035a8.109 8.109 0 0 0-4.053-7.021L90.02 14.568c-1.254-.725-2.654-1.086-4.054-1.086s-2.8.361-4.054 1.086L17.055 52.014a8.106 8.106 0 0 0-4.053 7.021v74.89a8.109 8.109 0 0 0 4.053 7.021l64.857 37.444a8.104 8.104 0 0 0 8.108 0l64.857-37.444a8.109 8.109 0 0 0 4.053-7.021zm-46.766 31.681c.119-.069.242-.118.365-.149.044-.012.088-.01.131-.018.076-.012.152-.029.228-.029l.015.001c.02.001.038.005.059.006.093.005.184.019.273.04l.1.03c.077.025.15.057.223.095.028.014.057.027.084.043.094.057.184.122.263.199.007.008.013.017.021.024.07.071.133.15.188.235.018.029.033.059.05.09.04.072.072.148.099.229a1.512 1.512 0 0 1 .081.46v16.209l-3.278 1.893a1.548 1.548 0 0 0-.678.83 1.533 1.533 0 0 0-.098.514v3.785l-14.038 8.104-.01.004a1.55 1.55 0 0 1-.354.146c-.045.012-.09.011-.135.018-.074.012-.15.029-.225.029l-.014-.001c-.02-.001-.039-.005-.059-.006a1.463 1.463 0 0 1-.273-.041c-.034-.008-.066-.019-.1-.03a1.318 1.318 0 0 1-.223-.094c-.029-.015-.057-.027-.084-.044a1.45 1.45 0 0 1-.263-.198c-.009-.008-.015-.019-.023-.027a1.495 1.495 0 0 1-.185-.232c-.019-.029-.034-.06-.051-.09a1.422 1.422 0 0 1-.098-.229 1.702 1.702 0 0 1-.033-.101 1.487 1.487 0 0 1-.048-.358l-.001-.002v-20.053a1.446 1.446 0 0 1 .727-1.255zM85.24 31.369a1.449 1.449 0 0 1 1.452 0l45.741 26.41a1.45 1.45 0 0 1 0 2.512l-17.366 10.027a1.457 1.457 0 0 1-1.452 0l-15.49-8.943 1.727-.996a1.552 1.552 0 0 0 0-2.688l-13.111-7.57c-.239-.139-.508-.207-.775-.207s-.535.068-.775.207l-3.278 1.893-14.038-8.104a1.451 1.451 0 0 1 0-2.513zM57.59 47.558c.251 0 .501.065.726.194l15.489 8.942-1.727.997a1.552 1.552 0 0 0 0 2.688l1.727.996-15.488 8.943a1.457 1.457 0 0 1-1.452 0L39.499 60.291a1.45 1.45 0 0 1 0-2.512l17.366-10.027c.225-.129.475-.194.725-.194zm-9.56 92.328c-.241 0-.489-.062-.724-.196l-17.365-10.026a1.45 1.45 0 0 1-.726-1.256V75.59c0-.847.694-1.453 1.452-1.453.242 0 .49.062.724.197l17.366 10.025c.449.26.726.738.726 1.257v17.886l-1.727-.997a1.552 1.552 0 0 0-2.327 1.344v15.139c0 .555.295 1.067.775 1.344l3.278 1.894v16.209a1.45 1.45 0 0 1-1.452 1.451zm29.828 14.929a1.452 1.452 0 0 1-2.177 1.257l-17.365-10.026a1.452 1.452 0 0 1-.726-1.257v-17.885l1.726.996c.25.145.515.211.773.211.811 0 1.554-.648 1.554-1.555v-1.993l15.489 8.942c.449.26.726.738.726 1.257zm0-32.768c0 .127-.02.246-.049.36-.009.035-.021.067-.032.101-.026.08-.059.157-.099.229-.017.03-.032.061-.05.09a1.48 1.48 0 0 1-.188.235l-.021.025a1.51 1.51 0 0 1-.264.199c-.026.016-.055.028-.082.043a1.597 1.597 0 0 1-.324.124 1.362 1.362 0 0 1-.278.041c-.018.001-.036.006-.055.006l-.015.001c-.077 0-.155-.018-.233-.03-.043-.007-.084-.005-.125-.017a1.484 1.484 0 0 1-.366-.149l-14.035-8.104v-3.784a1.545 1.545 0 0 0-.776-1.343l-3.276-1.892V91.976c0-.127.02-.246.049-.361.009-.034.021-.066.032-.1a1.33 1.33 0 0 1 .099-.229c.017-.03.032-.062.051-.091.054-.084.116-.163.187-.234l.021-.025c.079-.076.168-.142.263-.199.027-.016.056-.029.084-.043a1.476 1.476 0 0 1 .601-.166c.019 0 .036-.005.055-.005l.015-.001c.078 0 .157.018.236.03.04.007.081.005.122.017.124.031.246.08.366.149l17.361 10.023a1.456 1.456 0 0 1 .726 1.259zm-9.984-45.373a1.448 1.448 0 0 1-.544-.55 1.466 1.466 0 0 1 0-1.413c.121-.219.303-.41.544-.55l14.038-8.104 3.277 1.892c.48.276 1.071.276 1.551 0l3.278-1.893 14.038 8.105a1.45 1.45 0 0 1 0 2.513L86.691 86.7a1.447 1.447 0 0 1-1.452 0zm74.842 51.733c0 .518-.276.997-.726 1.256l-45.741 26.409a1.452 1.452 0 0 1-2.177-1.257v-20.053c0-.519.277-.997.727-1.257l15.488-8.941v1.992c0 .906.743 1.555 1.553 1.555.26 0 .523-.066.774-.21l13.11-7.57a1.55 1.55 0 0 0 .776-1.344v-3.784l14.038-8.105a1.452 1.452 0 0 1 2.177 1.257v20.052zm0-32.764c0 .519-.276.997-.726 1.256l-15.489 8.943v-1.993c0-.906-.744-1.554-1.554-1.554a1.519 1.519 0 0 0-.773.21l-1.727.996V85.616c0-.519.277-.997.727-1.257l17.365-10.025c.234-.135.482-.197.724-.197.758 0 1.453.606 1.453 1.453z" fill="#111d5a"/><g fill="#83e8c2"><path d="M59.402 90.568zM94.485 123.06zM94.771 123.29zM77.775 122.51zM77.072 123.33zM77.418 123.09zM77.856 122.05zM76.749 123.45zM94.119 122.41zM77.131 133.51l-15.489-8.942v1.993c0 .906-.743 1.555-1.554 1.555a1.53 1.53 0 0 1-.773-.211l-1.726-.996v17.885c0 .519.276.997.726 1.257l17.365 10.026a1.452 1.452 0 0 0 2.177-1.257v-20.053a1.454 1.454 0 0 0-.726-1.257zM94.25 122.74zM110.28 111.42zM94.494 100.98c.088-.089.189-.168.303-.232l17.365-10.026-17.365 10.026a1.392 1.392 0 0 0-.303.232zM77.627 122.83zM58.027 90.936zM58.374 90.693zM59.044 90.521l-.015.001c.083-.001.167.015.251.029-.079-.012-.158-.03-.236-.03zM57.819 91.195zM58.696 90.568zM57.589 91.977zM76.043 123.46zM57.67 91.516zM75.677 123.31l-14.035-8.11zM76.401 123.5l.015-.001c-.082.001-.166-.016-.248-.029.078.012.156.03.233.03zM112.16 90.716zM77.662 101.27zM113.64 90.734zM96.237 123.31zM113.33 90.597zM112.89 90.52c-.075 0-.151.018-.228.029.081-.014.162-.029.242-.028l-.014-.001zM141.26 74.137c-.241 0-.489.062-.724.197l-17.365 10.025c-.449.26-.727.738-.727 1.257v17.885l1.727-.996c.25-.145.515-.211.773-.21.81 0 1.554.647 1.554 1.554v1.993l15.489-8.943a1.45 1.45 0 0 0 .726-1.256V75.59c0-.847-.695-1.453-1.453-1.453zM112.96 90.526zM95.523 123.5c.074 0 .15-.018.225-.029-.08.013-.159.028-.238.028l.013.001zM95.451 123.5zM85.238 86.7zM95.078 123.43zM141.26 106.9c-.241 0-.489.062-.724.196l-14.038 8.105v3.784c0 .555-.296 1.067-.776 1.344l-13.11 7.57c-.251.144-.515.21-.774.21-.81 0-1.553-.648-1.553-1.555v-1.992l-15.488 8.941c-.449.26-.727.738-.727 1.257v20.053a1.452 1.452 0 0 0 2.177 1.257l45.741-26.409a1.45 1.45 0 0 0 .726-1.256v-20.053a1.454 1.454 0 0 0-1.454-1.452zM67.871 41.396a1.451 1.451 0 0 0 0 2.513l14.038 8.104 3.278-1.893c.24-.139.508-.207.775-.207s.536.068.775.207l13.111 7.57a1.552 1.552 0 0 1 0 2.688l-1.727.996 15.49 8.943a1.457 1.457 0 0 0 1.452 0l17.366-10.027a1.45 1.45 0 0 0 0-2.512l-45.741-26.41a1.449 1.449 0 0 0-1.452 0zM39.497 57.779a1.45 1.45 0 0 0 0 2.512l17.366 10.027a1.457 1.457 0 0 0 1.452 0l15.488-8.943-1.727-.996a1.552 1.552 0 0 1 0-2.688l1.727-.997-15.489-8.942a1.458 1.458 0 0 0-1.451 0zM49.481 138.43v-16.209l-3.278-1.894a1.55 1.55 0 0 1-.775-1.344v-15.139c0-.906.743-1.555 1.554-1.554.259 0 .523.065.773.21l1.727.997V85.611a1.45 1.45 0 0 0-.726-1.257L31.39 74.33a1.436 1.436 0 0 0-.724-.197c-.758 0-1.452.606-1.452 1.453v52.817c0 .518.276.997.726 1.256l17.365 10.026a1.45 1.45 0 0 0 2.176-1.255zM114.34 108.18l-3.278 1.893 3.278-1.893V91.971zM114.11 91.193zM114.16 91.283z"/></g><g fill="#de5941"><path d="M94.494 100.98a1.45 1.45 0 0 0-.424 1.023v20.053l.001.002c0 .126.02.244.048.358.01.034.021.066.033.101.026.08.059.156.098.229.017.03.032.061.051.09.055.084.115.162.185.232.009.009.015.02.023.027.079.077.169.142.263.198.027.017.055.029.084.044a1.46 1.46 0 0 0 .596.165c.02.001.039.005.059.006.079 0 .158-.016.238-.028.045-.007.09-.006.135-.018.119-.031.238-.08.354-.146l.01-.004 14.038-8.104v-3.785c0-.18.04-.35.098-.514.122-.343.353-.643.678-.83l3.278-1.893V91.977c0-.127-.021-.246-.049-.361-.009-.033-.021-.065-.032-.099a1.266 1.266 0 0 0-.099-.229c-.017-.031-.032-.061-.05-.09a1.425 1.425 0 0 0-.188-.235l-.021-.024a1.41 1.41 0 0 0-.263-.199c-.027-.016-.056-.029-.084-.043a1.509 1.509 0 0 0-.323-.125 1.591 1.591 0 0 0-.273-.04c-.021-.001-.039-.005-.059-.006-.08-.001-.161.015-.242.028-.043.008-.087.006-.131.018-.123.031-.246.08-.365.149l-17.365 10.026a1.447 1.447 0 0 0-.302.233zM77.13 100.74L59.769 90.717a1.424 1.424 0 0 0-.366-.149c-.041-.012-.082-.01-.122-.017-.084-.015-.168-.03-.251-.029-.019 0-.036.005-.055.005-.095.005-.188.02-.278.041-.034.009-.065.02-.099.03a1.406 1.406 0 0 0-.224.095c-.028.014-.057.027-.084.043a1.515 1.515 0 0 0-.263.199l-.021.025c-.07.071-.133.15-.187.234-.019.029-.034.061-.051.091-.04.073-.072.149-.099.229a1.463 1.463 0 0 0-.081.461v16.206l3.276 1.892a1.547 1.547 0 0 1 .776 1.343v3.784l14.035 8.104c.119.068.242.117.366.149.041.012.082.01.125.017.082.014.166.03.248.029.019 0 .037-.005.055-.006.095-.004.188-.019.278-.041.034-.008.065-.019.099-.029.077-.025.152-.058.225-.095.027-.015.056-.027.082-.043.095-.058.185-.123.264-.199l.021-.025c.07-.071.133-.15.188-.235.018-.029.033-.06.05-.09.04-.072.072-.149.099-.229a1.448 1.448 0 0 0 .081-.461v-20.047a1.456 1.456 0 0 0-.726-1.259zM86.689 86.7l17.365-10.026a1.45 1.45 0 0 0 0-2.513l-14.038-8.105-3.278 1.893a1.556 1.556 0 0 1-1.551 0l-3.277-1.892-14.038 8.104c-.241.14-.423.331-.544.55a1.466 1.466 0 0 0 0 1.413c.121.218.303.41.544.55L85.238 86.7a1.447 1.447 0 0 0 1.451 0z"/></g></g></symbol><symbol viewBox="0 0 24 24" id="riot" xmlns="http://www.w3.org/2000/svg"><defs><path d="M13.26 3.04l.58.05.54.07.52.09.49.11.46.13.44.14.41.16.39.17.36.19.33.21.32.22.29.23.26.25.22.22.2.22.19.24.17.24.15.25.15.26.12.27.12.28.1.29.08.31.07.31.05.32.04.34.02.35.01.37v.05l-.02.51-.05.49-.09.48-.13.45-.15.43-.19.4-.22.39-.26.37-.28.34-.31.33-.33.3-.37.28-.39.27-.41.24-.44.22L21 21h-7.04l-3.48-5.14H9.17V21H3V3h9.01l.64.01.61.03zm-4.09 8.52h2.66l.99-.11.75-.35.47-.55.16-.74v-.05l-.17-.75-.47-.54-.74-.32-.96-.11H9.17v3.52z" id="ija"/></defs><use xlink:href="#ija" fill="#ff1744"/><use xlink:href="#ija" fill-opacity="0" stroke="#000" stroke-opacity="0"/></symbol><symbol viewBox="0 0 24 24" id="robot" xmlns="http://www.w3.org/2000/svg"><path d="M12.05 2.804a1.787 1.787 0 0 1 1.788 1.788c0 .661-.357 1.242-.893 1.546v1.135h.893a6.256 6.256 0 0 1 6.256 6.256h.894a.894.894 0 0 1 .893.893v2.681a.894.894 0 0 1-.893.894h-.894v.894a1.787 1.787 0 0 1-1.787 1.787H5.795a1.787 1.787 0 0 1-1.787-1.787v-.894h-.894a.894.894 0 0 1-.894-.894v-2.68a.894.894 0 0 1 .894-.894h.894a6.256 6.256 0 0 1 6.255-6.256h.894V6.138a1.773 1.773 0 0 1-.894-1.546 1.787 1.787 0 0 1 1.788-1.788m-4.022 9.83a2.234 2.234 0 0 0-2.234 2.235 2.234 2.234 0 0 0 2.234 2.234 2.234 2.234 0 0 0 2.234-2.234 2.234 2.234 0 0 0-2.234-2.234m8.043 0a2.234 2.234 0 0 0-2.234 2.234 2.234 2.234 0 0 0 2.234 2.234 2.234 2.234 0 0 0 2.235-2.234 2.234 2.234 0 0 0-2.235-2.234z" fill="#ff5722" stroke-width=".894"/></symbol><symbol viewBox="100 100 800 800" id="rollup" xmlns="http://www.w3.org/2000/svg"><style>.ilst0{fill:url(#ilXMLID_4_)}.ilst1{fill:url(#ilXMLID_5_)}.ilst2{fill:url(#ilXMLID_8_)}.ilst3{fill:url(#ilXMLID_9_)}.ilst4{fill:url(#ilXMLID_11_)}.ilst5{opacity:.3;fill:url(#ilXMLID_16_)}</style><g id="ilXMLID_14_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_4_" x1="444.47" x2="598.47" y1="526.05" y2="562.05" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_15_" class="ilst0" d="M721 410c0-33.6-8.8-65.1-24.3-92.4-41.1-42.3-130.5-52.1-152.7-.2-22.8 53.2 38.3 112.4 65 107.7 34-6-6-84-6-84 52 98 40 68-54 158S359 779 345 787c-.6.4-1.2.7-1.9 1h368.7c6.5 0 10.7-6.9 7.8-12.7l-96.4-190.8c-2.1-4.1-.6-9.2 3.4-11.5C683 540.6 721 479.8 721 410z" fill="url(#ilXMLID_4_)"/></g><g id="ilXMLID_2_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_5_" x1="420.38" x2="696.38" y1="475" y2="689" gradientUnits="userSpaceOnUse"><stop stop-color="#BF3338" offset="0"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_10_" class="ilst1" d="M721 410c0-33.6-8.8-65.1-24.3-92.4-41.1-42.3-130.5-52.1-152.7-.2-22.8 53.2 38.3 112.4 65 107.7 34-6-6-84-6-84 52 98 40 68-54 158S359 779 345 787c-.6.4-1.2.7-1.9 1h368.7c6.5 0 10.7-6.9 7.8-12.7l-96.4-190.8c-2.1-4.1-.6-9.2 3.4-11.5C683 540.6 721 479.8 721 410z" fill="url(#ilXMLID_5_)"/></g><linearGradient id="ilXMLID_8_" x1="429.39" x2="469.39" y1="517.16" y2="559.16" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_3_" class="ilst2" d="M329.82 813.46c15.58-8.903 122.41-220.34 227.02-320.5s117.96-66.771 60.094-175.83c0 0-221.46 310.49-301.58 464.06" fill="url(#ilXMLID_8_)" stroke-width="1.113"/><g id="ilXMLID_7_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_9_" x1="502.11" x2="490.11" y1="589.46" y2="417.46" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_12_" class="ilst3" d="M373 537c134.4-247.1 152-272 222-272 36.8 0 73.9 16.6 97.9 46.1-32.7-52.7-90.6-88-156.9-89H307.7c-4.8 0-8.7 3.9-8.7 8.7V691c13.6-35.1 36.7-85.3 74-154z" fill="url(#ilXMLID_9_)"/></g><linearGradient id="ilXMLID_11_" x1="450.12" x2="506.94" y1="514.21" y2="552.85" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FBB040" offset="0"/><stop stop-color="#FB8840" offset="1"/></linearGradient><path id="ilXMLID_6_" class="ilst4" d="M556.84 492.96c-104.61 100.16-211.44 311.6-227.02 320.5s-41.732 10.016-55.643-5.564c-14.801-16.582-37.837-43.401 86.802-272.65 149.57-274.99 169.15-302.7 247.05-302.7 40.953 0 82.24 18.473 108.95 51.302 1.447 2.337 2.893 4.785 4.34 7.233-45.738-47.074-145.23-57.98-169.93-.222-25.373 59.204 42.622 125.08 72.335 119.85 37.837-6.677-6.677-93.48-6.677-93.48 57.757 108.95 44.403 75.563-60.205 175.72z" fill="url(#ilXMLID_11_)" stroke-width="1.113"/><linearGradient id="ilXMLID_16_" x1="508.33" x2="450.33" y1="295.76" y2="933.76" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFF" offset="0"/><stop stop-color="#FFF" stop-opacity="0" offset="1"/></linearGradient><path id="ilXMLID_13_" class="ilst5" d="M373.22 547.49c149.57-274.99 169.15-302.7 247.05-302.7 33.719 0 67.661 12.575 93.48 35.277-26.708-30.492-66.326-47.519-105.72-47.519-77.9 0-97.486 27.71-247.05 302.7-124.64 229.25-101.6 256.07-86.802 272.65 2.114 2.337 4.563 4.34 7.122 6.01-13.02-18.919-18.807-62.877 91.922-266.42z" fill="url(#ilXMLID_16_)" opacity=".3" stroke-width="1.113"/></symbol><symbol viewBox="0 0 24 24" id="ruby" xmlns="http://www.w3.org/2000/svg"><path d="M16 9h3l-5 7m-4-7h4l-2 8M5 9h3l2 7m5-12h2l2 3h-3m-5-3h2l1 3h-4M7 4h2L8 7H5m1-5L2 8l10 14L22 8l-4-6H6z" fill="#f44336"/></symbol><symbol viewBox="0 0 144 144" id="rust" xmlns="http://www.w3.org/2000/svg"><path d="M68.252 26.206a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0M25.766 58.451a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m84.97.166a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m-74.661 4.88a3.252 3.252 0 0 0 1.651-4.29l-1.58-3.574h6.214v28.01H29.823a43.847 43.847 0 0 1-1.42-16.738zm25.994.688v-8.256h14.798c.764 0 5.397.883 5.397 4.347 0 2.877-3.553 3.908-6.475 3.908zm-20.203 44.452a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m52.769.166a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m1.101-8.076a3.246 3.246 0 0 0-3.856 2.498l-1.787 8.342a43.847 43.847 0 0 1-36.566-.175l-1.787-8.342a3.246 3.246 0 0 0-3.854-2.497l-7.365 1.581a43.847 43.847 0 0 1-3.808-4.488h35.834c.406 0 .676-.074.676-.443V84.527c0-.369-.27-.442-.676-.442h-10.48V76.05h11.335c1.035 0 5.532.296 6.97 6.045.45 1.768 1.44 7.519 2.116 9.36.674 2.065 3.417 6.19 6.34 6.19h18.501a43.847 43.847 0 0 1-4.06 4.7zm19.898-33.468a43.847 43.847 0 0 1 .093 7.612h-4.499c-.45 0-.631.296-.631.737v2.066c0 4.863-2.742 5.92-5.145 6.19-2.288.258-4.825-.958-5.138-2.358-1.35-7.593-3.6-9.214-7.152-12.016 4.409-2.8 8.996-6.93 8.996-12.457 0-5.97-4.092-9.729-6.881-11.572-3.914-2.58-8.246-3.096-9.415-3.096H39.336A43.847 43.847 0 0 1 63.867 28.52l5.484 5.753a3.243 3.243 0 0 0 4.59.105l6.137-5.869a43.847 43.847 0 0 1 30.017 21.38l-4.201 9.487a3.256 3.256 0 0 0 1.652 4.29zm10.477.154l-.143-1.467 4.327-4.036c.88-.82.55-2.472-.574-2.891l-5.532-2.068-.433-1.428 3.45-4.792c.704-.974.058-2.53-1.127-2.724l-5.833-.949-.7-1.31 2.45-5.38c.502-1.095-.43-2.496-1.636-2.45l-5.92.206-.935-1.135 1.36-5.766c.275-1.17-.913-2.36-2.084-2.085l-5.765 1.359-1.136-.935.207-5.92c.046-1.198-1.357-2.135-2.45-1.637l-5.379 2.452-1.31-.703-.95-5.833c-.193-1.183-1.75-1.83-2.723-1.128l-4.796 3.45-1.425-.432-2.068-5.532c-.42-1.127-2.072-1.452-2.89-.576l-4.036 4.33-1.467-.143-3.117-5.036c-.63-1.02-2.318-1.02-2.946 0l-3.117 5.036-1.467.143-4.037-4.33c-.819-.876-2.47-.551-2.89.576l-2.069 5.532-1.426.432-4.795-3.45c-.974-.703-2.53-.055-2.723 1.128l-.951 5.833-1.31.703-5.379-2.452c-1.093-.5-2.496.439-2.45 1.637l.206 5.92-1.136.935-5.765-1.36c-1.171-.272-2.36.915-2.086 2.086l1.358 5.766-.933 1.135-5.92-.206c-1.193-.035-2.134 1.355-1.637 2.45l2.453 5.38-.703 1.31-5.832.949c-1.185.192-1.827 1.75-1.128 2.724l3.45 4.792-.433 1.428-5.532 2.068c-1.123.42-1.452 2.07-.574 2.891l4.328 4.036-.143 1.467-5.035 3.116c-1.02.63-1.02 2.318 0 2.946l5.035 3.117.143 1.467-4.328 4.037c-.878.818-.549 2.468.574 2.89l5.532 2.068.433 1.428-3.45 4.793c-.701.976-.056 2.532 1.129 2.723l5.831.948.703 1.312-2.453 5.378c-.5 1.093.444 2.5 1.638 2.451l5.917-.207.935 1.136-1.358 5.768c-.275 1.168.915 2.355 2.086 2.08l5.765-1.357 1.137.932-.207 5.921c-.046 1.199 1.357 2.136 2.45 1.636l5.379-2.45 1.31.702.95 5.83c.193 1.187 1.75 1.829 2.725 1.13l4.792-3.453 1.427.435 2.069 5.53c.42 1.123 2.072 1.454 2.89.574l4.037-4.328 1.467.146 3.117 5.035c.628 1.016 2.316 1.018 2.946 0l3.117-5.035 1.467-.146 4.036 4.328c.818.88 2.47.549 2.89-.574l2.068-5.53 1.428-.435 4.793 3.453c.974.699 2.53.055 2.722-1.13l.952-5.83 1.31-.703 5.378 2.451c1.093.5 2.493-.435 2.45-1.636l-.206-5.92 1.135-.933 5.765 1.357c1.171.275 2.36-.912 2.085-2.08l-1.358-5.768.932-1.136 5.92.207c1.194.048 2.138-1.358 1.636-2.451l-2.45-5.378.7-1.312 5.833-.948c1.187-.19 1.831-1.747 1.127-2.723l-3.45-4.793.433-1.428 5.532-2.068c1.125-.422 1.454-2.072.574-2.89l-4.327-4.037.143-1.467 5.035-3.117c1.02-.628 1.021-2.315.001-2.946z" fill="#ff7043" stroke-width="1.146"/></symbol><symbol viewBox="0 0 500 500" id="sass" xmlns="http://www.w3.org/2000/svg"><path d="M422.676 96.573c-12.192-47.839-91.508-63.557-166.575-36.892-44.68 15.877-93.029 40.786-127.81 73.311-41.349 38.675-47.943 72.328-45.216 86.395 9.583 49.622 77.585 82.069 105.535 106.126v.144c-8.246 4.05-68.565 34.584-82.684 65.799-14.893 32.932 2.372 56.556 13.804 59.742 35.424 9.859 71.764-7.866 91.311-37.01 18.853-28.12 17.28-64.422 9.086-82.487 11.3-2.976 24.476-4.314 41.218-2.36 47.248 5.52 56.517 35.017 54.747 47.366-1.77 12.35-11.681 19.14-14.998 21.186-3.317 2.045-4.326 2.766-4.05 4.287.405 2.215 1.94 2.137 4.758 1.652 3.894-.656 24.804-10.042 25.709-32.828 1.14-28.933-26.587-61.302-75.684-60.45-20.216.354-32.933 2.268-42.123 5.69-.681-.774-1.363-1.547-2.084-2.307-30.35-32.382-86.46-55.285-84.088-98.824.866-15.823 6.372-57.5 107.817-108.052 83.104-41.415 149.637-30.009 161.135-4.76 16.427 36.08-35.554 103.137-121.858 112.812-32.88 3.684-50.198-9.059-54.498-13.804-4.536-4.995-5.204-5.218-6.909-4.287-2.753 1.533-1.01 5.938 0 8.574 2.583 6.712 13.15 18.603 31.176 24.515 15.863 5.205 54.459 8.063 101.156-9.99 52.283-20.255 93.12-76.523 81.125-123.548zM200.213 340.34c3.92 14.5 3.487 28.016-.564 40.248a65.289 65.289 0 0 1-3.225 7.97c-3.12 6.477-7.316 12.534-12.442 18.132-15.653 17.069-37.507 23.532-46.88 18.092-10.122-5.874-5.048-29.944 13.083-49.11 19.52-20.636 47.602-33.903 47.602-33.903l-.039-.079 2.465-1.35z" fill="#ec407a" stroke="#ec407a" stroke-width="16.286552999999998"/></symbol><symbol viewBox="0 0 300 300" id="sbt" xmlns="http://www.w3.org/2000/svg"><path d="M105.46 209.517c-7.875 0-13.452-7.521-13.452-15.37v-.327c0-7.848 5.578-13.735 13.452-13.735h164.05c1.476-4.905 2.625-11.446 3.281-17.986h-137.81c-7.875 0-14.273-6.05-14.273-13.898s6.398-13.898 14.273-13.898h137.31c-.82-6.54-1.969-13.081-3.773-17.986h-104.01c-7.875 0-14.273-6.05-14.273-13.898s6.398-13.898 14.273-13.898h91.87c-21.327-37.607-60.864-61.315-106.14-61.315-67.918 0-123.04 54.448-123.04 122.3 0 67.856 55.122 123.28 123.04 123.28 46.59 0 87.112-25.507 107.95-63.114h-152.73z" fill="#0277bd" stroke-width="1.638"/></symbol><symbol viewBox="0 0 256 256" id="scala" xmlns="http://www.w3.org/2000/svg"><path fill="#f44336" fill-rule="evenodd" stroke-width=".3" d="M59.607 50.647l149.097-21.982v49.488L59.607 100.135zM59.593 114.08L208.69 92.098v49.488L59.593 163.568zM59.587 177.358l149.097-21.982v49.488L59.587 226.846z"/><path fill="#f44336" fill-rule="evenodd" stroke-width=".3" d="M62.425 91.414l95.605 30.923-2.832 8.757-95.605-30.922zM113.084 61.13l95.604 30.922-2.832 8.757-95.605-30.922zM62.425 154.79l95.605 30.922-2.833 8.758-95.604-30.923zM113.097 124.408l95.604 30.923-2.832 8.757-95.605-30.922z"/></symbol><symbol viewBox="0 0 24 24" id="settings" xmlns="http://www.w3.org/2000/svg"><path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="shaderlab" xmlns="http://www.w3.org/2000/svg"><path d="M9.11 17H6.5l-4.91-5L6.5 7h2.61l1.31-2.26L17.21 3l1.87 6.74L17.77 12l1.31 2.26L17.21 21l-6.79-1.74L9.11 17m.14-.25l5.13 1.38L11.42 13H5.5l3.75 3.75m6.87.38L17.5 12l-1.38-5.13L13.15 12l2.97 5.13M9.25 7.25L5.5 11h5.92l2.96-5.13-5.13 1.38z" fill="#1976d2"/></symbol><symbol viewBox="0 0 24 24" id="slim" xmlns="http://www.w3.org/2000/svg"><path d="M6.959 2.5a4.605 4.605 0 0 0-4.615 4.615v9.957a4.605 4.605 0 0 0 4.615 4.615h9.957a4.605 4.605 0 0 0 4.615-4.615V7.115A4.605 4.605 0 0 0 16.916 2.5zm4.938 2.691a6.811 6.811 0 0 1 6.81 6.813H13.43L9.938 7.287l.699 4.717H5.086a6.811 6.811 0 0 1 6.81-6.813z" fill="#f57f17"/></symbol><symbol id="smarty" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.iust0{fill:#ffce00}</style><path class="iust0" d="M9.14 20.606c0 .556.398.953.954.953h3.812c.556 0 .953-.397.953-.953v-.953H9.141zM12 2.5c-3.653 0-6.671 3.018-6.671 6.671 0 2.303 1.112 4.289 2.859 5.48v2.144c0 .556.397.953.953.953h5.718c.556 0 .953-.397.953-.953V14.65c1.747-1.191 2.86-3.177 2.86-5.48 0-3.653-3.019-6.671-6.672-6.671zm2.7 10.563l-.794.555v2.224h-3.812v-2.224l-.794-.555A4.712 4.712 0 0 1 7.235 9.17 4.78 4.78 0 0 1 12 4.405a4.78 4.78 0 0 1 4.765 4.765 4.712 4.712 0 0 1-2.065 3.892z"/></symbol><symbol viewBox="0 0 200 200" id="snyk" xmlns="http://www.w3.org/2000/svg"><title>Group 2</title><g transform="translate(15.255 18.22) scale(1.8477)" fill="none" fill-rule="evenodd"><path d="M65.161 24.997c-1.656 5.974-5.255 23.587-5.255 23.587s-6.618-2.464-14.148-2.476h-.055c-.413.002-.822.012-1.23.026v41.649h6.677v.003h5.815v-.003h20.858c.111-8.177-2.036-27.066-2.036-27.066-1.088-2.279.46-7.668.46-7.668-8.869-9.092-11.086-28.051-11.086-28.051zm-3.357 43.958c5.476 0 1.381 4.64.9 5.168H52.35c.944-1.18 4.504-5.168 9.453-5.168z" fill="#607d8b" stroke-width="1.6"/><path d="M26.366 24.995s-2.217 18.961-11.087 28.053c0 0 1.548 5.391.46 7.669 0 0-2.15 18.895-2.038 27.066h19.273v.003h7.079v-.003h5.744V46.107h-.025c-7.532.013-14.151 2.478-14.151 2.478s-3.6-17.615-5.255-23.59zm3.264 43.96c4.95 0 8.51 3.987 9.452 5.168H28.73c-.479-.528-4.573-5.168.9-5.168z" fill="#90a4ae" stroke-width="1.6"/><g transform="translate(23.76 77.45) scale(1.5998)"><g transform="translate(17.526)"><path d="M7.357.06H.177v.075C.177 2.64 2.345 4.67 4.89 4.67 7.431 4.67 9.6 2.64 9.6.135V.059z" fill="#455a64"/><path d="M1.972.06v.075a2.692 2.692 0 1 0 5.386 0V.059z" fill="#fff"/><path d="M5.496.06H4.234c-.012 0-.023.005-.034.007.157.033.243.388.21.624a.721.721 0 0 1-.71.617c.102.471.487.85.997.922a1.188 1.188 0 0 0 1.35-1.007C6.112.743 5.881.06 5.495.06z" fill="#37474f"/></g><path d="M7.552.06H.372v.075c0 2.505 2.17 4.535 4.712 4.535 2.544 0 4.712-2.03 4.712-4.535V.059z" fill="#455a64"/><path d="M2.168.06v.075a2.692 2.692 0 1 0 5.385 0V.059z" fill="#fff"/><path d="M5.692.06H4.428c-.01 0-.022.005-.032.007.156.033.242.388.21.624a.72.72 0 0 1-.712.617c.104.471.488.85.999.922A1.187 1.187 0 0 0 6.24 1.223C6.308.743 6.078.06 5.69.06z" fill="#37474f"/></g><path d="M25.514-.27l-4.202 7.697C19.838 10.17 6.858 34.465 6.858 43.243v.516L12.8 59.573c-.8 7.258-2.203 21.643-1.78 28.21h5.73c-.354-3.787.648-17.008 1.903-28.25l.076-.677-1.075-2.892c3.694-3.868 6.285-9.193 8.073-14.261l.174 1.235 5.869 9.629 2.291-.983c.058-.024 5.935-2.523 11.643-2.523 5.672 0 11.646 2.5 11.702 2.525l2.29.976 5.86-9.626.23-1.608c1.769 5.117 4.358 10.536 8.07 14.49l-1.127 3.035.076.678c1.259 11.286 2.266 24.564 1.916 28.252h5.677c.406-6.567-1.05-20.952-1.848-28.208l5.838-15.817v-.514c0-8.779-12.876-33.074-14.347-35.816L65.923-.27l-5.897 41.229-2.723 4.478c-2.628-.882-7.1-2.11-11.603-2.11-4.498 0-8.94 1.225-11.557 2.108l-2.722-4.476-2.07-14.452a.832.832 0 0 0 .006-.071l-.016-.004zm-3.166 18.39l1.206 8.407c-.46 3.143-2.561 15.47-8.198 23.24l-2.598-6.99c.325-4.554 5.067-15.462 9.59-24.656zm46.763 0c4.523 9.194 9.267 20.104 9.592 24.657L76.166 49.6c-6.09-8.553-8-22.459-8.166-23.73z" fill="#607d8b" stroke-width="1.6"/></g></symbol><symbol viewBox="0 0 24 24" id="solidity" xmlns="http://www.w3.org/2000/svg"><path d="M5.8 14.05l6.253 8.61 6.252-8.61-6.254 3.807z" fill="#0288d1" stroke-width="4.553" stroke-linejoin="round"/><path d="M12.051 1.347L5.8 11.833l6.252 3.807 6.254-3.807z" fill="#0288d1" stroke-width="5.025" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 120 120" id="sonar" xmlns="http://www.w3.org/2000/svg"><style>.a,.b{fill:#fff}.b{stroke:#fff;stroke-miterlimit:10}</style><path d="M115.45 23.033S97.961 33.27 97.534 33.412c-.427.284-.852.57-1.137.854-1.422 1.421-1.848 3.41-1.422 5.26.285.852.711 1.849 1.422 2.56.711.71 1.564 1.137 2.559 1.422 1.848.426 3.84 0 5.262-1.422.426-.427.709-.853.851-1.28l.143-.427 2.56-4.692zm-39.102 9.242c-27.441 0-31.99 13.08-31.99 29.29 0 3.838.569 7.962-1.99 11.942-3.84 5.972-8.957 5.828-10.236 5.828-1.706 0-7.962-.993-8.246-2.841h.994c6.682 0 11.658-5.404 11.658-12.655v-2.56h-5.686c-4.123 0-7.82 1.849-10.238 5.12-2.417-3.271-6.113-5.12-10.236-5.12h-5.83v2.56c0 7.11 5.688 12.795 12.797 12.795h1.848c0 4.124 5.687 20.332 47.63 20.332 16.352 0 40.665-2.843 40.665-33.697 0-5.829-1.848-11.23-4.691-15.78-.996.284-1.992.568-3.13.568a8.92 8.92 0 0 1-8.956-8.957c0-.995.141-1.991.425-2.986-4.265-2.702-8.53-3.838-14.787-3.838z" fill="#1e88e5" stroke-width="1.422"/></symbol><symbol viewBox="0 0 412 395" id="stylelint" xmlns="http://www.w3.org/2000/svg"><title>stylelint-icon-white</title><g transform="translate(31.478 29.499) scale(.84775)" fill="#cfd8dc" fill-rule="evenodd"><path d="M208.8 393.05c45.057-161.12 43.75-161.85 76.32-276.73l7.832 4.523c4.255 2.458 7.738.448 7.738-4.455V61.602c8.643-30.27 15.416-53.66 17.4-60.693h35.287l58.618 54.304-38.498 33.27 29.11 31.473-191.86 273.09c-.938 1.542-2.244 1.19-1.947 0zm20.96-347.28c1.733 0 3.148.958 3.148 2.147v28.077c0 1.186-1.415 2.15-3.147 2.15h-47.396c-1.742 0-3.153-.96-3.153-2.15V47.917c0-1.185 1.41-2.147 3.153-2.147h47.396z"/><path d="M288.26 14.688l-52.14 30.1c.605.92.973 1.98.973 3.136v28.078c0 1.457-.565 2.77-1.496 3.83l52.663 30.402c3.59 2.073 6.535.377 6.535-3.764V18.456c0-4.145-2.944-5.836-6.535-3.768zM175.02 76V47.923c0-1.15.368-2.21.966-3.13l-52.14-30.105c-3.588-2.068-6.53-.376-6.53 3.768v88.013c0 4.14 2.938 5.84 6.53 3.76l52.66-30.405c-.926-1.06-1.487-2.37-1.487-3.827z"/><path d="M201.25 393.05h1.947c-45.05-161.12-43.753-161.85-76.32-276.73l-7.833 4.523c-4.253 2.458-7.737.448-7.737-4.455V61.602C102.662 31.332 95.892 7.942 93.902.909H58.619L.002 55.213l38.494 33.27-29.11 31.473z"/><circle cx="204.57" cy="122.54" r="14.231"/><circle cx="204.57" cy="207.16" r="14.231"/><circle cx="204.57" cy="291.78" r="14.23"/></g></symbol><symbol viewBox="0 0 412 395" id="stylelint_light" xmlns="http://www.w3.org/2000/svg"><title>stylelint-icon-black</title><g transform="translate(31.478 29.499) scale(.84775)" fill="#546e7a" fill-rule="evenodd"><path d="M208.8 393.05c45.057-161.12 43.75-161.85 76.32-276.73l7.832 4.523c4.255 2.458 7.738.448 7.738-4.455V61.602c8.643-30.27 15.416-53.66 17.4-60.693h35.287l58.618 54.304-38.498 33.27 29.11 31.473-191.86 273.09c-.938 1.542-2.244 1.19-1.947 0zm20.96-347.28c1.733 0 3.148.958 3.148 2.147v28.077c0 1.186-1.415 2.15-3.147 2.15h-47.396c-1.742 0-3.153-.96-3.153-2.15V47.917c0-1.185 1.41-2.147 3.153-2.147h47.396z"/><path d="M288.26 14.688l-52.14 30.1c.605.92.973 1.98.973 3.136v28.078c0 1.457-.565 2.77-1.496 3.83l52.663 30.402c3.59 2.073 6.535.377 6.535-3.764V18.456c0-4.145-2.944-5.836-6.535-3.768zM175.02 76V47.923c0-1.15.368-2.21.966-3.13l-52.14-30.105c-3.588-2.068-6.53-.376-6.53 3.768v88.013c0 4.14 2.938 5.84 6.53 3.76l52.66-30.405c-.926-1.06-1.487-2.37-1.487-3.827z"/><path d="M201.25 393.05h1.947c-45.05-161.12-43.753-161.85-76.32-276.73l-7.833 4.523c-4.253 2.458-7.737.448-7.737-4.455V61.602C102.662 31.332 95.892 7.942 93.902.909H58.619L.002 55.213l38.494 33.27-29.11 31.473z"/><circle cx="204.57" cy="122.54" r="14.231"/><circle cx="204.57" cy="207.16" r="14.231"/><circle cx="204.57" cy="291.78" r="14.23"/></g></symbol><symbol viewBox="0 0 200.00001 200.00001" id="stylus" xmlns="http://www.w3.org/2000/svg"><path d="M126.814 155.9c14.64-17.51 16.362-35.595 5.024-69.18-7.177-21.24-19.09-37.602-10.334-50.807 9.329-14.065 29.135-.43 12.63 18.371l3.301 2.297c19.806 2.296 29.566-24.83 14.783-32.58C113.179 3.621 79.02 42.803 94.09 88.156c6.458 19.232 15.5 39.613 8.18 55.83-6.314 13.923-18.514 22.103-26.695 22.39-17.079.862-5.74-38.32 13.922-48.08 1.722-.861 4.162-2.01 1.866-4.88-24.256-2.727-38.464 8.468-46.645 24.112-23.825 45.497 45.21 62.29 82.095 18.371z" fill="#c0ca33" stroke-width="1.435"/></symbol><symbol viewBox="0 0 24 24" id="swc" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="jba"><stop offset="0" stop-color="#791223"/><stop offset="1" stop-color="#d92f3c"/></linearGradient><linearGradient xlink:href="#jba" id="jbb" x1="12.356" y1="21.559" x2="12.356" y2="2.949" gradientUnits="userSpaceOnUse"/></defs><path d="M6 3c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6 3 6.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.5c0-.5-.17-.93-.46-1.27l-1.39-1.68C18.88 3.21 18.47 3 18 3H6zm-.07 1h12l.94 1H5.12l.81-1z" fill="url(#jbb)"/><path style="line-height:125%" d="M11.053 11.918h-.008c-.244.022-.475.054-.676.11a2.9 2.9 0 0 0-.856.412 3.399 3.399 0 0 0-.67.683 9.36 9.36 0 0 0-.586.95c-.07.131-.134.244-.201.365v.001h-.002l-.768 1.372-.003-.001c-.136.253-.264.485-.38.686-.123.212-.26.39-.411.539a1.599 1.599 0 0 1-.52.34c-.04.016-.092.024-.138.036h-.567v1.383H5.834v-.001c.245-.02.477-.053.679-.11a2.9 2.9 0 0 0 .856-.411c.245-.185.469-.413.67-.683.195-.275.39-.591.585-.95.07-.131.135-.244.202-.366l.004.001.002-.002.02-.038H10.948v-1.378h-.19v-.001H9.624c.125-.234.246-.452.355-.64.123-.21.259-.39.41-.538.152-.148.325-.26.52-.34.04-.015.091-.024.136-.035h.57V13.3h-.002v-1.381h-.56v-.001z" font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#fff"/></symbol><symbol viewBox="0 0 24 24" id="swift" xmlns="http://www.w3.org/2000/svg"><path d="M17.09 19.72c-2.36 1.36-5.59 1.5-8.86.1A13.807 13.807 0 0 1 2 14.5c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17z" fill="#fe5e2f"/></symbol><symbol viewBox="0 0 24 24" id="table" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5m4 7.5h-4v2h1l-2 1.67L10 13h1v-2H7v2h1l3 2.5L8 18H7v2h4v-2h-1l2-1.67L14 18h-1v2h4v-2h-1l-3-2.5 3-2.5h1v-2z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 200 200" id="terraform" xmlns="http://www.w3.org/2000/svg"><g transform="translate(177.03 -58.705) scale(.92881)" fill="#5c6bc0" stroke="#b0aff5" stroke-linejoin="round"><g stroke-width=".288"><path transform="skewY(26.439) scale(.89541 1)" d="M-203.8 170.95h64.714v51.88H-203.8zM-124.37 171.04h64.714v51.88h-64.714zM-124.37 236.09h64.714v51.88h-64.714z"/></g><path transform="skewY(-22.59) scale(-.92328 1)" stroke-width=".284" d="M-19.172 128.27h62.76v51.88h-62.76z"/></g></symbol><symbol viewBox="0 0 24 24" id="test-js" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="test-jsx" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#00bcd4"/></symbol><symbol viewBox="0 0 24 24" id="test-ts" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#0288d1"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="tex" xmlns="http://www.w3.org/2000/svg"><g font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#42a5f5" stroke-linejoin="miter"><text style="line-height:125%" x="9.914" y="364.919"><tspan x="9.914" y="364.919" font-size="287.5">T</tspan></text><text style="line-height:125%" x="136.374" y="435.558"><tspan x="136.374" y="435.558" font-size="287.5">E</tspan></text><text style="line-height:125%" x="307.819" y="361.201"><tspan x="307.819" y="361.201" font-size="287.5">X</tspan></text></g></symbol><symbol viewBox="0 0 24 24" id="todo" xmlns="http://www.w3.org/2000/svg"><path d="M3 5h6v6H3V5m2 2v2h2V7H5m6 0h10v2H11V7m0 8h10v2H11v-2m-6 5l-3.5-3.5 1.41-1.41L5 17.17l4.59-4.58L11 14l-6 6z" fill="#42a5f5"/></symbol><symbol id="travis" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><style id="jkstyle2">.jkst0{fill:#cb3349}.jkst1{fill:#f4edae}.jkst2{fill:#e6ccad}.jkst3{fill:#656c67}.jkst4{fill:#e5caa3}.jkst5{fill:#c7b39a}.jkst6{fill:#ebd599}.jkst7{fill:#2d3136}.jkst8{fill:#edf6fa}.jkst9{opacity:.8}.jkst10{opacity:.75;fill:#ebd599}</style><g id="jkg99" transform="translate(11.017 12.484) scale(.8858)"><g id="jkg10"><path class="jkst0" d="M47.781 86.572s-31.118 21.903-32.335 30.247l2.335-.48S55.045 91.64 84.584 88.628l.669-3.749z" id="jkpath4" fill="#cb3349"/><path class="jkst0" d="M96.629 83.442l-24.511 17.385 1.325 1.063c.999-.806 43.539-13.798 43.539-13.798l8.969-5.623c-6.018.749-29.322.973-29.322.973z" id="jkpath6" fill="#cb3349"/><path class="jkst0" d="M117.932 104.469c17.405 0 43.495-17.046 43.495-17.046l-8.434-1.605c-.417.417-13.6-.462-13.6-.462l-6.258-1.738-14.951 17.036-1.217 2.956c1.075-.437.965.859.965.859z" id="jkpath8" fill="#cb3349"/></g><path class="jkst0" d="M174.728 158.832l-5.377 1.514-24.843-.537-15.541-12.085-18.784 4.7-21.726-1.88-12.166 13.294-22.828 6.819-11.398-3.534-.574-.494 5.116 12.527s11.588 12.424 18.061 13.885c6.472 1.461 18.165-.105 26.935-1.463 8.769-1.357 15.764-4.489 18.582-9.603 2.818-5.117 3.236-6.578 3.236-6.578s8.353 11.797 15.556 13.155c7.203 1.357 28.605-5.952 28.605-5.952s13.051-3.549 15.346-8.038c2.297-4.489 8.353-19.209 8.353-19.209zM44.456 169.038l-.361-.166-2.013-1.736z" id="jkpath12" fill="#cb3349"/><g id="jkg97"><path class="jkst1" d="M195.832 70.085a48.125 48.125 0 0 0-.21-2.009 26.472 26.472 0 0 0-.215-1.424c-1.793-1.509-3.831-2.851-5.952-4.071-2.299-1.343-4.704-2.546-7.159-3.663-2.438-1.15-4.942-2.191-7.461-3.207a134.313 134.313 0 0 0-3.798-1.477c-1.269-.495-2.55-.956-3.835-1.424 2.697.447 5.366 1.059 8.015 1.741 1.723.446 3.437.945 5.14 1.477-12.112-31.655-41.07-52.27-72.687-52.27-31.622 0-60.577 20.615-72.686 52.27a109.044 109.044 0 0 1 5.137-1.477c2.653-.682 5.323-1.294 8.018-1.741-1.289.468-2.567.929-3.84 1.424-1.267.472-2.536.967-3.798 1.477-2.519 1.016-5.016 2.057-7.46 3.207-2.45 1.117-4.857 2.32-7.156 3.663-2.121 1.219-4.157 2.562-5.957 4.071-.075.457-.151.951-.21 1.424a51.768 51.768 0 0 0-.21 2.009 51.354 51.354 0 0 0-.177 4.061 59.216 59.216 0 0 0 .5 8.11c.37 2.692.864 5.366 1.595 7.951.36 1.295.768 2.572 1.24 3.808.237.617.495 1.225.764 1.816.134.294.274.585.413.864l.172.328c.199.101.408.204.607.3l1.204.575c.671.305 1.6.746 2.368 1.09.043-.037.086-.075.123-.114l-2.235-8.513c.474-.13 4.718-1.225 12.032-2.617a38.816 38.816 0 0 1-1.772-.381c-1.665-.414-3.309-.919-4.899-1.564a22.415 22.415 0 0 1-2.309-1.115c-.742-.426-1.472-.908-2.037-1.548 8.036 2.622 24.64 1.434 39.399-.091 13.499-1.391 27.029-2.293 40.63-2.32 13.602.027 27.137.929 40.63 2.32 14.766 1.525 31.37 2.713 39.405.091-.564.64-1.293 1.123-2.035 1.548a22.5 22.5 0 0 1-2.308 1.115c-1.592.645-3.234 1.15-4.899 1.564-.247.059-.496.113-.743.166 8.02 1.488 12.689 2.697 13.188 2.831l-2.138 8.11c.43-.194.864-.381 1.29-.574l1.202-.575c.2-.097.403-.199.607-.3l.166-.328c.146-.279.286-.57.419-.864.27-.591.528-1.199.764-1.816a42.235 42.235 0 0 0 1.241-3.808c.731-2.585 1.225-5.259 1.595-7.951.345-2.685.526-5.398.501-8.11a50.874 50.874 0 0 0-.179-4.059z" id="jkpath14" fill="#f4edae"/><path class="jkst2" d="M116.787 182.661c-1.064.16-2.128.295-3.186.375-.682.033-1.404.102-2.059.102l-.242.005c.822-1.837 1.446-3.26 1.919-4.339.963 1.08 2.188 2.417 3.568 3.857z" id="jkpath16" fill="#e6ccad"/><path class="jkst2" d="M119.101 185.018c3.304 3.272 7.398 5.146 11.904 5.479-7.569 3.074-14.702 4.26-20.197 4.63-5.478.367-11.032-.279-16.474-1.771.456-.082.79-.14 1.193-.189.447-.054 10.206-1.327 14.605-7.868l.413.009 1.08-.009c.731 0 1.395-.06 2.094-.087a43.69 43.69 0 0 0 4.878-.703c.167.171.333.338.504.509z" id="jkpath18" fill="#e6ccad"/><path class="jkst3" d="M128.464 87.071a98.82 98.82 0 0 1-1.048 1.343c-1.933 2.444-4.614 5.57-7.794 8.627a369.585 369.585 0 0 0-11.404-.177c-6.46 0-12.655.171-18.537.457 8.311-3.449 18.296-6.818 29.109-8.842a113.323 113.323 0 0 1 9.674-1.408z" id="jkpath20" fill="#656c67"/><path class="jkst3" d="M79.821 90.792c-2.966 2.084-6.317 4.744-9.566 7.971a360.155 360.155 0 0 0-21.567 2.81c9.207-4.232 19.713-8.127 31.133-10.781z" id="jkpath22" fill="#656c67"/><path class="jkst3" d="M181.48 107.969l-3.384 23.679-16.212 11.355-42.283-4.807-6.365-20.961a1.383 1.383 0 0 0-1.108-.971c-1.567-.253-2.953-.382-4.108-.382-1.16 0-2.541.129-4.115.382-.522.086-.95.461-1.106.971l-6.209 20.45-42.047 9.357-16.662-11.672-3.283-26.572c.715-.404 1.441-.806 2.176-1.209 1.031-.222 2.191-.457 3.475-.704l3.094 25.073c.048.392.264.741.586.967l11.462 8.032a1.425 1.425 0 0 0 1.101.213l34.57-7.692c.119-.027.237-.069.344-.124a1.39 1.39 0 0 0 .682-.827l6.225-20.498c1.67-.43 5.947-1.429 9.706-1.429 3.749 0 8.03.999 9.701 1.429l6.225 20.498c.161.532.624.912 1.176.977l34.57 3.927c.335.037.677-.05.952-.242l11.469-8.025c.31-.22.52-.566.573-.946l3.062-21.421c2.301.444 4.224.846 5.733 1.172z" id="jkpath24" fill="#656c67"/><path class="jkst3" d="M185.751 93.119l-2.976 11.29c-6.086-1.342-19.456-3.975-37.654-5.747 5.946-2.535 12-5.715 17.531-9.69 10.829 1.53 18.78 3.169 23.099 4.147z" id="jkpath26" fill="#656c67"/><g id="jkg32"><path class="jkst4" d="M63.841 128.441c2.357-1.274 5.021-1.085 9.19-1.079.447.011.908.005 1.39-.005.41-.005.822-.011 1.258-.022 4.296-.042 7.869.366 7.806-6.381-.065-6.746-3.062-12.198-7.354-12.155-4.297.037-8.454 5.564-8.197 12.306.07 1.756.328 3.023.742 3.937-3.745.938-4.777 3.254-4.835 3.399zm51.657-27.749a46.634 46.634 0 0 1-5.249 3.712l-6.097 3.68a52.065 52.065 0 0 0-7.331 1.467 1.216 1.216 0 0 0-.317.14 1.406 1.406 0 0 0-.629.794l-6.209 20.46-33.185 7.38-10.452-7.321-3.041-24.634c5.936-1.09 13.874-2.352 23.41-3.42a56.802 56.802 0 0 0-2.955 3.855l-5.677 8.149 8.266-5.511c.123-.086 5.387-3.549 13.998-7.761a377.407 377.407 0 0 1 35.468-.99z" id="jkpath28" fill="#e5caa3"/><path class="jkst4" d="M151.835 125.675c-.042-.16-.945-2.873-4.942-2.397.461-1.003.666-2.356.521-4.21-.528-6.731-4.443-12.08-8.735-11.931-4.292.152-7.042 5.731-6.805 12.478.236 6.741 3.84 6.694 8.132 6.543 5.77-.107 8.939-1.88 11.829-.483zm21.18-19.385l-2.992 20.944-10.539 7.379-33.141-3.766-6.183-20.363a1.41 1.41 0 0 0-.945-.934c-.205-.06-4.308-1.23-8.659-1.607l.795-.053c.687-.049 12.118-1.451 25.767-6.157 15.115 1.161 27.458 3.02 35.897 4.557z" id="jkpath30" fill="#e5caa3"/></g><g id="jkg38"><path class="jkst5" d="M63.841 128.441c2.357-1.274 5.021-1.085 9.19-1.079.447.011.908.005 1.39-.005.41-.005.822-.011 1.258-.022 4.296-.042 7.869.366 7.806-6.381-.065-6.746-3.062-12.198-7.354-12.155-4.297.037-8.454 5.564-8.197 12.306.07 1.756.328 3.023.742 3.937-3.745.938-4.777 3.254-4.835 3.399zm51.657-27.749a46.634 46.634 0 0 1-5.249 3.712l-6.097 3.68a52.065 52.065 0 0 0-7.331 1.467 1.216 1.216 0 0 0-.317.14 1.406 1.406 0 0 0-.629.794l-6.209 20.46-33.185 7.38-10.452-7.321-3.041-24.634c5.936-1.09 13.874-2.352 23.41-3.42a56.802 56.802 0 0 0-2.955 3.855l-5.677 8.149 8.266-5.511c.123-.086 5.387-3.549 13.998-7.761a377.407 377.407 0 0 1 35.468-.99z" id="jkpath34" fill="#c7b39a"/><path class="jkst5" d="M151.835 125.675c-.042-.16-.945-2.873-4.942-2.397.461-1.003.666-2.356.521-4.21-.528-6.731-4.443-12.08-8.735-11.931-4.292.152-7.042 5.731-6.805 12.478.236 6.741 3.84 6.694 8.132 6.543 5.77-.107 8.939-1.88 11.829-.483zm21.18-19.385l-2.992 20.944-10.539 7.379-33.141-3.766-6.183-20.363a1.41 1.41 0 0 0-.945-.934c-.205-.06-4.308-1.23-8.659-1.607l.795-.053c.687-.049 12.118-1.451 25.767-6.157 15.115 1.161 27.458 3.02 35.897 4.557z" id="jkpath36" fill="#c7b39a"/></g><path class="jkst2" d="M187.481 115.502c.508.419.911 1.504.456 6.558-.559 6.188-3.16 17.049-4.771 18.8-1.778.344-5.505-.064-7.778-.595.393-1.559.505-2.306.822-3.9l3.975-2.781c.317-.22.526-.566.58-.941l2.778-19.466c1.686.912 3.421 1.899 3.938 2.325z" id="jkpath40" fill="#e6ccad"/><path class="jkst2" d="M40.937 140.908c.199.704.408 1.407.624 2.1-2.139.628-6.495 1.23-8.465.886-1.633-1.645-4.679-12.966-5.345-18.978-.543-4.871-.162-5.924.333-6.334.575-.483 2.728-1.708 4.593-2.707l2.519 20.449c.048.393.257.741.586.967z" id="jkpath42" fill="#e6ccad"/><path class="jkst2" d="M121.347 141.194l-.151 1.305s-4.581 4.248-11.956 5.199c-7.375.95-13.171-3.582-13.171-3.582.242.788.586 2.567 2.256 4.086a53.184 53.184 0 0 0-6.313-.393c-.804 0-1.616.023-2.401.061-4.539.237-10.924 7.1-15.414 14.014-2.203.697-9.089 2.883-17.06 5.237-7.44-10.309-11.098-20.842-11.469-21.932l.005-.006c-.15-.419-.301-.839-.441-1.268l1.913 1.338v.005l4.726 3.309 1.58 1.101c.236.167.515.253.794.253.102 0 .204-.011.305-.031l43.435-9.67a1.385 1.385 0 0 0 1.025-.95l6.194-20.39c1.069-.145 2.008-.22 2.814-.22.801 0 1.746.075 2.815.22l6.374 20.997c.162.532.624.919 1.171.977z" id="jkpath44" fill="#e6ccad"/><path class="jkst2" d="M170.926 140.066l1.402-.984c-.232.973-.484 1.94-.747 2.896-1.949 6.248-4.25 11.774-6.805 16.656-.565.039-1.161.061-1.8.061-1.972 0-3.986-.167-6.215-.371-3.868-.355-10.007-1.058-11.946-1.283-1.67-1.332-7.385-5.873-12.14-9.615-.187-.151-.348-.291-.505-.42-.837-.708-1.789-1.513-3.717-1.513-1.751 0-4.308.638-10.489 2.508 3.212-2.401 3.233-5.5 3.233-5.5l.151-1.305 40.748 4.629a1.41 1.41 0 0 0 .955-.241l4.094-2.868z" id="jkpath46" fill="#e6ccad"/><path class="jkst6" d="M140.937 54.337c.124 3.625.033 10.194-1.655 16.345a1.335 1.335 0 0 0 0 .704 259.298 259.298 0 0 0-6.446-.591c2.412-5.054 2.938-10.436 3.052-12.332 1.852-1.317 3.696-2.896 5.049-4.126z" id="jkpath48" fill="#ebd599"/><path class="jkst6" d="M79.456 58.462c.112 1.896.638 7.267 3.046 12.317-2.149.171-4.297.37-6.441.596a1.328 1.328 0 0 0 0-.694c-1.686-6.139-1.772-12.714-1.654-16.345 1.353 1.231 3.19 2.81 5.049 4.126z" id="jkpath50" fill="#ebd599"/><path class="jkst7" d="M151.835 125.675c-2.89-1.396-6.059.377-11.828.484-4.292.151-7.896.198-8.132-6.543-.237-6.747 2.513-12.326 6.805-12.478 4.292-.15 8.207 5.2 8.735 11.931.145 1.854-.06 3.207-.521 4.21 3.996-.477 4.899 2.235 4.941 2.396zm-13.488-9.878a2.203 2.203 0 0 0 2.154-2.235 2.186 2.186 0 0 0-2.235-2.153 2.194 2.194 0 0 0 .081 4.388z" id="jkpath52" fill="#2d3136"/><circle transform="rotate(-1.049 138.093 113.428)" class="jkst8" cx="138.307" cy="113.602" id="jkellipse54" r="2.194" fill="#edf6fa"/><path class="jkst7" d="M83.484 120.953c.063 6.747-3.509 6.339-7.806 6.381-.435.011-.848.016-1.258.022-.482.011-.944.016-1.39.005-4.168-.005-6.833-.194-9.19 1.079.058-.145 1.09-2.461 4.835-3.4-.414-.914-.673-2.181-.742-3.937-.257-6.741 3.9-12.269 8.197-12.306 4.292-.042 7.289 5.411 7.354 12.156zm-6.634-3.529a2.195 2.195 0 1 0-.122-4.388 2.195 2.195 0 0 0 .122 4.388z" id="jkpath56" fill="#2d3136"/><circle transform="rotate(-1.473 76.78 115.216)" class="jkst8" cx="76.79" cy="115.23" id="jkellipse58" r="2.195" fill="#edf6fa"/><g class="jkst9" id="jkg64" opacity=".8"><path class="jkst6" d="M50.691 75.155s.667-8.692 2.03-12.023c.702-1.717 4.996-2.81 8.276-3.591 3.278-.78 8.508-2.342 9.524 2.264 1.015 4.606 2.653 7.963 3.746 9.446l-1.404-18.97-22.562 5.464-1.484 16.786.703 1.327 1.171-.703" id="jkpath60" fill="#ebd599"/><path class="jkst6" d="M164.855 75.155s-.666-8.692-2.029-12.023c-.703-1.717-4.997-2.81-8.275-3.591-3.28-.78-8.51-2.342-9.526 2.264-1.013 4.606-2.654 7.963-3.748 9.446l1.407-18.97 22.562 5.464 1.483 16.786-.703 1.327-1.171-.703" id="jkpath62" fill="#ebd599"/></g><path class="jkst10" d="M132.965 18.378s-.598 45.49-11.224 45.49h-14.875-12.752c-10.626 0-11.484-45.47-11.484-45.47l-5.22 15.438.085 21.183 3.707 2.947 1.685 9.096 2.357 5.307 45.482.084 2.105-3.791 1.769-6.4.254-4.043 5.023-14.341z" id="jkpath66" opacity=".75" fill="#ebd599"/><path class="jkst10" d="M166.429 60.794s2.187 15.692 7.974 18.522c5.788 2.829 0 0 0 0l-8.103-2.444z" id="jkpath68" opacity=".75" fill="#ebd599"/><path class="jkst10" d="M48.908 60.794s-2.187 15.692-7.975 18.522c-5.788 2.829 0 0 0 0l8.104-2.444z" id="jkpath70" opacity=".75" fill="#ebd599"/><path class="jkst7" d="M167.987 76.8c2.755.902 5.526 1.858 8.036 3.325-1.343-.532-2.729-.913-4.126-1.257a70.385 70.385 0 0 0-4.201-.924c-2.82-.531-5.65-.982-8.498-1.327-2.841-.37-5.687-.682-8.546-.924-2.858-.241-5.709-.483-8.573-.65-11.446-.704-22.924-.88-34.41-.892-11.483.006-22.962.221-34.409.897-2.862.166-5.715.409-8.572.651-2.857.241-5.71.548-8.546.923-2.847.345-5.678.796-8.498 1.327-1.407.264-2.81.57-4.206.919-1.391.344-2.783.725-4.126 1.257 2.509-1.466 5.28-2.427 8.041-3.331.232-.075.467-.139.703-.214-.015-.059-.032-.113-.043-.177-.048-.317-1.069-7.859.709-18.645.086-.516.456-.935.962-1.075l2.917-.831c.634-22.625 9.952-33.266 10.243-33.594-8.326 13.397-8.25 29.286-8.106 32.986l18.128-5.152c.016-.005.026-.005.042-.01.076-.016.151-.027.226-.032.021 0 .049-.006.075-.006a1.19 1.19 0 0 1 .297.027c.015 0 .031.011.053.016.075.016.145.042.224.075.033.016.054.033.086.049.058.033.119.07.177.112.016.011.034.016.049.033l.032.032c.016.016.037.027.054.044.012.016.494.493 1.262 1.209-.182-5.973.102-23.108 8.262-37.31-.172.498-6.646 19.428-4.415 40.645.724.58 1.486 1.149 2.229 1.649.359.247.58.655.585 1.09.006.07.161 6.833 3.148 12.586.042.086.074.177.102.268 7.429-.505 14.878-.709 22.312-.714 7.436.005 14.88.22 22.307.731.027-.097.06-.193.109-.285 2.986-5.753 3.142-12.516 3.142-12.586.01-.436.231-.843.591-1.09.741-.5 1.493-1.069 2.224-1.649 2.234-21.217-4.24-40.147-4.411-40.645 8.153 14.201 8.444 31.336 8.262 37.31a62.536 62.536 0 0 0 1.261-1.209c.016-.016.039-.027.053-.044.012-.01.018-.021.033-.032.016-.016.033-.022.049-.033.06-.042.119-.079.177-.118.028-.01.054-.027.081-.043.081-.033.155-.059.236-.08.016 0 .033-.011.049-.011.096-.021.2-.032.296-.027.027 0 .049.006.07.006.075.005.156.016.231.032.012.006.028.006.042.01l18.129 5.152c.146-3.7.221-19.59-8.104-32.986.289.328 9.609 10.969 10.237 33.594l2.922.831c.499.14.875.559.962 1.075 1.777 10.786.752 18.328.708 18.645-.01.065-.026.124-.042.182.239.07.47.139.707.215zm-3.297-.968c.14-1.207.789-7.809-.591-16.801l-20.52-5.833c.184 3.475.265 11.012-1.707 18.199a1.619 1.619 0 0 1-.101.258c.203.021.408.037.606.064 5.769.661 11.511 1.584 17.189 2.83 1.712.398 3.426.823 5.124 1.283zm-25.409-5.151c1.688-6.15 1.779-12.72 1.655-16.345-1.353 1.23-3.197 2.809-5.049 4.125-.114 1.896-.64 7.278-3.052 12.332 2.149.173 4.298.366 6.446.591a1.33 1.33 0 0 1 0-.703zm-56.78.098c-2.408-5.05-2.934-10.422-3.046-12.317-1.858-1.316-3.696-2.895-5.049-4.125-.119 3.631-.032 10.206 1.654 16.345.065.237.058.473 0 .694 2.145-.227 4.292-.425 6.441-.597zm-8.933.864a1.65 1.65 0 0 1-.098-.247c-1.975-7.187-1.889-14.723-1.712-18.199L51.244 59.03c-1.38 8.982-.736 15.583-.597 16.797 1.703-.462 3.411-.887 5.131-1.284 2.835-.628 5.693-1.154 8.556-1.638 2.869-.478 5.747-.843 8.626-1.192.205-.027.404-.042.608-.07z" id="jkpath72" fill="#2d3136"/><g id="jkXMLID_1_"><g id="jkg78"><path class="jkst7" d="M129.293 18.973v17.025h-12.068v-4.974h-2.72v22.981h4.109v12.85H97.505v-12.85h4.092v-22.98h-2.711v4.974h-12.06V18.973zm-3.626 13.408v-9.789H90.443v9.789h4.816v-4.974h9.964v30.225h-4.1v5.606h13.865v-5.606h-4.1V27.407h9.964v4.974z" id="jkpath74" fill="#2d3136"/><path class="jkst0" id="jkpolygon76" fill="#cb3349" d="M101.123 57.632h4.1V27.407h-9.964v4.974h-4.816v-9.79h35.224v9.79h-4.816v-4.974h-9.964v30.225h4.1v5.606h-13.864z"/></g></g><path class="jkst3" d="M30.694 93.119c1.759-.399 4.136-.907 7.051-1.47a104.37 104.37 0 0 0-6.222 4.597z" id="jkpath83" fill="#656c67"/><path class="jkst5" d="M95.111 139.78s.492 3.165-3.938 4.519c-4.428 1.355-32.482 9.716-35.682 9.263-3.199-.451-11.319-5.874-11.319-5.874l-1.969-7.004 12.016 7.492z" id="jkpath85" fill="#c7b39a"/><path class="jkst5" d="M120.242 139.167s-.354 3.182 4.131 4.345c4.484 1.161 32.875 8.295 36.05 7.704 3.176-.591 11.053-6.361 11.053-6.361l1.663-7.084-11.045 6.588z" id="jkpath87" fill="#c7b39a"/><path class="jkst5" d="M28.412 133.956s3.887 7.775 10.166 5.083l4.485 1.645-.448 3.29-9.419 1.195-2.541-1.494z" id="jkpath89" fill="#c7b39a"/><path class="jkst5" d="M187.551 131.822s-6.353 8.115-12.632 5.424l-2.019 1.302.448 3.289 9.419 1.196 2.54-1.495z" id="jkpath91" fill="#c7b39a"/><path class="jkst5" d="M89.279 192.904s23.03 11.611 49.106-4.188l-8.374-.571s-18.272 7.232-32.738 3.235z" id="jkpath93" fill="#c7b39a"/><path class="jkst7" d="M112.626 171.509l1.594 1.899c.036.046 3.577 4.26 7.906 8.552 2.879 2.853 6.357 4.297 10.343 4.297 1.361 0 2.791-.175 4.235-.523 1.34-.326 2.796-.673 4.287-1.03 5.384-1.287 11.482-2.749 14.438-3.577.585-.166 1.238-.315 1.925-.472 3.935-.909 9.329-2.163 12.187-7.889 2.149-4.297 5.047-9.874 7.197-13.961-1.863.859-3.816 1.79-5.203 2.52-2.138 1.123-4.938 1.667-8.558 1.667-2.152 0-4.266-.181-6.605-.389-4.675-.43-12.586-1.361-12.667-1.372l-.606-.067-.478-.383c-.071-.052-7.003-5.575-12.606-9.981-.227-.186-.434-.358-.621-.513-.59-.503-.59-.503-.942-.503-1.797 0-7.02 1.62-18.462 5.167l-.703.223-.689-.26c-.078-.026-7.585-2.81-16.581-2.81-.736 0-1.47.019-2.185.056-.901.046-5.958 2.448-12.425 12.68l-.419.657-.741.238c-.107.037-11.238 3.63-23.042 7.005l-.766.218-.725-.337c-.077-.031-4.696-2.174-9.091-4.194 2.397 3.541 5.462 7.958 8.159 11.422 4.711 6.067 10.649 11.674 22.034 11.674 1.428 0 2.945-.088 4.503-.265 11.581-1.309 14.563-1.837 16.168-2.117.543-.092.973-.171 1.522-.238.088-.011 9.571-1.237 12.232-7.206 2.744-6.134 3.298-7.595 3.319-7.651l.968-2.583s.12-.669.317-.877c0 .005 0 .005.005.005l.019.016c.305.219.757.902.757.902zM40.499 55.71c-2.516 1.014-5.016 2.06-7.46 3.209-2.449 1.119-4.856 2.32-7.155 3.66-2.121 1.222-4.157 2.563-5.954 4.076-.077.455-.149.952-.211 1.423a51.357 51.357 0 0 0-.388 6.068c-.026 2.713.16 5.426.502 8.112.372 2.692.864 5.369 1.594 7.952a41.963 41.963 0 0 0 1.243 3.804c.233.623.492 1.228.762 1.818.134.294.274.585.413.864l.172.326c.201.104.409.207.605.3l1.206.574c.673.311 1.6.751 2.366 1.093.046-.037.088-.078.124-.114l-2.231-8.511c.471-.129 4.717-1.227 12.032-2.619a33.744 33.744 0 0 1-1.775-.379 36.704 36.704 0 0 1-4.898-1.563 22.857 22.857 0 0 1-2.309-1.119c-.741-.425-1.471-.905-2.035-1.547 8.035 2.624 24.637 1.433 39.398-.088 13.501-1.393 27.028-2.293 40.628-2.325 13.6.031 27.138.931 40.63 2.325 14.77 1.522 31.374 2.713 39.406.088-.564.642-1.293 1.122-2.034 1.547-.739.42-1.522.782-2.309 1.119a36.965 36.965 0 0 1-4.903 1.563c-.244.056-.492.114-.741.166 8.02 1.486 12.689 2.697 13.186 2.832l-2.138 8.107c.43-.192.864-.377 1.288-.574l1.207-.574c.196-.094.404-.196.606-.3l.166-.326c.144-.279.284-.57.419-.864.27-.591.528-1.196.767-1.818.471-1.231.879-2.51 1.236-3.804.731-2.583 1.228-5.26 1.595-7.952.346-2.686.528-5.4.502-8.112a52.755 52.755 0 0 0-.176-4.059 51.573 51.573 0 0 0-.213-2.009 29.83 29.83 0 0 0-.213-1.423c-1.797-1.513-3.831-2.853-5.954-4.076-2.299-1.34-4.704-2.541-7.159-3.66-2.438-1.149-4.943-2.195-7.46-3.209a140.105 140.105 0 0 0-3.801-1.476c-1.267-.491-2.552-.956-3.835-1.423 2.696.445 5.369 1.06 8.013 1.739 1.724.446 3.444.948 5.141 1.481-12.11-31.658-41.07-52.272-72.685-52.272-31.622 0-60.576 20.614-72.684 52.272a107.832 107.832 0 0 1 5.135-1.481c2.651-.678 5.322-1.294 8.02-1.739-1.29.466-2.568.931-3.842 1.423-1.268.47-2.535.967-3.799 1.475zm159.43 18.316a53.972 53.972 0 0 1-.258 8.733 55.462 55.462 0 0 1-1.619 8.605c-.4 1.414-.86 2.811-1.404 4.198a38.295 38.295 0 0 1-.89 2.071c-.161.341-.331.678-.523 1.025l-.284.512a8.975 8.975 0 0 1-.348.574l-.294.457-.461.237c-.492.254-.895.445-1.342.653l-1.298.585a88.22 88.22 0 0 1-2.62 1.065c-.611.239-1.15.457-1.662.674l-1.444 5.487c-.036-.009-.471-.12-1.283-.315l-.078.574c1.594.833 4.726 2.522 5.793 3.403 2.148 1.775 2.299 4.587 1.823 9.841-.244 2.697-1.139 7.946-2.381 12.767-2.144 8.298-3.283 9.273-4.753 9.649-.746.192-1.894.383-3.008.383-2.266 0-5.353.063-7.429-.439-.533 1.888-2.055 6.812-5.068 12.962.151-.073.3-.135.435-.207 3.717-1.952 10.861-5.064 11.162-5.199l5.643-2.452-2.89 5.435c-.067.118-6.264 11.773-10.059 19.383-3.769 7.538-10.835 9.179-15.065 10.151-.637.151-1.241.291-1.733.425-3.035.854-9.18 2.319-14.599 3.623-.064.016-.13.033-.197.042a64.057 64.057 0 0 1-10.955 5.411c-14.568 5.518-29.923 5.208-43.844.092a647.05 647.05 0 0 1-9.193 1.097 45.12 45.12 0 0 1-4.985.291c-13.264 0-20.294-6.736-25.425-13.331-5.493-7.062-12.212-17.546-12.497-17.985L31 158.426l6.585 2.961c3.152 1.419 12.524 5.757 15.205 7 .217-.061.43-.124.642-.186-4.457-6.357-8.112-13.605-10.695-21.634-2.195.662-5.576 1.175-8.206 1.175-.961 0-1.822-.072-2.484-.228-1.471-.336-3.148-1.754-5.431-9.795-1.325-4.668-2.314-9.764-2.603-12.387-.57-5.121-.466-7.864 1.662-9.636 1.283-1.071 5.611-3.344 6.507-3.809l-.192-1.58c-13.75 8.08-21.991 15.22-22.157 15.366L0 134.302l7.005-11.047c5.544-8.755 11.948-15.832 17.84-21.284-.244-.098-.471-.196-.71-.294l-1.299-.585a34.907 34.907 0 0 1-1.34-.653l-.461-.237-.295-.457c-.166-.249-.238-.388-.347-.574l-.29-.512c-.181-.347-.358-.684-.518-1.025a30.878 30.878 0 0 1-.89-2.071 44.74 44.74 0 0 1-1.404-4.198 54.745 54.745 0 0 1-1.62-8.605 54.664 54.664 0 0 1-.259-8.733c.078-1.455.218-2.909.419-4.354.104-.725.213-1.45.358-2.17.15-.734.296-1.418.518-2.221l.155-.564.404-.317c2.294-1.802 4.768-3.163 7.284-4.369a78.87 78.87 0 0 1 6.311-2.616c5.943-16.493 16.162-31.118 29.591-41.311C74.337 5.57 90.664 0 107.671 0s33.334 5.57 47.218 16.106c13.43 10.193 23.649 24.819 29.588 41.307a78.282 78.282 0 0 1 6.316 2.62c2.515 1.206 4.99 2.567 7.283 4.369l.404.317.156.564c.227.803.372 1.487.517 2.221.146.72.26 1.445.357 2.17.203 1.443.348 2.897.419 4.352zm-11.995 48.031c.456-5.052.058-6.139-.455-6.554-.513-.43-2.247-1.412-3.935-2.329l-2.779 19.464a1.39 1.39 0 0 1-.58.942l-3.977 2.781c-.315 1.593-.429 2.345-.817 3.903 2.273.528 5.999.938 7.775.595 1.612-1.748 4.214-12.61 4.768-18.802zm-5.161-17.648l2.977-11.29c-4.318-.978-12.27-2.615-23.1-4.148-5.53 3.976-11.582 7.155-17.53 9.691 18.199 1.771 31.57 4.406 37.653 5.747zm-4.68 27.237l3.385-23.676a240.127 240.127 0 0 0-5.731-1.169l-3.059 21.422a1.415 1.415 0 0 1-.575.943l-11.472 8.023c-.27.192-.616.28-.947.243l-34.572-3.929a1.391 1.391 0 0 1-1.176-.973l-6.227-20.5c-1.668-.431-5.949-1.43-9.696-1.43-3.764 0-8.041.999-9.708 1.43l-6.228 20.5a1.388 1.388 0 0 1-1.025.947l-34.572 7.692a1.483 1.483 0 0 1-.306.033 1.36 1.36 0 0 1-.792-.25l-11.467-8.029a1.396 1.396 0 0 1-.585-.968l-3.091-25.072c-1.284.249-2.443.487-3.479.703-.734.405-1.46.809-2.174 1.213l3.281 26.568 16.666 11.675 42.047-9.354 6.207-20.449a1.389 1.389 0 0 1 1.108-.975c1.574-.253 2.95-.382 4.116-.382 1.153 0 2.536.129 4.105.382.528.083.957.461 1.108.975l6.366 20.956 42.282 4.808zm-8.07-4.411l2.992-20.948c-8.439-1.536-20.78-3.394-35.897-4.554-13.647 4.707-25.077 6.108-25.766 6.155l-.797.057c4.353.374 8.454 1.544 8.66 1.605.452.135.804.481.944.933l6.186 20.366 33.138 3.764zm2.303 11.845l-1.404.983-3.779 2.651-4.095 2.868c-.279.192-.621.28-.954.243l-40.746-4.633-2.966-.337a1.39 1.39 0 0 1-1.171-.977l-6.377-20.998c-1.066-.145-2.014-.219-2.81-.219-.809 0-1.751.073-2.817.219l-6.192 20.392a1.383 1.383 0 0 1-1.025.946l-43.435 9.672c-.103.02-.206.03-.305.03-.279 0-.559-.083-.798-.253l-1.578-1.098-4.726-3.307v-.011l-1.91-1.335c.135.43.289.85.441 1.268l-.006.006c.368 1.092 4.028 11.622 11.467 21.929a873.96 873.96 0 0 0 17.057-5.234c4.488-6.917 10.877-13.777 15.418-14.014a51.12 51.12 0 0 1 2.402-.061c2.221 0 4.344.16 6.31.393-1.671-1.517-2.013-3.298-2.256-4.085 0 0 5.793 4.53 13.17 3.584 7.378-.953 11.959-5.204 11.959-5.204s-.021 3.102-3.236 5.503c6.182-1.869 8.739-2.511 10.489-2.511 1.931 0 2.883.808 3.717 1.519.161.129.322.268.507.419a3519.302 3519.302 0 0 1 12.141 9.614c1.936.227 8.075.926 11.943 1.283 2.23.201 4.245.372 6.217.372.637 0 1.233-.026 1.797-.063 2.558-4.88 4.857-10.411 6.808-16.653.261-.96.516-1.928.743-2.901zm-15.034-51.593c-.01-.006-.02-.012-.031-.012a551.624 551.624 0 0 0-9.826-.651 905.6 905.6 0 0 0-13.667-.668 72.95 72.95 0 0 1-1.574 2.225c-2.479 3.355-7.398 9.51-13.704 14.729 8.926-1.6 24.409-5.56 37.803-14.905.336-.238.668-.486.999-.718zm-29.876.926c.377-.471.729-.926 1.044-1.34-3.281.331-6.512.808-9.67 1.408-10.814 2.024-20.801 5.389-29.11 8.837a383.259 383.259 0 0 1 18.54-.455c3.908 0 7.708.067 11.404.176 3.179-3.056 5.861-6.182 7.792-8.626zm3.587 102.085c-4.503-.332-8.598-2.205-11.903-5.477a271.86 271.86 0 0 0-.502-.512 44.25 44.25 0 0 1-4.881.704c-.698.026-1.361.087-2.091.087l-1.083.011-.413-.011c-4.396 6.539-14.159 7.813-14.605 7.87-.403.046-.734.103-1.191.186 5.442 1.491 10.996 2.138 16.474 1.77 5.492-.367 12.627-1.558 20.195-4.628zm-17.4-7.461a45.604 45.604 0 0 0 3.184-.378 138.958 138.958 0 0 1-3.568-3.857 398.441 398.441 0 0 1-1.92 4.339h.243c.658.001 1.378-.071 2.061-.104zm-3.354-78.632c1.827-1.103 3.582-2.366 5.249-3.712a422.33 422.33 0 0 0-7.278-.072c-10.137 0-19.606.415-28.189 1.061-8.61 4.209-13.875 7.672-13.998 7.76l-8.268 5.514 5.679-8.149a52.452 52.452 0 0 1 2.956-3.857c-9.536 1.066-17.477 2.329-23.41 3.422l3.038 24.632 10.453 7.321 33.184-7.378 6.212-20.464c.104-.337.331-.621.627-.793.098-.063.202-.109.315-.14.192-.052 3.51-.999 7.336-1.465zm3.816-18.788c-2.31-.036-4.623-.057-6.933-.062h-.005c-3.39.005-6.787.041-10.189.109l-6.269 2.971c-.005.005-.041.021-.088.048-.942.46-9.174 4.613-16.919 12.021 6.943-3.65 17.146-8.418 29.153-12.115a144.186 144.186 0 0 1 11.25-2.972zM70.251 98.761c3.251-3.225 6.605-5.886 9.567-7.967-11.415 2.651-21.923 6.543-31.128 10.778a360.846 360.846 0 0 1 21.561-2.811zm2.159-9.949a150.122 150.122 0 0 1 11.813-2.796c-5.798.212-11.6.481-17.393.808-3.366.186-6.715.414-10.065.667-1.678.129-3.345.263-5.007.445-.476.046-.942.098-1.418.16-4.369 2.614-21.127 13.134-32.631 26.889 11.179-7.769 30.654-19.443 54.701-26.173zm-30.85 54.197a68.861 68.861 0 0 1-.621-2.102l-5.162-3.612a1.391 1.391 0 0 1-.586-.969l-2.516-20.449c-1.864.999-4.017 2.225-4.592 2.707-.497.409-.875 1.46-.336 6.332.668 6.01 3.712 17.333 5.348 18.979 1.968.347 6.327-.258 8.465-.886zm-3.815-51.36a229.005 229.005 0 0 0-7.051 1.47l.829 3.127a103.93 103.93 0 0 1 6.222-4.597z" id="jkpath95" fill="#2d3136"/></g></g></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="tune" xmlns="http://www.w3.org/2000/svg"><path d="M6.85 2.852h-2v6h2v-6m12 0h-2v10h2v-10m-16 10h2v8h2v-8h2v-2h-6v2m12-6h-2v-4h-2v4h-2v2h6v-2m-4 14h2v-10h-2v10m4-6v2h2v4h2v-4h2v-2h-6z" fill="#fbc02d" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 50 50" id="twig" xmlns="http://www.w3.org/2000/svg"><path d="M9.727 47.556c-.125-.223-.297-2.168-.183-2.087.034.025.171.267.304.537.132.27.282.487.332.482.123-.011.075-1.196-.1-2.454-.331-2.398-1.176-4.435-2.358-5.69-.2-.212-.344-.4-.319-.419.093-.067 1.327.843 1.842 1.359.293.293.735.825.981 1.181.328.474.465.618.51.534.078-.147-.21-9.903-.376-12.701-.074-1.255.063-1.023.61 1.035 1.064 4.006 1.858 7.922 2.342 11.55.086.637.173 1.172.195 1.19.022.016.092.001.157-.034.888-.483 1.524-.667 2.55-.736.727-.048.945.062.35.178-1.15.222-1.99 1.013-2.344 2.201-.315 1.061-.327 2.707-.024 3.434.152.366.037.426-1.067.56-.716.088-.977.096-1.202.037-.356-.092-1.118-.098-1.195-.008-.031.036-.243.066-.47.066-.38 0-.423-.017-.535-.215zm1.974-3.233c.152-.205.072-.41-.204-.522-.225-.09-.263-.088-.437.025-.21.137-.252.43-.08.554.18.13.607.096.72-.057zm1.248.086a.763.763 0 0 0 .214-.203c.241-.33-.352-.622-.745-.366-.406.265.08.785.531.569zm2.288 3.094c-.033-.039.117-.387.334-.775.216-.387.411-.665.433-.618.07.152-.201 1.28-.33 1.372-.15.108-.354.117-.437.02zM8.2 47.092c-.29-.343-.221-.434.14-.182.176.123.321.263.321.31 0 .165-.279.087-.46-.128zm8.649-.145c0-.053.102-.18.227-.282.25-.204.312-.113.143.207-.095.18-.37.236-.37.075zm8.065-.827c-.243-.025-.48-.088-.527-.141-.11-.125-.114-3.043-.004-3.043.045 0 .132.149.193.331.127.38.228.42.31.124.094-.337.065-3.472-.039-4.297-.449-3.55-1.865-6.124-4.342-7.89-1.086-.774-2.653-1.436-4.047-1.711-.764-.15-.522-.224.598-.182 2.364.089 4.167.706 5.847 2.001a11.046 11.046 0 0 1 2.32 2.502c.453.682.64.854.64.584 0-.07.063-.882.139-1.805.679-8.26 2.396-15.1 4.984-19.86 1.86-3.422 5.108-6.817 7.885-8.244 1.397-.718 2.539-.988 4.02-.952.933.023 1.01.036 1.77.307a6.822 6.822 0 0 1 1.363.662c.612.407 1.309 1.004 1.235 1.058-.026.018-.343-.165-.705-.407-2.657-1.771-5.062-1.52-7.12.742-1.108 1.22-2.651 3.53-3.634 5.443-2.828 5.503-4.541 11.464-5.291 18.413-.163 1.509-.282 3.76-.195 3.703.032-.022.266-.52.518-1.108 1.597-3.723 3.578-6.428 5.79-7.908.672-.449 1.612-.904 1.715-.83.022.016-.172.22-.432.454-1.957 1.754-3.248 3.76-4.232 6.572-.938 2.68-1.366 5.588-1.368 9.3-.002 1.741.188 4.385.366 5.101.125.505.08.546-.585.546-.55 0-2.306.138-3.416.27-.414.05-.817.04-1.609-.036-.58-.056-1.129-.119-1.218-.14-.165-.037-.18-.014-.2.302-.01.186-.098.203-.728.139zm2.507-6.725c.294-.11.375-.22.375-.517 0-.63-1.309-.706-1.524-.088-.074.211.13.51.42.616.297.108.413.106.73-.011zm2.369-.052c.277-.222.318-.364.174-.611-.4-.691-1.755-.307-1.428.404.121.266.299.35.738.354.227 0 .387-.045.516-.147zm3.011 6.681c-.027-.05.088-.268.256-.484.879-1.135 1.22-1.544 1.284-1.544.04 0 .056.037.036.082l-.423.964c-.212.485-.445.924-.519.977-.169.122-.57.125-.634.005zm2.446-.596c0-.121.853-.683.896-.59.018.04-.056.209-.166.376-.168.259-.238.305-.464.305-.164 0-.266-.035-.266-.091zm-13.04-.124c-.177-.159-.493-.656-.462-.725.018-.038.248.1.512.309.264.207.457.405.428.438-.075.088-.371.074-.478-.022z" fill="#9bb92f" stroke-width=".078"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="typescript" xmlns="http://www.w3.org/2000/svg"><path d="M49 51h408v408H49V51zm246.669 314.879l19.463-1.702c.922 7.8 3.067 14.199 6.435 19.198 3.368 4.998 8.597 9.04 15.688 12.124 7.09 3.085 15.067 4.627 23.93 4.627 7.87 0 14.819-1.17 20.845-3.51 6.027-2.34 10.512-5.548 13.455-9.625 2.942-4.077 4.413-8.526 4.413-13.348 0-4.892-1.418-9.164-4.254-12.816-2.836-3.651-7.516-6.718-14.039-9.2-4.183-1.63-13.436-4.165-27.759-7.604s-24.355-6.683-30.099-9.732c-7.445-3.899-12.993-8.739-16.644-14.517-3.652-5.779-5.478-12.249-5.478-19.41 0-7.871 2.234-15.227 6.701-22.069 4.467-6.842 10.99-12.036 19.569-15.581 8.58-3.546 18.116-5.318 28.61-5.318 11.557 0 21.75 1.861 30.577 5.584 8.828 3.722 15.617 9.199 20.368 16.432 4.75 7.232 7.303 15.421 7.657 24.568l-19.782 1.489c-1.064-9.856-4.662-17.301-10.795-22.335-6.133-5.034-15.191-7.551-27.174-7.551-12.479 0-21.573 2.286-27.281 6.86-5.707 4.573-8.561 10.086-8.561 16.538 0 5.602 2.021 10.21 6.062 13.826 3.971 3.617 14.34 7.321 31.109 11.115 16.769 3.793 28.273 7.108 34.513 9.944 9.076 4.183 15.776 9.483 20.101 15.9 4.325 6.417 6.488 13.809 6.488 22.175 0 8.296-2.375 16.113-7.126 23.452-4.751 7.338-11.575 13.046-20.474 17.123-8.898 4.077-18.913 6.116-30.045 6.116-14.11 0-25.933-2.056-35.47-6.169-9.537-4.112-17.017-10.299-22.441-18.559-5.424-8.26-8.278-17.602-8.562-28.025zm-65.728 50.094V278.454h51.583v-18.399H157.938v18.399h51.37v137.519h20.633z" fill="#0288d1"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="typescript-def" xmlns="http://www.w3.org/2000/svg"><path d="M457 459H49V51h408v408zM69 71v368h368V71H69z" fill="#0288d1"/><text x="342.219" y="344.544" font-family="ArialMT" font-size="12" fill="#0288d1" transform="translate(-6058.94 -5838) scale(18.1514)"><tspan style="-inkscape-font-specification:sans-serif" font-family="sans-serif" font-weight="400">TS</tspan></text></symbol><symbol viewBox="0 0 24 24" id="url" xmlns="http://www.w3.org/2000/svg"><path d="M16 6h-3v1.9h3a4.1 4.1 0 0 1 4.1 4.1 4.1 4.1 0 0 1-4.1 4.1h-3V18h3a6 6 0 0 0 6-6c0-3.32-2.69-6-6-6M3.9 12A4.1 4.1 0 0 1 8 7.9h3V6H8a6 6 0 0 0-6 6 6 6 0 0 0 6 6h3v-1.9H8c-2.26 0-4.1-1.84-4.1-4.1M8 13h8v-2H8v2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="verilog" xmlns="http://www.w3.org/2000/svg"><path d="M17.282 17.08H6.718V6.513h10.564m4.226 4.226V8.627h-2.113V6.514c0-1.173-.95-2.113-2.113-2.113H15.17V2.288h-2.113v2.113h-2.112V2.288H8.83v2.113H6.718c-1.173 0-2.113.94-2.113 2.113v2.113H2.492v2.113h2.113v2.113H2.492v2.113h2.113v2.113a2.113 2.113 0 0 0 2.113 2.113H8.83v2.113h2.113v-2.113h2.112v2.113h2.113v-2.113h2.113a2.113 2.113 0 0 0 2.113-2.113v-2.113h2.113v-2.113h-2.113V10.74m-6.339 2.113h-2.112V10.74h2.112m2.113-2.113H8.831v6.34h6.338z" fill="#ff7043" stroke-width="1.056"/></symbol><symbol viewBox="0 0 24 23.999999" id="vfl" xmlns="http://www.w3.org/2000/svg"><defs><style>.jra{fill:#f05223}.jrb{fill:url(#jra)}</style><radialGradient id="jra" cx="205.45" cy="208.29" r="225.35" gradientTransform="matrix(.04556 0 0 .0456 2.888 2.88)" gradientUnits="userSpaceOnUse"><stop stop-color="#ffd104" offset="0"/><stop stop-color="#faa60e" offset=".35"/><stop stop-color="#f05023" offset="1"/></radialGradient></defs><title>houdinibadge</title><g stroke-width=".046"><path class="jra" d="M19.97 3H4.03A1.03 1.031 0 0 0 3 4.031v4.135C4.548 6.977 6.563 6.21 8.948 6.21c5.107.003 8.35 3.574 8.348 8.081 0 3.13-1.46 5.485-3.746 6.71h6.42A1.03 1.031 0 0 0 21 19.968V4.031a1.03 1.031 0 0 0-1.03-1.03z" fill="#f4511e"/><path class="jrb" d="M3 17.722v2.247A1.03 1.031 0 0 0 4.03 21h1.837C4.474 20.21 3.49 19 3 17.722z" fill="url(#jra)"/><path class="jra" d="M8.948 8.231c-2.586-.09-4.598.86-5.948 2.264v3.163c.918-2.654 3.447-3.87 5.565-3.85 2.647.027 4.689 2.025 4.7 4.284.012 2.159-.892 3.748-3.33 4.14-1.33.213-3.411-.567-3.318-2.578.046-1.037.854-1.622 1.777-1.58-.905 1.213.293 2.102 1.139 1.921 1.048-.224 1.475-1.156 1.475-1.878 0-.762-.718-1.994-2.498-1.951-2.204.052-3.591 1.639-3.638 3.602-.056 2.468 2.253 4.091 4.622 4.121 3.48.046 5.543-2.24 5.539-5.586-.005-3.029-2.434-5.946-6.085-6.072z" fill="#f05223"/></g></symbol><symbol viewBox="0 0 24 24" id="virtual" xmlns="http://www.w3.org/2000/svg"><path d="M21 14H3V4h18m0-2H3c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h7l-2 3v1h8v-1l-2-3h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 281.25 281.25" id="visualstudio" xmlns="http://www.w3.org/2000/svg"><path d="M196.18 101.74l-52.778 42.444 52.778 40.889V101.74m-136.67 110l-30-18.889v-100L62.843 81.74l47.778 37 96.666-89.222 44.444 27.778v172.22l-55.555 22.222-85.111-81.555-51.555 41.555m3.333-48.889l20.667-19.111-20.667-19.778z" fill="#ab47bc" stroke-width="11.111"/></symbol><symbol viewBox="0 0 300 300" id="vscode" xmlns="http://www.w3.org/2000/svg"><defs><style>.icon-canvas-transparent{fill:#f6f6f6;opacity:0}.icon-white{fill:#fff}</style></defs><title>BrandVisualStudioCode</title><path d="M218.62 29.953l-105.41 96.92L54.301 82.47 29.955 96.64l58.068 53.359-58.068 53.359 24.346 14.212 58.909-44.402 105.41 96.878 51.424-24.976V54.93zm0 63.744v112.6l-74.719-56.302z" fill="#2196f3" stroke-width="17.15"/></symbol><symbol viewBox="0 0 24 24" id="vue" xmlns="http://www.w3.org/2000/svg"><path d="M1.821 4.15l10.21 17.618L22.24 4.235V4.15h-7.692L12.113 8.33 9.691 4.15H1.82z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179H5.937z" fill="#35495e"/></symbol><symbol viewBox="0 0 420 419" id="watchman" xmlns="http://www.w3.org/2000/svg"><g stroke="#fff" stroke-linecap="round" stroke-linejoin="bevel"><path d="M166.95 145.32a93.935 123.23 0 0 1 92.934 3.263" fill="none" stroke-width="18.467"/><path d="M162.92 137.96L44.63 256.25a174.07 173.93 0 0 0 5.705 16.486l123.68-123.68-11.096-11.096zM266.54 144.04l-11.096 11.096 117.16 117.16a174.07 173.93 0 0 0 5.691-16.5l-111.76-111.76zm170.65 170.65v22.193l17.1 17.1 11.096-11.098-28.195-28.195z" fill="#fff" stroke-width="1.963"/><path d="M167.52 273.36a93.935 123.23 0 0 1 92.934-3.263" fill="none" stroke-width="18.467"/><path d="M49.516 144.56a174.07 173.93 0 0 0-.809 2.213 174.07 173.93 0 0 0-4.757 14.344 174.07 173.93 0 0 0-.016.055l119.56 119.56 11.098-11.096-125.07-125.07zM454.87 64.703l-17.668 17.668v22.191l28.764-28.764-11.096-11.096zm-80.984 80.984l-117.86 117.86 11.098 11.096 112.18-112.18a174.07 173.93 0 0 0-5.416-16.777z" fill="#fff" stroke-width="1.963"/></g><image x="21.229" y="20.262" width="378" height="377.1" preserveAspectRatio="none" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAaQAAAGjCAYAAABjSWGNAAAgAElEQVR4AeydB3hUVdrH/+fOpNMF JAFUmivFXtZuIBRRQUUTil1RV3et6Lr6rSu6rg3B1dXVtXeBCCioCASIimLDhkFsgAIJSAkhPZmZ 8z3vjReGMJlMueeWmfc88MzMLaf8zp3855zznvcV4MQEkoxA7sVr01PrUrv4pdZNBNA1AHQRkJ0g ZAcJrYOA7Aipv8/Q6JgUKdBkG0iZISDSDVwSaAfAY3z+/bVSAD56L+mfwA5INAqgSkrUCKA2IOQO IcQOIUU5pNwhNZQLia2Q2OyH3OJBxqaiwk4VzfLlj0wg4QmIhG8hNzDJCEgxJH9DjgfevgGBAyCx vxSyh5DoAWA/CHQH0MEFUOoBbJbAeiHEBiED6wMSv0DgF02In1J2Vq+ZP78fXcOJCSQMARakhOnK 5GrICaO3tE3P8B0kERgkgP6Qop8E+gqgL4Bdo5gEphKAwHpIrIHAjxL4DpCrAo1yVfHsHhsSuN3c tAQmwIKUwJ2bGE2TYsjY33qLQOBwCHk4JA6HhgE08kmM9ilpRQUgvwPE1xD4MiC0r2R67dfFz/eq U1IaZ8oETCLAgmQSSM7GHAIj8td38sN7HAROkpDHATgMTWs15hSQvLn4JLBaQHwqEPgQQiwvmpHz XfLi4JY7kQALkhN7JYnqlDd2w4GQnuMk5AkCOBHAQQD4ubTgGZDAFg1ieQCBj6Dhg7SKmhW8LmUB eC6iRQL8xW8RDZ8wn4AUQ8eVHib9YjCELj4nAOhqfjmcY4wE6iGxAkIsCwDvSel5v7iwa1WMefFt TCBqAixIUSPjG6IhkDtmQw/N4xkKTQ4TEkNZgKKhZ/u1DZD4CAKLIMSik/p3WzF5sgjYXiuuQMIS YEFK2K61p2GTJ0tt2Xebj5EyMArAab+vAdlTGS7VVAI0xSeAd6QQb2uBtIW8V8pUvJwZz9XzM2AG AdpoKqpTh3uEdlYA8gwBdDEjX87D0QQaALlMCLzha5Rz2NTc0X3lmsrxCMk1XeWsig6/YFOWr0Ge JoBzICWNhNo6q4ZcGwsJkFOKT4TUZiOgzSqate8aC8vmohKIAAtSAnWm6qaMHPljWl2bzBGapk2A lKMBZKguk/N3JYHPpMCrHuGbuWj6fqWubAFX2hYCLEi2YHdToVIMKSg7SQAXABgDoJObas91tZWA H5DvCeCV2rrUwg/ndqm0tTZcuOMJsCA5vovsqeDwszd19af4LwbEZQAOtKcWXGoCEaiCwAwB7emi Gd0+TqB2cVNMJMCCZCJMt2dFFnIflJQNh8BEgKbkRIrb28T1dySBlULIpzyBwCsLCntud2QNuVK2 EGBBsgW7swqlvUIer+cSQE7UPWI7q3pcm8QlUAtgtibFU4sKu70PCJm4TeWWRUKABSkSSgl5DTkt 3XSqJuVfJDAiRFyfhGw1N8qxBH4AxJP+hrpnit/otcOxteSKKSXAgqQUr/My18216wMXCOB6AH9w Xg25RklOoEoI8bzw+R5ZNKvnj0nOIumaz4KUJF0+YsyWbL/Xdx0gr5BAxyRpNjfTvQQCkPIdDdqD iwqz33NvM7jm0RBgQYqGlguvbfKmrd2MJrPtNBc2gauc5ASElJ8EIO4/eWD2m+xLL7EfBhakBO3f wfmlR2oCtwI4G4CWoM3kZiURASmwGpAPVLTPeXnFk6IxiZqeNE1lQUqwrs4bW3Y0AoF/QIjT2Vdh gnUuN8cgsFYK3LNPoPzFwsKBDcZBfnU/ARYk9/eh3oIh+WXHaELeIZs8bCdIq7gZTCAsgXUQuK9T oPw5FqawnFxzkgXJNV0VuqJD8zcdDCHvlvpG1tDX8FEmkOAE1gohJp/Yv9vLvMbk7p5mQXJp/w0Z u7mPkP7JACbwGpFLO5GrbS4BiRII/H3xzOw3eZOtuWityo0FySrSJpWTO760s8cv7wQEeVVINSlb zoYJJBAB+bGQ4uaiwpxlCdSopGgKC5JLuvm4/PUZmcJ7NSD/DqCDS6rN1WQCdhKY45f+vxYX9vzJ zkpw2ZETYEGKnJVNV1L4h9JxAuI+9jNnUxdwsW4mQFZ4//E31N/NLomc340sSA7uo2H5Gw7zC+3f AjjFwdXkqjEBNxDYKgVuPbl/9rNs+ODc7mJBcmDf5J61toMnNfVfgLgCgNeBVeQqMQF3EpD4XGja NRyTyZndx4LkqH6RYkj+pouEkFMAdHZU1bgyTCBxCEgIPFvvabx52av7lydOs9zfEhYkh/Th0HM2 95Ye//8ADHVIlbgaTCDRCWwWENcWzcyemegNdUv7WJBs7qncXOnVum66XoBMuZFpc3W4eCaQdAQE xFyfz//n4tk9NiRd4x3WYBYkGzuEjBak0J6WwJE2VoOLZgJMANgJIW49qX+3J9jowb7HgQXJBva0 pyhLeO+QkJPYaMGGDuAimUDLBJYJgSuKZuR81/IlfEYVARYkVWRbyHfouLJcBPCUhOzbwiV8mAkw AXsJ1EOKe3Z07HYvh7mwtiNYkCzinXvx2nRvTdq9EriOw0JYBJ2LYQLxEfgCUlywuDB7VXzZ8N2R EmBBipRUHNcNGVt6hJB4CcCAOLLhW5kAE7CeQB1tqF0yI/thdtiqHj4LkkLG+fnSUy7KbpWQ/wBE isKiOGsmwASUEpBLPBouXji9+3qlxSR55ixIih6A3DEbemhe7WV2+6MIMGfLBKwnsA3AxMUzc96w vujkKJEFSUE/D87fOFoT4lkA+yjInrNkAkzAVgLyv/7MhknFz/eqs7UaCVg4C5KJnTpy5I9p9W3b TBGQf2HDBRPBclZMwHkEVnr8KFg4K2e186rm3hqxIJnUd0PGlO4vPHgdAkeZlCVnwwSYgLMJVEHI yxfP6D7d2dV0T+1YkEzoq6H5ZadLIV/gKToTYHIWTMBlBIQQj6bsrLpp/vx+9S6ruuOqy4IUR5fo VnRa2Z1S4jaeoosDJN/KBNxP4DMhcW5RYc6v7m+KfS1gQYqRvR6zKCXtVQiMjDELvo0JMIEEIiCB LR4p8hcVZr+XQM2ytCmapaUlSGF5+WUDvKnpn7EYJUiHcjOYgAkEBNAlIAKLho7deI0J2SVlFjxC irLbh+SXni0EXgTQJspb+XImwASShICAeC6lsuoqXleKrsNZkKLglVdQeiuAf/F6URTQ+FImkLwE lnkatXMWzun2W/IiiK7lLEgR8KL9RQ1tsyia60URXM6XMAEmwAQMAmsDHjFq6WvZJcYBfm2ZAAtS y2z0M7njSzt7/JgD4MRWLuXTTIAJMIFQBCqkkAVLZnRfGOokH9tNgI0adrPY611u/vq+Xr9YzmK0 Fxo+wASYQOQE2gsp3h4ytnRi5Lck55UsSC30+5D8smM8wvMhB9JrARAfZgJMIBoCXiHxVN7YjXcC kmemWiDHYEKAyRu76QzIALkDyQpxmg8xASbABGImQBZ4vt+6XVFcLHwxZ5KgN7IgNevYvPyNl0EI MmDwNDvFH5kAE2AC5hCQmJ9Zh3PnzcupMSfDxMiFp+yC+nFIQdlNEOIpFqMgKPyWCTAB8wkIjKxJ xyLy+GJ+5u7NkUdIet9JkVdQRvuLaJ8RJybABJiAVQS+9kvvqcWFXTdZVaCTy0l6QZo8WWrLVpU9 JoE/ObmjuG5MgAkkJgEB8ZNPw7Di6dnrErOFkbcqqQWJvHVvF2UU2fXCyJHxlUyACTAB0wn86pf+ vOLCnj+ZnrOLMkzaNaTcXOndLja9zGLkoqeVq8oEEpfAfh7hfW/4OaUHJW4TW29ZUo6Q8vNLUreh 42tCYEzriPgKJsAEmIBlBDZDiiGLC7NXWVaigwpKOkEiMSoXnQol5GgH9QNXhQkwASagE6C4SprU 8ooKu61MNiRJNWVH03Q0MmIxSrbHnNvLBNxDgOIqSREoGjq2tL97am1OTZNmhEQGDNtE6asCosAc dJwLE2ACTEAlAVkGIXMXz+jxg8pSnJR3UoyQfreme57FyEmPHteFCTCB8ARENqS2mJw8h78ucc4m gSBJUS7KHgVwfuJ0G7eECTCBJCHQwyM8i3LHbOiRDO1NeEEaMrbsHt70mgyPMreRCSQsgQM8Hu3d kfllXRK2hb83LKEFaUjBxluExN8SvRO5fUyACSQ4AYGBjQjMG5q/vX0itzRhBSlvbOmVAuLeRO48 bhsTYALJQ0AK8UcpamfT1pVEbXVCCtLg/I2jIfEYgKSxIkzUB5TbxQSYQDABMWS76Phsogb5SzhB Gjxu0x81IV7jEBLBDzG/ZwJMIIEInJc3tuyhBGrPrqYklCCReaQIBOYByNzVQn7DBJgAE0g0AhLX Dc0vTbj18YSZ0iILlEaBjyRk0tjsJ9p3jNvDBJhAVAQkpBy/uLD7jKjucvDFCTFCokW+Bsg3WIwc /KRx1ZgAEzCbgIAQz9IyhdkZ25VfAgiSFPoin8DxdkHkcpkAE2ACNhHI1AKBuYmycdb1gjS0oOz/ AJxn08PAxTIBJsAE7CbQ1ePV3kmEPUquFqS8/LJzJXCX3U8Dl88EmAATsJnAwVLUv+R2c3CPzRBj Ln5o/qaDIeRbABJ2k1jMcPhGJsAEkpHAH3oPrNTWlkxb6tbGu9LKLu/sDfsgRfsEQB+3gud6MwEm wAQUEJBC4NyiGTmzFeStPEvXTdlRKAmkaLTxlcVI+ePBBTABJuAyAkJKvKDPILms4lRd1wlSudj0 LwDDXMiaq8wEmAATsIJAG6kFZrnRyMFVgkQ+6iTkX63oUS6DCTABJuBaAhL9Aqhznc871wjS0HM2 99aEeIEdprr2K8IVZwJMwEICQmBMXsGmGy0sMu6iXGHUkHvx2nRPddpHEDg87hZzBkyACTCBpCEg G4UUQ4oKc5a5ocmuGCF5a9IeYjFyw+PEdWQCTMBZBESKFGKGbpnsrIqFrI3jBWno2NIxHII8ZN/x QSbABJhABARkDlI8z7lh06yjBWnImNL9IfF0BMT5EibABJgAE2iRgByVl192bYunHXLCsWtItN9o O8reBztNdcijwtVgAkzA5QTqhSaPK5re/UuntsOxI6RyUXo7i5FTHxuuFxNgAi4kkBaQ4tVRo0od G8DUkYI0JL/sGAlBXrw5MQEmwASYgEkEhMRBtZnifpOyMz0bx03ZDb9gU5a/PvAFgANNby1nyASY ABNgAlIKeeqSGd0XOg2F40ZI/nr/AyxGTntMuD5MgAkkEAEhpHh2RP76Tk5rk6MEacjYjcMBcZXT IHF9mAATYAIJRqC7X3gedVqbHDNld8LoLW3T0xu/BbCf0yBxfZgAE2ACiUhASoxZUpgzxyltc8wI KT29cQqLkVMeC64HE2ACyUBACPnYiRN+6eiUtjpCkIbll50C4AqnQOF6MAEmwASSg4DITvN7pzml rbZP2ZFNfE0GvuGAe055JLgeTIAJJB0BiZGLC3Petbvdto+QajPEP1iM7H4MuHwmwASSmoDA407Y MGurIOWNX3+IRMBV8TqS+qHlxjMBJpCoBA6oyRB32N042wRp8mSpwe99AhApdkPg8pkAE2ACTEDe OHjshkPt5GCbIC0r2XQlII+zs/FcNhNgAkyACewi4NWkeEIfLOw6ZO0bWwRpZH5ZFynkPdY2lUtj AkyACTCB8ATEse9/V3Zp+GvUnbVFkBqaxKiDumZxzkyACTABJhATAYl7cs9aa8vfZ8sFadi40qMA 2KbAMXUQ38QEmAATSBICAuiipabfZUdzLRYkKQIB+R8AFpdrB1oukwkwASbgTgIC8uph+RsOs7r2 lgrDkPxNFwHiWKsbyeUxASbABJhAVAQ8AWgPRXWHCRdb5qnhd48MPwDobkK9OQsmwASYABNQTEAI eVbRjO5vKi5mV/aWjZBq0sVNLEa7uPMbJsAEmIDjCUgpHjjyCmnZXlFLBGnEmC3ZEPJmx9PnCjIB JsAEmEAwgQM7lJddHXxA5XtLBMmX0vhPAG1UNoTzZgJMgAkwAQUEBG63KkSFckEafk7pQZC4SAEm zpIJMAEmwATUE9gn1Z/yV/XFWGB+7feARkdeKxrDZTABJsAEmID5BITENXnjN+9rfs575qh0hPT7 Jthz9iySPzEBJsAEmIDLCGSJQODvquusVJACAZC/OstMy1XD4vyZABNgAslKQEp5xfD8Tb1Utl+Z IA0pKD0ZwDCVlee8mQATYAJMwDICqX7NTwFVlSVlgiQgbQ/2pIwaZ8wEmAATSEYCUpw/ZOzmPqqa rkSQ8saVngSIIaoqzfkyASbABJiALQS8Aj5la0lKBAkBOdkWVFwoE2ACTIAJqCUgxflDz9ncW0Uh pgvS0PzSE3l0pKKrOE8mwASYgCMIeOFRY3FnuiBB4FZHIONKMAEmwASYgBICEvK84eM29jQ7c1MF KW/8+kMkMNLsSnJ+TIAJMAEm4CgCqb6AuMHsGpkqSPB7yL0E7zsyu5c4PybABJiAwwgI4PIR+es7 mVkt0wQpd1zZAQDGmlk5zosJMAEmwAQcS6CNT3j/bGbtTBMkLYAb2WedmV3DeTEBJsAEnE5AXntc /voMs2ppitPT3LPWdhCQl5hVKc6HCdhNwOsVaN9WQ1amQFaGQEaGhox0gdQUAaEBaali19x0Q6PU j1OdG30S/gDQ0CDh9wPVtQFUVUlUVgdQXRNATa20u2lcPhMwk0DnLOE5H8BTZmRqiiBpqekTAcnx jszoEc7DUgIkLL3396JvrxT03T8FPXK8yNnXgy77eJTUo7pG4tdSH9Zv9OGXDT78vK4Rq39uRFV1 QEl5nCkTUE1AAtcD8mlAxP1rK24DhNxc6fV0LfsZwH6qG875M4F4CXTqoOHQAWk4dGAqBv0hFQf0 NOU3WbzV0gXqu58asXJ1A75YWY/NW/xx58kZMAGrCAjg1KKZOQviLS/ub6O3a9mZksUo3n7g+xUR SEkROKR/Kv54eJr+v3t23I+8kpr27O4F/R9+StN0/MZNPnzxTQM++7oeK76pR31D3D8+ldSbM2UC REBKXAUgbkGKe4SUl1+6FAK53C1MwCkEaK3n2CPSMfiEdBx/VLpTqhVXPZZ9WocPP6vDxyvq9fWo uDLjm5mA+QQCwu/pVzRr3zXxZB2XIOXllw2AkCXxVIDvZQJmEKCR0PFHpWHw8Rk48ZjEEKGWuJAw LXq/Fp98WY/GRh45tcSJj1tLQEDcXzQz+2/xlBqfIBVsfBQQptqhx9MYvjf5CPQ5IAUjB2fgrFOz kq/xAN4qqsE7i2vww5rGpGw/N9pRBLZ2kuXdCwsHNsRaq5gFadSo0syaDGwE0CHWwvk+JhALAbKM yzsxA6OGZ6Jfr5RYski4e0iQ5i2qwdIPa1FXz6OmhOtglzRISHleUWH3V2OtbsyCNLRg46US4plY C+b7mEC0BPbt4sGoYZkYdybvMAjHbtbb1Zi7qAYby3zhLuNzTMB8AhIfLC7MoWjhMaWYBElKeeCM udULXp1TeQDtq+DEBFQSGHRQKkYPy8SQE03bEK6yuo7J+73ldZi7sBpfr4p5BsUxbeGKuIOApkE+ PLnTjQMOSv93LDWOWpBIjAB8T4XNW1iDp1/bCRalWNDzPa0ROO7IdH1a7pjD0lq7lM+HIfDNqgZ9 xFT8UW2Yq/gUE4iPAE2fP35fZyOTvwsh/mV8iPQ1FkG6F8AuSwoWpUhR83WREsg9PkMfER0yIDXS W/i6CAjQxtt5C6t1Cz3JExsREONLIiXQe78UPDlllxgZt3mFEFHt8I5KkKSU5E9lr4lpFiWDP7/G Q+CUY2lElIXDBrIQxcMxknsfeqoCbxfVRHIpX8MEwhJoQYzontOEEPPD3tzsZLSCdCqAkAWQKP3v 5Z1s4dMMMH9snQBNyZHFHE3RcbKOwKofGnTLPNrTxIkJxEIgjBhRdjOFEFGFJIpWkF4EcEFLFSdR eviZipZO83EmsAeBA3unYPTwTJw6OHOP4/zBWgLkmohMxskbBCcmECkB8gP59INdWru8vRBiZ2sX GecjFiQpJf3VqDZubOmVRaklMnzcINCxvaabbp9zuns3s1IYCWMdJjNDoLau6XNKCnaFojDa65ZX GimRVd53P/ImW7f0mV31pC0Y40a30Wc2WqnDJUKI51u5ZtfpaASJhl7Td90Z5g2LUhg4SXxKCGD8 WW1w6bi2jqZAISHWl/qwcZMf5OR0yzY/tu8IoKIygIqdgV1CFK4RXg/QsYMHnTtq6NTRg25dPdi/ hxf75XhBZuxOTrSPqfCtamzdHtV6tJObxHUzkUAUYkSlLhJCDI+0+GgE6U0AoyPNmEUpUlLJcR15 Vrj1Guc59aA/urRP59vVDbr7nTW/+pT7hyNh7pHtxYADU3BQ31TdiKNnjvO8kD8/sxKvzK6KSICT 4ynmVkYpRgawbCHEJuNDuNeIBElK2RHA9nAZhTrHohSKSnIdoz+8Z52a6Shfcx9/UY9PvqjDFysb 9BGQE3qka2cPjjg4DUcflgayNnRSuu/RHSj6gA0fnNQndtQlRjGiql4jhHg0kjpHKkgUnvzZSDJs fg2LUnMiyfP5vDFtcMlYZ0zP0R/UpR/V4qtvGxwfW4g8lx91SBpOOCYNp+Y6w+CDnLjSd/nnX3h9 KXm+wbtb2r6thosL2kayZrT7pt3vioUQg3d/bPldpIL0NtmUt5xN+DMsSuH5JNpZp3hYIKuxhe/V 6kHu3BqmgcSJggtS4D4nxHZ66fUqvPh6JU/jJdqXNkx7sjIFJo5vF6sYGTnvK4T4zfjQ0murgiSl bA9gR0sZRHqcRSlSUu69zuMBLhvXDgWj7bOeW7fBh/lLavQpJjJASKTUrq2GYSdl4KqL2tnerH8+ VI73PmYzcds7QnEFTBIjquWVQognW6tuJII0HkDM7sSDK8CiFEwjsd4POzkDt/zZPqMFGg3NXViD L1bWJxbYEK0ho4hDB6TqXi3sXG+a/U41XplTpVsehqgmH3I5ARPFiEgsFEKMaA1JJII0E0B+axlF ep5FKVJS7riOwoVfPLYtzjnNnlHRzHnVmLeoGmWbk9NEOWdfjy5M+WfYw5+e0keeqdB/DLjjieVa RkKA9tZdPiHuabrmRXUQQoT1nNCqIAUCcqYQ5gkS1ZBFqXk/ufOznaOiFwsrMXt+DaqqE2taLtYn gf6AnD0yyzYjkjcX1GD2/GqOwRRrBzrsvusuax/vmlGoFh0phPgi1AnjWKuCNOutquvHnJ71kHGD Wa8sSmaRtD4fWiuiX0/n2vCr/IXCSsycW+14Sznre6WpRK9XYMxpmbjiPHvWmZ54aSdef6tVhy52 4eFyIyCgSIyoZE0IEdbPPHnvDpu2+f5y7uqfGo/PO8nc4Gh/6JOCju09+OTLxJ/zDwvYZSfJ0uvZ aV0w4EBrvQ2QEP3ffeX63iF/cs7ORfSkBAJAyfeN+tpOba3EkYdYG0vqqEPT0KG9B6Wb/dhZyaPX iDrNQRepEqM/3bK14sWnMqesWnVnfILUa9Ckh0s3+Tuv/qkRLEoOenJsqArtKbpuIhldWpdmzK3C P6aU47Ov6sFCFDl38rNX8kMjXn2jGo0+icMHWSdM9GPzrFOzUF0r2S9e5F1m+5WqxOiav28lLyjp dahatGbV1F/DNTTsCGnI2M19hJR3UQbk14tFKRzKxD3X94AUfVOclc5Q58yvxgOPV2Dph3U8PRfH o0UjppXfNWDGvGpoAjjYQj96Rx+ahvbtPPh5nU93PhtHM/hWxQRUidH1d2zDqh9+30wtsGltydQl 4ZoSVpB6DbxxrADOMDIgUfplgw+nHMfTdwaTRH+lX7p33dwR/XqnWNLU5Svq8PgLO/HGuzU85WMi cRpdfvltAxYva9o71L+fNVOuB/VNAVkA/rYtgJ/WsZcHE7vUtKyuubQ9Ro8w3yMIiRH5iAxKaWtL pj4d9Hmvt2GNGobkl84SAmOa30V7H26/gdzbmZvY0MFcnvHmRhswrTTnfvQ5EiJnLoiTFdthA9NA 01H7dfcip5sHbTI1tMnSdMwNjVIfBZBn8A2lPtAG3a9K6rH2170CLMfbLabcT/uYRg/PwinHWec3 j4wdyOiBk3MIkBidaY0YUaP9XunvuqCwZ4t+UVsUpPx86dkuyrYCCLnbUZUokfnof54Na6runN5M 0JrQegMFzjvpj9b8saJ1ohdmVoH+qDsppaUKDD4hA8NPzsAhA2IbUWwvD+CDT+vwzmJn+oEbNSzT 0nXBjz6v04MB0pogJ3sJqBKjG+7YhpV7jox2N1SK/MWF2a/vPrDnuxYFKS9/w3EQ2kd7Xr7nJxal PXkkwieaXrnyAmtMht9bTt4VqvXwD05iRzvUzz29DS44t42p1fr863p9Ayn9UXZS0jToa4QTzja3 veHa+NyMptAW4a7hc+oIqBKj2+7djk/D/dgQeHLxjJwrW2pZi2tIvQbedJEQGNLSjXSc1pNUrCnR vDMthn7KJuHh8Jt+jqboLjjXGu/cT76yU18r2rzFOTbcNCKiP8r33baP7prHbMA53bz6iOsPfVJR XRvAxjJntJ0s8mh9icJy0B6zvr3UrxfSKJy+4zSlSdF3OVlH4M8Xt1MSDqZVMWpqYoe1JVMfaam1 LY6QhuaXLpACEUX645FSS3jdcZz2FNHUDXleUJ30Hf3vVDsmDhG1lzaTjh1tvZcDGimR/z0aOTkp 0ZoC/YK2Kt37nx1YvIzjLVnBm8SIPHqYnSIUI71YKf09lhT23BiqDiFHSLm50ivaVP0XQEQT5zxS CoXWHcdGDc/EnZM6os/+6n8VP/x0BV6aVYXKKmdsmKTRwLgz22DaHftYuk/HeDIoSuzQkzJ0I4kd lQE4ZbT4/c+NmPVONVJThCUboGmt0uMR+KpkD4ssAxO/mkTACWJETdEgVqxZNW1lqGaFFKRex151 tJDy6lA3tHSMRaklMs49ftn4tpg4Qf16EVnO0eZWChXuhETesmmt7N93ddajtNpdpwN6pmBEbia6 dfVie7kfW7fbL9iNjdBHbr+W+nGyBRFsDx63vWsAACAASURBVOmfqntuIevE6hqewjP7mVQlRpOn lmP5iihH+EJsXVsy9a1QbQwpSH37T5oAgWGhbgh3jEUpHB3nnOvYXsOV57eDFRtdyWKSgrrV1tn/ R4aEiPZVPfqvziAXN05LfQ5IwWl5mejU0YMt2wIor7BfmNat9+mjWlpfG/SHiCZMYsZKJvW0zWB9 qR9ULidzCKgSI4qJ9cEnMRnoZK0tmfpYqNaFXEPKKyibC8hRoW6I5BivKUVCyZ5rjjksDffc2kl5 4bQ2MnNuFTY5wGiBhIj+0N9wuXXrImYApvW2eQur9T1NZuQXbx4nHJ2OO28yf/9hqHqRN/cXX68K dYqPRUHgTxeocYIcb4DGVCm6zi/M3tK8KSFHSL0GTnpYADGvfPFIqTlmZ3ymxcxbrwm5rczUCj79 WiWeea0SVQ6YeqHQ3/+7vwsorLrbElmbjh6RpW++JWG321np+tKm0VJ6qoaBikdLhw5MA0XI/ea7 BvicYYzotscHThUjAtkoRPG6kqk/Noe6lyANz9/USwp5W/MLo/3MohQtMbXXTxzfFpeOU2vSvWRZ LR5+Zifo1e405MQMkH8uFRZFVreN3PzQVGN6uoYNZfavsaxYWY9fNvpwyrFqrTIP6puqm+GvXN2I Tb+xKkXz3CkTo3+bE7peCPnz2pJpxc3btJcgHXDwDacC4tzmF8bymUUpFmrm3pOWJnD1Re2Vxy4i bwsPP70Tv2219w8HucK59rL2utFC1857Pd7mwrU4NxqV0BoLWb+tXe9DXb1963L03SaHrRlpAqr9 4tEot6IyALL+49Q6AVViNOVxMs+Pac0oRKVFw9qSqS81P7HXN7b3oJsmAji2+YWxfmZRipVc/PfR lM+F57bF6UPNd5wYXLsHn6jQg+YFH7P6PcVposXb8We1Qbcuez3WVldHaXmDDkpFwag2ujB9v6YR ZBFnRyKHrZ99XY/ynQEce4TaKdE/Hp6u7xejDbycWiYwcUJbFIw23+MGidGCYlNnPjpflP/gA8XF e8ZH2suoIa9g43JAmCZIBjo2dDBIWPOad2KG8vUicoY7Y16VrdMpZC1HfvdIkJI1vTK7Cq+9UWXr iClnXw/yz2ijIuz1Ht1Khh5Pv7rTEVabe1TMAR9IjGhfndlJgRjpVfT40X/hrJzVwfXd46ckOVSt FVX/BmD6LkkeKQVjV/ueHkrVgfTojyB5bq6qtmfaiJydXj6haR8VbTBN5kR7eMjlEfXEdz82gmIg WZ0qq6Ue/ZnqQF7RVSUa9Xfr6sGOioDt08Oq2hhLvm4TI72NQi5vvkF2D0HKOfjKgwBcHwuQSO5h UYqEUnzXkPHChflqjRfuf2yHvpM/vprGdnf/vim4dFw7XH1RO9CGUjsTTR9R8Luff/FhQ5kfASn1 zZ121YmE4PwxbdDogx5M0w5h+mZVA35c68OQE9QZPPTaLwWnDs7E9h0BikRqF27HlKtKjMizyjtL TJ2m24OZEFi7pmTaouCDe0zZ5Y0tPQ8SLwdfoOL9iNwM3HyV+ebHyR66QtUGOOMZoCm619+2xw9d v14p+nTQaUPUrocZbW3p1fDYTYEEySlp89Q2S9MNSM4bY/7USfOyWvtMDmxnvV1tS+j3lBShj2DH nBbz7pHWmqeff3lWFZ6fWRnRtYl4kSoxeuSZCt3PokpmUorFSwqzhwaXsccIqffASRcBOD74AhXv KaTx5q1+0EY7M1Oyegnvso8Hl09ohzNPVfflnzm3Gv99YaflfugO6OnVR3yTrmwPEiW7Erk9euqV nfr+KtqP01KimE7kk418wdEIhabT7EpHHpKGC85pq6+30FSelYnaTgYPZAlI9VCVaOqW9iuFDXmg qnCb8724oK0+VWt2NawQI6qzEGi/tmTqA8H133OEVFBKw6c9FCv4YrPf80gpfqL0B2/a5H3izyhM DnZEcqV1IfJArvoXdphm66dKvm/QfynG6o2a3DSRqfa4s+wfMVE/vrmgOuTIrjUO8Zy3IuAjjd4L 36pC6WZ7tx3Ewymae0mMzj/H/GfKKjEy2iok9i8qzPl112fjDb3mFWwsBUR28DHV71WJ0pz51Xjs +cQOl2yFJd3N/9ymx8pR/RwY+dOUF5luF4xWN9ozymrt9YH/7sDC98yZQ9+3iwdnjshCwSj72/XQ UxV4u6imteabep6CAF51oZrQB8EVjSYMQvB9bnqfKGKkM5cYubgw512D/64puxMn/NLRG/DcbZyw 6lXV9B1t1mvbRkOihkomx6g3XKHONxv5orvlX9vx68aWp6fMfkbox8kjd3dW7pamtXrTH2zyTk7P plmJPFiv+KYeRR/U6lN5FIPKrkRulAb8IVWfygs3/Whm/Wi9jb6LZHBxxMHqpvDyTsrAth0B/Jig xg6qxOh/L+3EnPnW/kjRny8hvlpbMnW58aztEqQDD/rrURC41Dhh5SuLUnS06aGk0BGqEnldePyF naD1EKsSmS3/5RJ1AhtJO2hK6//u367UcovMo8kwYulHTUYRqr0ctNTunH29GHx8Bsj4wMrNpt+u brJKpLJVJRJcenLJ4i+REhnKXKTAgpbEqPCtaptQiQ1rS6bONQrfJUi9Dp40EsAZxgmrX1mUIiNO bkFUrkfQw0lB9KxMNNq74jz1cZlaahO1+dZ7t+um0i1dY/ZxcpRKI4YPP6unxV0c2Nseg42DD0rV nbeSAYJViUZlbxXVIC1NA4WcUJHIBD4zQ8Pn31jXLhXtMPIkMbpkrPk/Qu0VI2qdrF9bMu0Zo527 BKn3oEkXmOkyyCggmlcWpfC0/nJJO6WL/HdOK8f8peasmYRvye6zfQ9IsSykwe5Sm96RR3ISom+/ t9YCLbgeFPPo4y/qdXGiUOoUE8nqRKO01FSBL1ZaN6Kg+FiffFkP8rWoKs4STYt2bO9ByQ+Nlo72 ze6/xBUjIiXarS2Zep/BbJcg9Rk46RoA/YwTdr2yKO1Nnhb6aUGYFsVVJLJQuu/RHVi52ro/SEY7 aPqxn8Wjg+dmkBCV61M6ofYSGXWz8pWixH74WR2+WtUACoZn9aZf8o9nbFy3st0kghSm5OjD1Kwr 0QisXRtND3hIG2ndllSJ0bPTKzFjrl3TdHv0QlrvQ/76xNpvH9Qrs0uQeg2c9A8Anfe41KYPLEq7 we/X3YsJZ6nzETb7nWrQ2okdsXbItFvFBund9PZ890JhJf7vvnJ9zcQOLwZ71ib0p81b/Hj/kzpQ yIWMdIH9e1jnFql9O49pVoWhWxf6KO2RIiexZDWqItEPnjOGZeplbCxzj1m4KjGiH2SvzrF2Wj5c v2oy8Maakmnr6RpdkJp82FVOBcQugQqXgRXnWJSAwwel4rF7OiubZ6fQ4k+/at8ud/rCDein3tqM /O7d/sB2fP51gy1eC2L5vlD8n/eW12HVj43IzBCwwl8f+Yhb9UOjLXt5SCjeXlyL9FSh7Hknwftt WwA/rbNvijbSZyH/jCxMnGD+uiqJEX0fHJWE9t6akqlfU510Aeox8KoDpMCNjqokoJvdqvDo4AaT 8NzjM3D3X9WFGievC9PftPfBJFdH7dtqyh676W9UYfK0cny8ot62EA3xNo42epJF3k/rfPo2hpxu akdM6ekCxcvNinkTXeuNdSUKRKgqIi15hSfvEbSu5NREYnTlBUkiRmTWAJSsLZm6lPpDf7r98PcF 9nDa4Ji+MmJwmD21Y0QSdeLmWfJQoNJb9z8p6qNNf3SMB8vrgbJf/TPnVYOmIrdud8/0jMGlpdeP Pq8D/acfKqOHZYJc5qhIJx6Trlv92bm29uTLO1G+w6/kjzIxu+L8droFnhN94CWbGFF/aEAf41nW BUlqYn9h3ZYTo+yIX5NJlFQ9kAbsa2/fhlU/WG+8YJRvvHbsYP7sMDkSJQ8dm7YkjhAZvIzX4o9q Qf8piuqo4Vkg7+dmp306emwXc9oXQ/14x40dzW6enh+53UlPE3oIFSUFxJCpqu8+OaB13DRdEB8p sb/xUf+r0HvQjWcC4hTjoBNfk2FNidZUVMwbU3+SJR25VdlQZp73gXieE/pjkD/KHF9cNBp64L8V IH9zZLGVDIlCXsxfUqOviZgdnHDRB7Uod4BFGnkJ+fDzemiK9mmRWTgZcnz6pf17lc4ckYmrLzZ/ Y7grvKELYG3J1Ifoe6uPkITUekp9b7Ozv8qJPFJS5RKEetSJfv0aTJjCp82VJLQ//2JCZs5+9Fus 3btLa7CguAan5WXihsvN/4PWYsEWnfh5XaPuZd7nB+iPttmJ8iTBe+H1Sj3on9n5R5IfRTy+5lLz +84VYtQEKCc3V3qLi4VPHyEdMOCma4RA70jg2X1NIo6UKKYJuc5RkWio/uQr9lnStdQm2ogZT7hl cm1EFkO0sZQTdN9t5GFjZ6XEMYfHt6eHhN4JIySjX/1+6KMYVcYOtFcpI03Tpwgrdlr7PJEYXXtZ UosRdbOW0rby6Z+/nbZTN3ESQvY0Ot8NrzRSomiGZicydCDLLyuT7groTDVi9NQrlfofbSvbw2XZ R4AMEd54txpTHt9hXyUUlkzGDuRdQ0UaNTwTz0ztAnKlZFVSJUZkPetEg41wXGUA+9F5w+a2R7iL nXhu3qIaUOwOs5OVokTid+4Zarwv/PupCpCTVE5MIJEIvPZGFR58wvzvvcHooTv3gdlrckbewa8q xcjOvYXBbYzmfUBqetgjbdSoUpqYNX9yNpraxHgthUhwqyjRnLFheh5j81u87Z8PlevOK1u8gE8w ARcToHUzitOlKt11c0cMO1mN1wiqM4VZUTFNRyMjN4oRMZEIdKNXrTrTY2lAPrMfIpWiRNNpZidy B3PdZe2VLNBSXW+6axve+9iejY1ms+L8mEBLBChkxgXX/qYbtbR0TTzHb/lzB9AoxuykKiCpm8WI GAsNXejVK4Xs7OQ9SJE8ECRKlMz+1WFMpz3xkjmRZ7MyBSaObwearzY7kbXZ7Hersd7CgHpmt4Hz YwLRECjb7Mdjz1cgINVY4NHfE/JcMdMkJ6QsRmF6V4ocOuvVAgFXj5CMJjpdlLp29mD8mWqcpJIY Pf3aTlBUUk5MIF4CNbXWWprFU18yB//PsxXw+SQorpbZieJ0kfd18vsYT1IlRq+/Ve3aabpmPHXH 3l4BdEmUP2NOFaVuXTwYO1qNGJFVFXnr5sQEzCIg3aNHu5r8+Is7dR91tLnc7ERRWj2aiNlyjdw9 me36jNpIYmTW7I3ZzKLPT+xL93ghRQe4fc4uqPVOE6Xe+6XgySlqonok1gMZ1In8lgnEQID2pdXU Slx+nvmRVcnVkNeLqEcjpxybjr9f1yGG1oS/JfG++1KHpEnR9CZ88911lkRJhfUdrSlFY+jQr5c6 MaJFzMT5deSu54tr61wCtNVh2pNqzMJpI3c0338So9tvMN8XX+KJkf486aA0IPEEiZpntyhRBM7H 71MzMiKXIG4173TunzKuWaIQeGdxDf4xpVxJcyL9UapKjMgNWIL+EG0aIQkI8yVcyaMQfaZ2iRIF 1vv3nftEX+EI7qBpCbftwo6gWXwJEzCVAIXquO4favYqkSiF8+hysqKRkRN9UprYaem5F69NJ08N 5jtSMrGW8WZltSgdc1gaptyuRoz+99JOR7uRj7ev+H4mYCaBku8bcOmNW5TsVaJN7aEcotL3/x8K pukSXIyaur06q4NXAubv/jTzqTIhLxIlSqr3KdHDeM+taqK8PvxMhZIvlgl4OQsm4FgCFMLi+cIm /3dm7/8zPIXTd5OSqu9/UogRgFQEMryQSHdosFhTH3LVovTFynplYnT/Yzuw6P1aU3lwZkwgWQiQ B+9HnlWzgdYQueUr6pR8/5NFjOhZDABtvBCIz1e9i55qlaJkeHUwG8fkqeVY9im7AjKbK+eXXATI EzptoA0EpOk+JEmUDGEyk+qbC2rw2PPJs8fQ70EmBehL6DWk5g+IKlFqXo4ZnynC66df2R/N0oy2 cB5MwAkE6A88xVdS9QPSrDaSGJGAJlMSgUAaCVLSjJCMznWDKN04eRu++a7BqDK/MgEmYBIBMpuu b5BQ4dXBjComoxgRN02KtmRll24GRLflQaLk1OHw9XewGLnteeL6uosAbZ+g/XxOS8kqRkY/0Agp aRMtGHo9wJUKwkzECvWKm7diza+Nsd7O9zEBJhAhAdrP1+iTuGSs+a6GIqzCHpeRk+Rkm6YLBiAR aJPUgkQwCt+q1pnYLUr0ML65sBrr1vuC+4jfMwEmoJDAK7Or0NAgbf9RSt9/w3xcYXMdnbUAPCRI zvh5YCMqu0WJHsbpc6uweYvfRgpcNBNITgL0/acwFuG8L6gkw2K0my4JEq0jJX2yS5ToYXx5dhW2 lbMYJf1DyABsI0DT936/NH3zfGsNYjHak1DST9kF47BalOhh5MB6wT3A75mAfQTI0Ims71TELgrV Khajvanw6KgZExIl8hmnOrEYqSbM+TOB6AksKK7FPx9S4yk8uDb0/f/fy+r/zgSX6Yb3JEgujA+p Fq1qUWIxUtt/nDsTiIfAex/XKRUl4/tfV58osbrjob3nvSRITZ4H9zye1J+yMgVy9lU3m0luRkYO yUxqxtx4JuBkAocMUOcvgL7/+WeYH2rdyTwjrZu6v7qR1sBh15EYTRzfTolvquCmUuRJEWR2Hnwu Gd5npFPrOTEB5xG47rL2yr//FBKdEsc2293/EvCzIO3mAavEyCiS9j4JAcyc17QXyjieDK8eXr1M hm52VRszMwQun6D+x6gBhUXJINH0KqBV0Z8FdiUNWC5GRldccX47x/rUMurIr0wg0Ql0bK9ZKkYG TxKliwuSfiuogQM0Qkp6d9JWj4x20f/9DbkuSfEKHr43BxPmcy0vCIehw6eiIdCtiwdjR7dRPk3X Up14pNREJiBkJQlScvk4b/ZU0DDdijWjZsXu9ZEeSq8XePpVtjHZC06IA7SJkRMTiJdA7/1S8OSU zvFmE/f99P0nv3rkyihZk9S0eg0yuUdIVs4Zt/agjTuzDSZO4OF7a5z4PBMwg0D/fs4QI6MtNFPi 1JAYRh1Vvnr8qNEgkncNyQprmmg7kESJLPA4MQG7CGRkJL4F5CEDUvGfu+0fGTXv42QWJQ2o0gSQ lNuFnShGxsNJ0SztcvRo1IFf3UugPs64jhr9VUjgdPxR6Zh2xz6ObWGyilIDtNqkXENyshgZ35Kz R2aB/jAkc3wUgwW/RkegsZHX11oiNuzkDNzy5w4tnXbMcRIlSkm1ppRVvUOTkOodNzmmmwFVYvT6 73GVzGzqmSMy9fqmpyX2L1YzmXFeTKAlAqOHZyoTI4pAa3YiUco/I8vsbJ2aX13x873qvIDY4dQa ml0vVWJ0273b8elXTdbzNN1mZiI3I5QK36pC6WYOUWEmW84reQgUjMoC7flTkcgZK/m/o2SMbMwq xwgcakQiMCtfB+aj65AmZHIIkhVi9MRLO6FipESi9OIjXXFQ3xQHPkdcJSbgbAIXntvGEjGi6bWX Z5lvtk2ilAQjJX2mToOQCT9CskKMjK8kiZIqV0CP/qszjj5MndNHow38ygQShQBto7gwX81Witsf 2D0yMniRbzoWJYNGNK9NAyNNAluiuc1t11opRgabJ1/eielvmv9LifK/99ZOGHJChlEUvzIBJtAC Ado+QdsoVKS/3bMdy1eE9rqmUpTMXhJQwSa2POVmuk8LaFpZbBk4/y47xMigQh4XVPxSovxvu7YD Rg3j8BUGa35lAs0JXHNpe6j64339Hdvw+dfhPa6pEiUSWTLOSMC0ldrkFVLobxKtgXaKkcGSHkpy B2L2Qiflf93E9qANjDPnJp+ncIMvvzKB5gQ6ddBwwTltlfmlu/KWrfh5XWPzYkN+pu8/JcNXXciL Yjh47WXt9bso5HrCJCFLqS3erBp/WU2CzQA5QYyMB4UWOn0+4PLzzJ/HvuK8dsjK0KDC5NSoP78y AbcQ+EOfFDx2jxrvCxTldfa71Vi/0RcVDhIl8lFp9tRhoomSDDQtHWnz5uWQzCaM1DpJjIwnd8bc KvzvJTUOMcj31VUXqjFnNerPr0zA6QTI+4JKMZo+typqMTKY0fS9ijVlEqVEmb4T0DYRLyNM2gYD nptfad7Y2LdjZjuC9xnFmi/tI3jkGTWO1c85PQvU9pQU3kAba//wfe4lcFpeJu66uaOSBtDI6OnX dmLzlvj2AKoUpTOGun9NSRMB3ZZBjxgrpVgvhDxQSY9alCn9QSbPBmanux4q37XpNd68ac63vkHi 5qvMd11CbScXZLPnV2N9aXTTCvG2i+9nAnYRoDhGKqbDqT1vLqjBo89VQJrkickILWP29N31l7fX 16oXFNfa1Q1xlys0/EqZ6CMkoQXWx52jjRmoEiPagf3+7zuwzWoePTQkcioSjQ6fe6gLDh/Ee5VU 8OU8nUWAjIVUidGc+dW6H0mzxMggp2qkRD9yR+S61hjA37ApRx8hNU3ZSbhWkFSKkeEOxHiYzHol kbv5n9vNym6vfKbc3glDT3Ltw7lXe/gAE2hO4OqL2imLHUTeVh57Xs2aL7WDREmFRxcXi1JpcbHQ p3V0QZJC6MOl5p3u9M9uFCOD6Zff1uNPt2zFO4vV2JP87S8dcM5p5vrVM+ruhFdaxD7qEB4JOqEv rKxD504e3eHwGEXP9qtzqkDeVlQnVW7GXClKQQMifQ0JkL8C7loQd7MYGQ/7T+sa8eLrVfD7ocQY 46qL2qFDew3PvGa+J2KjDXa9nnB0Ouh/8Ue1mLuoBt+sijMIkF0NMbFcjwf6jxBVTkRNrGpMWR06 IBVTFcYxou0TVoZ7MITP7A28JEqNPmDJMpesKQUNiHRB8gY8P/pFIKaHxI6bEkGMDG5bt/vx3xeb fpGpsBAcf1YbZKQLPPqc+l99RpusfM09PgP0f+F7tZi3sBrf/RTZpkUr66i6LCEAGjEksvk/rY/Q H1pVibZl2OFRW5Uo3XZNB/h9cpcXclXczMlXrjHy8dCbIwZOqawVlbcCQv9snHTiayKJkcE3EAA+ +bIebdto6N8v1Ths2utBfVPRsb0Hm7b4UbHTGT88AhIgsTQr9TkgBWT+26mjB1u2BVBe4Yx2mtW+ UPmQEJ11ahZ0p7uHmjd9+eaCauxwyHNC7R53Vhtcc0mTd4JQHOI99u+nKvDGu2qmziOp2+ff1CMz Q8OAA8397p9yXAZ+2eDT/0dSD7uuEZDPrymZ9iWVrwvQqlV3yt4DbzofgHPj+gL6XhsVpt3B8Uzs 6hQq97Ov1DyYlDftYj9zRBbW/OrDr1HuNlfBhKYUzj2jjel7pw7snaL7+WvXVmsS4MrEFKbTh2bi v/d2xjGHmydERj/TNHJdvUm2zkamMb6SN5ILzjXvh0vzakyeWo6iD+yf2iJRUvGD1BWipImpa7+d qtsx7BoR9Rk4aSSAfs07zCmfE3FkFIotPZgej8Ah/c39tWSURdNbldUSqx0wtdW/Xwp65vy+jGlU 0KRXGhWSAGdladj0mx87qxJDmIafkoHrr1DnXLeqOoDnZ6rxVB9N12ZmCPzpwvagTd+q0rW3b8OK b8I7SVVVdqh86QdpMoqSP+C9bd2qKfpDt0uQeg2adAyAY0OBsvtYsoiRwfmrkgb9F+qRiqzIjjks TRc9KsfOJCFw8rHpSqswoF+qPq2Vnq5hQ5kP1TXO+OUfbaPph8S1l7bX14q6dNr1tY02m1avX/5F vel771ottNkFBx+UiosK2mLkYPM3ulNR5H2BRkY0neW0pFKUflzr078DDmvzzqWF3f5u1GnXk917 0KT9AZxhnHDKa7KJkcG95IdG/LY1gOOPVvMHm0ZgNK21YmW9aTvRjbpH+kpThxeca77T2VDlD/xD qm6BlpoisHa9zzFTUqHqGnzsxGPS8eeLm+L67Ntl19c1+BJT39Pifumm+NzkxFMhcoMzeVJH9NpP TXRk8r7wyLMVqKl17g8TVaJEcdRoZmSjjf0b4tn4Zm3J1KeM47ue8D4DJmVA4FLjhBNek1WMDPZk Fk4PUJ6iTa40rUW/trduD2BbufVTWrQLnkYsVkbBHXRQKgpGNa1d/bCmEY0ONcr74xFpuPrC9nro gpx91UxrGs+Z8frdj436pk3js9Wv5HlBpck6bUb97wvusDZVJUr0t8RhorR4bcnUN4xnbZcg9Rt4 fVVAaLcYJ+x+/csl7fSpFrPr4RQDhkjbRb9mPvi0DgJCN0yI9L5Ir+vbKwW0QL55qx8/r7N+CoP+ CLZv58FBfdX8Im6JA00LkZUfrddRHWgvmBPSUYem4U8XtsPFBW3RI9saITLa/ez0Sqz5xfpngJwC k+cF+qGgKlGwzKddth+PREnFd8NJoiQhX1tbMu1Do993CdLPqx6q7T3wxisBYc0cilGDEK80RXH2 SPMXM+9/bAeWfBg67HCIajjm0I6KAFaubkCbTE2JKFFDaZMppa9t2GBK0Tc7tPMoa1u4jqSpSwrh QRM4q35oBJng25EOGZCKyye0xcQJ7ZQZeoRr18x51SicZ32wx4EHpuLisW11k/1w9Yvn3JMv78Qr c+w31IilDZ9+mdiipEnt32tWTf3RYLNLkOhArwE3jxQCvY2TdryqEqMpj+/QN0/a0SYzyiQzaZV7 laiOhw5M09eVSJSsHDHQ1N2n9GuwrfUjJaNvDhuYpk+PEWea0rBKmPr3TcGl49rpI4QDelo7SjTa To5En/h9c7ZxzIrXkUOawkb03l9du+/9zw68VWTfHiMzOKoUpa9WNcQdWiOeNgrg1jWrpu6Ky7OH IPUeNOkQAMfHU0A896oUIze7Zg9mSsN4r1fgYEVm4bSuRCOGb75r0PfxBJet+j198eoaJFRZF0ZS /yMObhKm2jqJ739uVGbw0Wf/FN2SjEIH0KZeuxK5ynnyFetdS106ri2uPF9tYMnrbt+m/4izi62Z 5aoSpRG5mfji2wb8ttWWOettiwtzCjVkfQAAIABJREFU/i+Y056CNHBSVwBjgi+w6j2LUeSkv/y2 AVWKjQGGn5Jpy36lku8b8frb1boQqBLdSEjTWs4F57RFTZ3U15giuSeSa2jf1QXntMHNV3cAbeK1 Ky1fUYfHX9iJtxU5922pXZ06aLj8vHYw239bcHlk1v23e7Y70cQ5uJpRv1clSqcOtkeUpBTL166a +mIwiD0Eqc+gSWRz9OfgC6x4z2IUPWVaiCevC7Q/RVWi/UoZGZrlmwdp2oxEd96iGgT8AFnG2ZVI mC48ty12VjWNmGKtR86+Hpx/Tlvcek0HJe6hIq0X+fp7bnqlbk1ntfkvmbBTmHHyGqIqGaEjGhqc a9YdT9tJlMgNmNkM7RAlTcjZa0qmLQrmsYcgHTngwfJaUXUTAHVPTHDppH6KDBhozShRpumaIdv1 kfbxqLTAo4Jo0blrZ3tMw8l9DU0nLHivVl/Tor1EdiVy0UPCtG1HAD+uidxWvMs+TUL09+s7mu6r LFoW0/5XgUeeqcDPNljSnX9OG9D0pMr0YmElnplu/fSjyjaFypvWkhNBlITEY2tWTVsZ3Ma9Yk7k FWxcDghLPDawGAV3RezvyckmsSRHmyoT/TGjMOx2pe7ZXowelqnUnUwkbaNRBnkWX/R+bYtrTB3b a/pGXHIManci56E0NWd29NNI2kWbry/KbwsVPiiDy3/46Qp9RB18LNHfX3dZeyVha668ZSt+Xhf5 j65YOQuBAUUzcr4Lvn9vQRpb+m9IXBd8kYr3LEbmU504vq3uGdn8nHfnSFMihsv83Uetfdeze5Mw qdgaEG1LSJRopEpGEB4N6NhBQ98DUkBTfXYnCjlCnrvtECJqO7mF+scNHZVj+L/7tieM8UK0sFSJ 0hU3b8WaX5WKUsVJA7I7TZ68Z9yjEIK0cRykeC1aMNFcz2IUDa3orqXF4j9doNZ6iUK70wjBbl94 ZKlGMaTI3Qyn3QTI/Y9hGLL7qLXvaGMvTdOpTGS88PLsKmwrt8VCTGXTosrbpaK0aPHMnOHNG7rH GhKd7HPQLTXQpLIREotR8y4w9zNt7ly3wQdyO68qHdDDC7LCa2gEvv3ePgetFPPo4y/q9bAdZApv p/m0KtbR5EuRgW+9dzu+/V7pL9uwVdqvu1f3MpE/Su30MW3kJTdANDJN9kTfQRWb5unH3rJP69XE FhPylbUl04qb991eIyS6YEhB6W8C6NL84ng/sxjFSzDy+7t38+Lc07OUzDEH14KcVU5/swpbttn/ K5W8HdAak0rLw+C2O+X9C4WVeO2Navh89v5xpj9g9GtddbIruqvqdsWTf1amwMTx7ZR835VM3wlt 1OIZ3d5q3ua9Rkh0Qe+BN51EMd2aXxzPZxajeOhFf29lVUCfV8/KND8SZXBtyAcdCd9v2wIgZ7B2 ps1b/Hj/kzqQp3QK206/1hM5kX82GhF9sbLBMs8SoXimpQndKSo5R1Wd/jGlPOGtZ2NhSE6CVY6U Fr5Xq+99jKVuoe5Jlbj+p1VT97KQakGQbuwJiGGhMorlGDlOHHOa+UP4ZDDtjoV38D3kJ67BB5AH ApXp+KPS9TDMX5XUg8KT25nKNvtRvLwOZA1Hgd5UBQG0q43kXeH2B7brU5VWungK1V4ajf7v/s6g uFMqE60X3fVQue7WSWU5bs5bpSileIW+FGBSPLHvFhbmTAnFOqQg9Rn0Vz8gJ4a6IdpjtMCuIuoj mbLOX2J/6OFoedhx/berG/S9J4MVbqKldg04MFXf/PnLRp8jgp9RXJ+lH9Xhp3U+PRJnTjd3j5im v1GFO6eVY/mKekeEzZg4oa3ug0/1M134VjUee36na4MrquYTnD+JEq0jZ2WY64iZNuKmmidKs9eW TN1ruo7aEVKQ9usyZZOWVTUJQFw/e0iMVLgIof0wtIufU+QE1pf68M6SWqSlqgljEVyTU47NAE3j kLcFJyRqe9EHtfi11I8O7TRYEejOzHbTAv49/9mBDz6pc0RgwWOPSMNVF7XHqbnqrRtpi8FLr7vT U7eZz0A0eTU0Sn00QwJipkcHs0RJSPlQ8w2xRvtCCtK6dXcG+gyYdAoE+hgXRvuqUozs3JwZLQcn XU9RMmmXN8WfoXhAKtOgP6Tqng0oTLRTQkWvW+/TvT5s2uJHp44ePTihSgbx5j3r7Wrc/98KFH9U 65jRAe11u3Zie0tiNd3yr+1Y/AHPgsTyHNHUGlnbOlGU/H55/brvpoWMlBhSkAjAAQMn7S8EhsQC g8UoFmrW3UMjF4oSe9xRasKjB7eEzM8zMzR8/Z29C+/BdSLXOfOX1FjGILjsSN6/8W41pjxeoY/q yDjFCYnWCGktmLxDq07U/kl3bQeNbDnFTkC1KJERRdQRlwXWLH29+90ttapFQeo98AYfhLispRtb Os5i1BIZZx3/cW2jbpGmKhJtcGv1taUxbWyLShtcl+D3xOClWbSxMoDjjlQvzsFlh3pPcXumPlGh W5FV7HSGENH+LgoaSBGcaSuB6vTcjErQfiq7jTVUt9Oq/FWKEu19ilqUBApbWj8iJi0K0lEDp5bV iqprAUT8TWUxsuoxM6ecHTubTMPT0zRY4biUotKSb7MNZT49tIU5rYg/F3KWSsK0s1KCnKhancik lox0SJBos69T0ojcDDx+b2dLng1qM5l0v2NxOAynsFZZDxKlX0t9GGNyFG5aU4pWlATEA2tKppa0 1N6QG2ONi/MKSmcDONv4HO6VxSgcHeefOy0vEzdeoX5To0Hi6dcqQVZjTkvkqJb2VV2p2P0StZvW huYuqsE3NoSND8edfAWeOTxTubNeow40RUcjI/rDyUkdgd77peDJKZ1NL4BM8p9+LSIryIBX+rss KOy5vaVKtDhCohv6DLipEwROb+lm4ziLkUHCva80fbXkwzp4NHMtc1oiQvuiaCqPDC2ctlZAZrOv zKlCQ4Oa/VsffV6nmzFPf7Pa1vDRofrmgnPb4I4bO4IiB1uR6IfJs9Mro1+LsKJyCVYGjb7JFRB5 1DAzGSOllasbQLHMwqTPFxX2eCTMeYQdIeXmr+/rEZ4fw2XAYhSOjjvP0Y57CmNuVZozv1p3Bkqe FpyWPB6A9m+dPjQzbstEmpKjX5M//2KvR4tQjIeckKH/oVJtfWmU7RQHvUZ9kulV5Ujp4WcqwqG8 Z/HMPUOWN784rCDRxXkFpd8DOLD5jfSZxSgUlcQ4RtE9J09SHzogmBYFWCPvzQHnLKMEVw8d2ms4 8uA0HH5wKg7okYIe2R60ydL2uMb4QKbltFb2/U+NesTdVT80wOc8vdVDZYwengmasrUq0b6qJ18O afVrVRWSvhw7RElInFRUmLMsHPxIBOlBALRJdo/EYrQHjoT8QPuVaDf+OQrcPoUDZncgwHB1a36O XBNlpGtITxfw+yRq6qTugbqx0dnrISSk487MwrgzrRsJE7up/yMPK7ypvflzZMfnfr1S8Ph9ataU QoyUtneS2V0LC0XYn2Vh15AIUu+BN9H8wkXBwFiMgmkk7nsaqZAvvPKdARx7RMTGlnED+eMR6Ti4 f6rulYCC3zk50Zw5hUCg/UJVNRL1DdKxIzziqGnAhLPb4P7/64RBijdHB/cbGS7c8eAOfPOdM7x3 BNctWd9v3xHAF9824NTB5o6OaU2JQqzTJvygNOutwnazgj6HfNuqIHU64cGN6XW7zb9ZjEJyTOiD P/zciIXv18LjESDv3lak7K5e5B6Xgb69UnQT8dLNYX9YWVElV5dB1oMU4v4/d3fG4YOsNW0n9z/P z6zi2EUOfIJ+2+q3RJSkEHevLZm6qjUErU7ZUQZDC0pnSiCfpm9UDPEp0Nbsd6pbqyufdwABKyLS hmqmU02kQ9XVScdIiGh96IbLrTPpN9pPBhxvLqwGuWzi5GwCNFr+9537mF5Jipf2n2d3NAqZ0aWo sFNYiwcqPCJByhu7cdzE8e1eUyFGHGzL9GdAeYb79/DirBHqg/+FasiyT+t0x7orvtljOiDUpUl9 jIRoxCmZuOkq64WIwD/1SiVmzHXePrOkfihaabwqUVr6Ue36ISdk7tdK8frpiASp9LfGU7O7eOdH kmE017AYRUPLeddSWJGrLmxnS8U+/6ZeN6H+8LM6W8p3aqFkpj5qWJbu6seOOvKoyA7q5pV5+KBU TLnd/JESgH5CiJ9aq2lEgiSlfA3AuNYyi+Y8i1E0tJx7bbcuHj3ECK1P2JHW/NqoC9O7xbVwumWb Sj5tszR9H9Gl49RHbW2pHbRW9PpbPPXeEh+3HD/msDTcc2sns6vbTQixubVMIxWkCwG80FpmkZ5n MYqUlHuus9r1UCgy09+swluLakB7gJIl0X4S2nk/api5llLR8CMLOgqi58SNzdG0g6/dTcBkUSoW QgzenXvL7yIVpA4AylvOJvIzLEaRs3LblakpAhcXtEXBaHtGSwavT76ox4L3an6PrOrs/UBGnaN5 peCHucel47QhmZY5Pm2pfryvqCUy7j9uoihdI4R4NBIiEQkSZSSlfBPA6EgybekaFqOWyCTW8aMO SdN/tZN3b7sTWfkUfVCD1T81QrpYm8hI4ZD+qcg7McNSrwot9R+NRskHnVO9arRUbz4eHQGTRClb CLEpkpKjEaSxAKZHkmmoa1iMQlFJ7GMFo7Jwxfn2GD2EIkvrGxQGfNWPDa4QJxKhAf1SccIx6SCW TkhLljV5KP92NW9wdUJ/WFEH+mF5500xuxFbJIQYHmk9oxEkmqSOacWSxSjS7ki869q20XDe2W10 wwcnta7og1p8+mU9Vqysh1OC4RGfrEyBwwam4Y9HpOlTck5i9uhzO0HrRZySj8Apx6bj9htiEqVL hBDPR0osYkGiDKWULwK4INLM6ToWo2hoJe61hw5IxejhWTjlOPun8ZpTXrfBh5XfNej/v1/TiNJN PstGUB3bayBXKxQgkTwoWOUJozmDcJ9fnVOFFwo5ims4RslwLkZRai+EiNiTbrSCdCqAiPcjsRgl w2MaXRuHnZyBW/5MNjLOTuRzjTwMbNzkQ+kmP0o3+0ARdmMZTZGT2k4dNHTu6EGPHA96ZnuxXw+v 7mm7a+dWvXfZBmreoqZwGWRaz4kJEIEoRalQCFEQDbloBYm+PRH5AWExiqYbkutaWhspGNUGl59n 356ZWIn7/UBFZUDf80SOVBt9TZYSXo8Atcv4nJmu6Y5MMzMFaI+QmxJZKc5dVA165cQEmhOIQpRO F0K80/z+cJ+jEiTKSEp5L4C/hcuUxSgcHT5nEKCRw4Sz2oCilHJyBoEHn6jAu0s5PIQzesO5tYhw psMrRPhwE81bGIsg/QHA6uYZGZ/Zh5VBgl8jJdC+rYb8M7Iw7iwWpkiZmX0dGSy8uaDasrUzs+vP +VlPYERuBm6+qsXp9/uEELdGW6uoBYkKkFJ+AODE5oU9N6MSr8xmh4rNufDnyAjQesroYZksTJHh MuUqms14/W0WIlNgJmEmYUTpICEERRuPKsUqSJcCeCa4JBajYBr8Ph4CZHlGUWp5xBQPxfD3khDN nl8NWhPjxATiIRBClJYJIU6KJc9YBYl26e0aCrEYxYKe72mNAIXZPuvUTN0dUWvX8vnICDz8dAXe XlzDHhYiw8VXRUigmSidJ4R4NcJb97gsJkGiHKSU1wJ4+LHnd2LOfN4stwdV/mAqAa9X4PS8DFxz qT2xfUxtjA2Zfb2qAXMXVuP9j+t4jcgG/slSZJssbdurj+57aVaWmBtrm2MWJCrw7oe3n7j0wzpa T+LEBJQTILPqIw9Jw+jhmTj+KOdtsFUOIMoCFhTXYt6iat2PX5S38uVMIGoCAuL+opnZYS2wW8s0 LkGizPPyS5dCILe1gvg8EzCTABlAnDE0ExPOZsu85lzJ6ek7S2qwoyLQ/BR/ZgKqCASE39OvaNa+ a+IpwARBKjsXQhbGUwm+lwnESsDrAY45PB3DT8nAicck76jpvY/r9FhQX5XU87RcrA8T3xczAQEx t2hm9pkxZ/D7jXELUm6u9Hq6lv0MIKKY6fFWmO9nAi0RoP1Mg0/I0GMFDTootaXLEub4N6sasPjD Wn1tqLKKR0MJ07EubIgATi2ambMg3qrHLUhUgSEFZTcJyCnxVobvZwJmEdinowcn/TEdJx+brscR Mitfu/MhH3sffV6H95bXYcs2ttm2uz+4fJ3AqsUzswcBIu6IY6YIUu5Zazt4UtPWA+AJfX5CHUeA zMePPCQVfzw8HUcflgba5+SmRNNxH6+ow8df1INHQm7queSoqwCuKJqZ85QZrTVFkKgiQwrKHhGQ 15hRKc6DCagk0LO7F4f2T8XB/VP10VOXfZzjcZsct67+sRG0FvTFtw1Y84u7I92q7EfO2xEEttZI /37LC3vWmlEb0wRpeP6mXn4R+AGA14yKcR5MwCoC7dtpOLB3CvockIJ+vVLQM8eD7vt6kZZm2tcj ZFPKNvvx60Yfftnow/c/NYBiMW36jafhQsLig84kIMUdiwuz7zKrcqZ+4/IKSl8GcJ5ZleN8mICd BMhIonMnD9q1FaBpP4p+2yZT6EKV4hWgDbspv//8CgRoszjg+X2wVVsndW8IdQ0S9fUSOysD+nTb zqqAbo69dbsfPtYeO7uXy46fQJVX+vdfUNhze/xZNeVg7mjG438Afs8EAKYKnVmN5XyYQDQEaPqM /nNiAkxgbwISeMpMMaISTF3dXfxaz29EFBFl924iH2ECTIAJMAEXEGjwavIhs+tpqiBR5aSG+8yu JOfHBJgAE2ACziEgIF5ZOL07WVabmkwXpMXTcz4A5BJTa8mZMQEmwASYgFMI+ODX7lZRGdMFiSop Ie5UUVnOkwkwASbABGwmIOTL8fqsa6kFSgRpycyc9wEsbalQPs4EmAATYAKuJODzBwL/UlVzJYJE lZXAZFWV5nyZABNgAkzAFgKvFBf2/ElVycoE6fdR0iJVFed8mQATYAJMwFICDR6pKV2OUSZIhEnT cFvTYMlSaFwYE2ACTIAJmE5APrWwsNta07MNylCpIC2anvM5gFlB5fFbJsAEmAATcB+BGq8vVdna kYFDqSBRIR4/bgfgMwrkVybABJgAE3AXASnwyILZXcpU11q5IC2clbMaAi+obgjnzwSYABNgAkoI bGvwND6gJOdmmSoXJCrP25hCo6TqZmXzRybABJgAE3A4ASlw97JX9y+3opqWCBIN9SQER5S1oke5 DCbABJiAWQQEfqxon/2YWdm1lo8lgkSVyKqlEOeitLUK8XkmwASYABNwBgEZwC0rnhSNVtXGMkGa Ny+nRorA361qGJfDBJgAE2ACsROQwHtLCnPmxJ5D9HdaJkhUtZP757wACTIF58QEmAATYALOJeD3 yMD1VlfP8kB6eWPLjoaUH5sdi8lqcFweE2ACTCBRCUghH18yo/vVVrfP0hESNW7xjOzPADxrdUO5 PCbABJgAE2idgAS2BOobyMuO5clyQaIWpkpBjd1heWu5QCbABJgAEwhPQOC24jd62fL32RZBml+Y vQVC/F94KnyWCTABJsAErCQgpPzk5P7Zts1g2SJIBPik/t2eAPS1JCt5c1lMgAkwASYQmoDPr8kr J08WgdCn1R+13KghuEmDx244VJMaWd15g4/zeybABJgAE7CYgJBTFs/o/leLS92jONtGSFSLpTN6 fA2IaXvUiD8wASbABJiA1QTWeVI9SmMdRdIgWwWJKphZKwmC0hgbkYDga5gAE2ACyUpACnH1wpe6 2e5v1HZBIg8Omha4nAP5JetXgdvNBJiAzQReXjIje77NddCLt12QqBaLpvdYDOBJJwDhOjABJsAE kojAJq/0X+eU9jpCkAhGXV3KzQB+dQoYrgcTYAJMINEJSImrFxT23O6UdjpGkD6c26VSCslTd055 MrgeTIAJJDgBMd1q56mtAXWMIFFFl8zovhCQj7dWaT7PBJgAE2AC8RAQpV7p+3M8Oai411GCRA30 pHnIDv4HFY3lPJkAE2ACTABSQF7qpKk6o08cJ0hkehjQtAsB+IxK8isTYAJMgAmYRUD+t2hmzgKz cjMzH8cJEjVu6fRun0DIe8xsKOfFBJgAE0h2AlJgdWatsNUbQ7g+cKQgUYX9m3P+CYmPwlWezzEB JsAEmEDEBBoAnEd7PyO+w+ILHStIxcXCJ/2YIIByi5lwcUyACTCBxCMg8dclM3K+cHLDHCtIBG3J 7JxfpBRXOBkg140JMAEm4AICby8uzH7E6fV0tCARvMWF2a8L4Amng+T6MQEmwAScSUCU+j24GBDS mfXbXSvHCxJVtVr6b4TEl7urze+YABNgAkwgAgI+aHJc8Ws5WyO41vZLXCFIywt71krNkw+gwnZi XAEmwASYgEsISIhbF0/P+cAl1YUrBIlgLpmx789CyIvYK7hbHi2uJxNgAjYTmLNkZrepNtchquJd I0jUqqIZ3d+EkA9G1UK+mAkwASaQbAQEfhQy/RI3rBsFd42rBIkq3imQc6uUgsJVcGICTIAJMIG9 CVSLgHZOUWEn1y1xuE6QCguFPw0YD4Ff9u4HPsIEmAATSGoCUgAXFRV2W+lGCq4TJII8vzB7SwCB MwE4dsexGx8GrjMTYALuJiCBfxXNzJnl1la4UpAI9tIZPb6WQlzMRg5uffS43kyACZhKQMq3Th6Q fYepeVqcmbC4PNOLyysoux2Qd5meMWfIBJgAE3APgZVCpp/kxnWjYMSuFyRAiryCspfIaWBww/g9 E2ACTCAZCEhgS8AXOKJ4do8Nbm+va6fsdoMXspMsvxSQH+8+xu+YABNgAklBoEZq2qhEECPqrQQY ITU9dCPzy7o0CnwkIfsmxWPIjWQCTCDZCUgJed6Smd1fSxQQCTBCauoKsrzzSd9IGr4mSudwO5gA E2ACLREQErclkhhROxNmhGR02uBxm/6oBQJLAGQax/iVCTABJpBIBKSQjy+Z0f3qRGoTtSVhRkhG x1D484CU4ynorHGMX5kAE2ACCUTglX0COdckUHt2NSXhBIlatrSw+1wJ/IX3KO3qZ37DBJhAQhCQ S8iIizzWJERzmjUi4absgts3NL/0b1Lg3uBj/J4JMAEm4FICnwqZPtzte43CsU/IEZLR4KLCnPsE xP3GZ35lAkyACbiUwKpUKc5IZDGifknoEVLTg6dvnH0cwJUufRC52kyACSQzAYFf/I2BExNlr1G4 rkzoEVJTw2njbPafAbwSDgSfYwJMgAk4kMAGf8A/NBnEiNgnwQip6RHLz5eebaL0VQFR4MCHjqvE BJgAE2hGQJZByNzFM3r80OxEwn5MGkGiHjzyCpnSvrxsuhAYk7A9yg1jAkzA9QRog78mcErRjJzv XN+YKBqQBFN2u2mseFI07oPy8QJi7u6j/I4JMAEm4BwCuhhJLS/ZxIh6IKkEiRpcWDiwoaPcni8l ZjvnEeSaMAEmwAR0ApulRwx2a8TXePswqabsgmHl5kqvZ99NL0PKscHH+T0TYAJMwB4CotTjl3kL Z+Wstqd8+0tNuhGSgby4WPg6BbpRDKWXjWP8ygSYABOwicB6v/SdksxiRNyTVpCo8eR+o5PMpjDo /7PpIeRimQATSHICAuInvyZOLi7s+VOSo0ges+/wHS3FkLFl9wiJv4W/js8yASbABEwkIMU3fnhG FBd23WRirq7NKqlHSLt7TcglM3JuFVL8lR2y7qbC75gAE1BIQOKj+pSGXBaj3YyT1qhhN4I93w0Z WzpRSDwBwLPnGf7EBJgAEzCNwLuZtThn3rycGtNyTICMWJBCdOKQgo2jBASFBc4KcZoPMQEmwARi JiAgnivv0O1K2hcZcyYJeiMLUgsdS5FnRSAwTwBdWriEDzMBJsAEoiQg/7l4Zs4dgJBR3pgUl7Mg henm3Pz1fb3CO19C9g1zGZ9iAkyACbRGwC+Aq4pm5jzV2oXJfJ4FqZXezx1f2tnjxxwAJ7ZyKZ9m AkyACYQiUCGAsUUzcxaEOsnHdhNgK7vdLEK+K34tZ2tqZfVQ3kAbEg8fZAJMIDyBtQGPOIHFKDwk 4yyPkAwSEbzmFZTeCuBfyRS2IwIsfAkTYAIhCYjlnkZx1sI53X4LeZoP7kWABWkvJOEPDMkvPVsI vAigTfgr+SwTYAJJS0DI51N31vxp/vx+9UnLIIaGsyDFAG342E2DAlLOYWOHGODxLUwgsQn4hJA3 Fs3o/p/Ebqaa1rEgxcj1xAm/dEzzpbwK4NQYs+DbmAATSCACehwjTRQUTc8uTqBmWdoUNmqIEfey V/cv7ySzzwBwL7sbihEi38YEEoWAxOfw4WgWo/g6lEdI8fHT7x6aX3a6FPIFAPuYkB1nwQSYgIsI CCEeTdlZdROvF8XfaSxI8TPUcxiev6mXH4GZEDjKpCw5GybABJxNoAoCf1o8I+cVZ1fTPbXjKTuT +mphYbe1qVXVJ9KvJZ7CMwkqZ8MEnEtgpcePo1mMzO0gHiGZy1PPbejYjWdKKZ7hKTwFcDlLJmAz ASnk44GMhhuLn+9VZ3NVEq54FiRFXZo7ZkMPzau9LIBTFBXB2TIBJmAtge1SYuKSwhxyJcZJAQGe slMAlbIsnt1jwz4yOw8Q/wAku5lXxJmzZQIWEVgqJA5nMVJLm0dIavnquQ8bV3pUIACywhtgQXFc BBNgAuYRqBPAbScOyH548mQRMC9bzikUARakUFQUHDsuf31Gpua5FxLXsi88BYA5SyZgNgGJLwFx /uLC7FVmZ835hSbAghSai7KjeQVlgwXw5P+3d26xVVRRGP7/PbW2oqYgSIGC8a6QoPLgJYKppYL1 Fi+p0AcUrw/6oDHxHhU1XuOLiYmJqVGjRvAIGjERQ9GCGNEgmGgUlYhSORRRqQhKy5lZZho1EUs9 9zMz5386ObPXWnutb03yZ86Zvbe2HSoZYgUWgUIJDBj48K8NjQ/rVNdCUebmL0HKjVdRrAeflliz ALBbANQUJaiCiIAIFEzAgA/DPS8CAAAGbElEQVRovF5PRQWjzCuABCkvbMVxap275RQL2AlgWnEi KooIiECeBHYCvGvG5Man9V9RngSL4CZBKgLEQkI0N1uNO7z3ZsIeAFBfSCz5ioAI5EOAS/2Mf0P4 Zmw+3vIpHgEJUvFYFhSp9bJtRwUueIa0mQUFkrMIiEC2BLbB7KYVqQmLsnWQXWkJSJBKyzfH6MaW Oen5ND4OYHSOzjIXARHIjoABfK7GMre+k5r4S3YusioHAQlSOSjnOMfs9p5RGboHAV6vlx5yhCdz ERiewDrS3di1qHHN8GYarQQBCVIlqGc55+BLDz6fBDEjSxeZiYAIDE3gJxrunj5lXKdeWhgaUBSu SpCi0IVhczC2XJ6eS/BRAJOGNdWgCIjAvgQGQDzl9/c/2P3GkX37Dup7tAhIkKLVj/1mE65dGgHv JiNuB9CwX0MNiIAI/E3gdd/827pTEzf+fUGf0SYgQYp2f/6TXXNHerTn2/0ArgN4wH8MdEEEqpwA zT6C5+7QceLxuxEkSPHr2WDGze09x3j0FgDoAKBd22PaR6VdVAJfkLina9G41wFaUSMrWFkISJDK grl0k8zs6JmKjHsI5AWlm0WRRSDCBIjvYbxvlDW+lErRj3CmSu1/CEiQ/gdQXIZb5/SebkFwL4i2 uOSsPEWgQAKbaXx0JH55NpWaMlBgLLlHgIAEKQJNKGYKLe1bTyXtXgDnFzOuYolAhAh8R8MjI7Hj eQlRhLpShFQkSEWAGMUQ4aGAvo87SVys/5ii2CHllAeBrwh7fEfD+Bd1LEQe9GLgIkGKQZMKSfHs 9i3HO8dbYZgHoLaQWPIVgQoR+JjEY9NPHPeGFrVWqANlmlaCVCbQlZ7mnLmbxwfm3QzjdVrHVOlu aP4sCAQElhn4xIpXx72Xhb1MEkBAgpSAJuZSwqx5vSP8Absy3OUYwHG5+MpWBMpAYDdgL4D25IpF TV+XYT5NESECEqQINaOcqSxYYG7lF73nerAbDZgNwCvn/JpLBP5FgPjGjM8EA3s6tcXPv8hU1RcJ UlW1e+hiW9vTkwLyKsKu1n55QzPS1ZIQ6AfwGh07uxY2rtRi1pIwjlVQCVKs2lXaZMOnplVf9s5G YNeSdqG2Jiot7yqO/hkMz9bAf1HnEVXxXTBE6RKkIaDoEjCzY9tY8zPzSV4Dw7FiIgIFEtgF8FWY 37ki1fRhgbHknlACEqSENrZ4ZYXHX2ydQeIKGi41YGTxYitSwgkEAFbC7OXa2rrU2y8ftjPh9aq8 AglIkAoEWE3ubW3fHDhwyIg2g3UQvBBAfTXVr1qzJGBYC+AV52UWLl84KZ2ll8xEABIk3QR5ETjz ou2H1B04cB7Jyww4D8CIvALJKQkEDLCPaG4JAre4a/HYb5NQlGooPwEJUvmZJ27G8PDAg+iFr45f YkAbgTGJK1IF7UsgA9gqI5cGe4PXupc0/bCvgb6LQK4EJEi5EpP9sATa28372UufxoAXwHg+aFOH ddBgnAj8BGAZzN4i6pd1pUb9GqfklWv0CUiQot+jWGfYfOkPTTU1nGVw5xhspp6eYtXO8EiHNQSW +84tH+2PXavzhmLVv9glK0GKXcvim3C4zmn1hvRJ8F2rITgL5HTtqxepfvowrIfDKgZ8NwNvZXfq 8F2RylDJJJqABCnR7Y12ceHPezu89FTzeRaIGQBOBzAh2lknKrvdMKwDsdrI9/v/qFn9wZtjfktU hSomVgQkSLFqV/KTbWnvmeCcdxrMzjDwVAAnAzg0+ZWXvMKMARsIfGLAGs+CNXu3T/i8u5uZks+s CUQgSwISpCxByaxSBIwtc348CgimATaNNihQk7Xn3rD92EmzL438FMT6gO7Tg3cHny1dOv73Yb00 KAIVJiBBqnADNH1+BMJ1ULUH+ZO9wJ9ixAk0d7yZHQ3iaAB1+UWNlZcBCF+13mjARho2mLPPXcAN Xanxm2NViZIVgb8ISJB0KySMgHHW3HSTH7hjBp+iDEcYrYmGpsHvxMSY/AQY7oS9zYAewLYQDD83 G7DJkRsz9f2bup8/ck/CmqdyqpyABKnKb4BqLL95/qa62j21Y3xzjQDHGvwxNDfKYA2ObDCzBpg1 GFy9ozWY8QA4Oxhm9QT/efraz75+vxMIxQTh9gUg+mDYS2CXGcKxPwJaH8k+GnfArC8g+hyw3Rx+ 9PZie+DqtmqNTzXemar5T7boKrYfCqI6AAAAAElFTkSuQmCC"/></symbol><symbol viewBox="0 0 24 24" id="webpack" xmlns="http://www.w3.org/2000/svg"><path d="M19.376 15.988l-7.709 4.45-7.708-4.45V7.087l7.708-4.45 7.709 4.45z" fill="#fff" fill-opacity=".785" stroke-width="0"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18.21 0 .41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.939v2.104h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07l7-3.94zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83-4.9-2.83zm-5 5.08v3.58l4 2.308v-3.58l-4-2.308zm10 0l-4 2.308v3.58l4-2.308v-3.58z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83-4.9-2.83zm-5 5.08v3.58l4 2.308v-3.58l-4-2.308zm10 0l-4 2.308v3.58l4-2.308v-3.58z" fill="#1c78c0"/></symbol><symbol viewBox="0 0 24 24" id="wolframlanguage" xmlns="http://www.w3.org/2000/svg"><title>wolframLanguage</title><g transform="scale(.12121)" fill="none" fill-rule="evenodd"><circle cx="99.197" cy="98.946" r="83.28" fill="#212121" stroke-width=".841"/><path d="M182.529 98.828a83.406 83.406 0 0 1-39.14 70.721.064.064 0 0 1-.038.019l-28.62-35.665 23.71 2.612s11.385 1.177 13.978 0c2.373-.938 15.175-18.963 15.175-18.963s-36.75-23.23-49.312-36.032c1.434-21.575-1.656-50.269-1.656-50.03-9.251 9.234-10.429 10.669-19.68 19.203-4.028-13.04-5.923-17.547-9.95-30.588-12.104 9.95-21.337 26.799-27.977 46.48a78.68 78.68 0 0 0-4.23 5.094 109.774 109.774 0 0 0-2.667 3.66 114.558 114.558 0 0 0-5.132 8.002 172.555 172.555 0 0 0-3.403 6.051c-7.706 14.475-14.034 31.066-19.515 46.001a.858.858 0 0 1-.092-.184c-14.988-30.912-9.502-67.85 13.822-93.072 23.325-25.223 59.723-33.575 91.71-21.045 31.988 12.53 53.029 43.382 53.017 77.736z" fill="#e53935"/><path d="M101.452 69.178s-1.416-8.295-2.373-11.367c6.401-6.18 7.357-7.118 13.52-13.04.477 11.845.238 18.006-.479 32.481-3.55-3.568-10.668-8.074-10.668-8.074zm-27.737 40.778s-6.64-4.029-11.624-4.728c1.435-3.329 5.223-7.596 6.18-8.773-1.913.699-15.653 6.86-17.087 12.084a74.804 74.804 0 0 1 11.385 3.79 35.993 35.993 0 0 0-8.774 20.158s21.815-3.33 38.185-1.196c.283.168.609.251.938.24l8.534.239 27.111 45.136.221.35c-.037.018-.055.037-.073.037-51.133 18.485-88.085-15.543-95.976-27.443.034-.102.058-.206.074-.313 7.1-30.017 15.855-65.939 30-76.552 7.356-12.82 9.49-31.783 22.751-41.734 3.33 9.951 8.553 30.588 12.103 40.539 15.653 15.652 39.361 35.094 55.234 43.15 1.656.956 3.79 7.596 3.79 7.596l-6.401 8.056-68.276-6.879a54.462 54.462 0 0 0-4.58-.183 86.848 86.848 0 0 0-14.144 1.36c3.311-8.295 10.43-14.935 10.43-14.935zm22.054-8.774c3.789-.46 7.817.956 12.323 3.568 4.267-1.195 4.745-1.434 9.013-2.612-5.463-4.028-11.386-8.295-19.442-7.118a47.249 47.249 0 0 0-1.894 6.162z" fill="#fff" stroke-width=".936"/></g></symbol><symbol viewBox="0 0 24 24" id="word" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5M7 13l1.5 7h2l1.5-3 1.5 3h2l1.5-7h1v-2h-4v2h1l-.9 4.2L13 15h-2l-1.1 2.2L9 13h1v-2H6v2h1z" fill="#01579b"/></symbol><symbol viewBox="0 0 24 24" id="xaml" xmlns="http://www.w3.org/2000/svg"><path d="M18.93 12l-3.47 6H8.54l-3.47-6 3.47-6h6.92l3.47 6m4.84 0l-4.04 7L18 18l3.46-6L18 6l1.73-1 4.04 7M.23 12l4.04-7L6 6l-3.46 6L6 18l-1.73 1-4.04-7z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="xml" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5l3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41-3.74 3.74m11.16 0l-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41 3.74-3.74z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 24 24" id="yaml" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="yang" xmlns="http://www.w3.org/2000/svg"><path d="M12 2a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2m0 2a8 8 0 0 0-8 8 8 8 0 0 0 8 8 4 4 0 0 1-4-4 4 4 0 0 1 4-4 4 4 0 0 0 4-4 4 4 0 0 0-4-4m0 2.5A1.5 1.5 0 0 1 13.5 8 1.5 1.5 0 0 1 12 9.5 1.5 1.5 0 0 1 10.5 8 1.5 1.5 0 0 1 12 6.5m0 8a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 289.99999 290.00001" id="yarn" xmlns="http://www.w3.org/2000/svg"><path d="M250.733 218.418c-12.39 2.943-18.661 5.653-33.993 15.641-24.004 15.487-50.176 22.688-50.176 22.688s-2.168 3.252-8.44 4.723c-10.84 2.633-51.647 4.878-55.364 4.956-9.988.077-16.105-2.555-17.809-6.66-5.188-12.388 7.434-17.809 7.434-17.809s-2.788-1.703-4.414-3.252c-1.471-1.47-3.02-4.413-3.484-3.33-1.936 4.724-2.943 16.261-8.13 21.45-7.125 7.2-20.598 4.8-28.573.619-8.75-4.646.62-15.564.62-15.564s-4.724 2.788-8.518-2.942c-3.407-5.266-6.582-14.248-5.73-25.32 1.084-12.777 15.176-25.011 15.176-25.011s-2.477-18.661 5.653-37.787c7.356-17.422 27.179-31.437 27.179-31.437s-16.648-18.352-10.454-35c4.027-10.84 5.653-10.763 6.97-11.227 4.645-1.781 9.136-3.717 12.466-7.356 16.648-17.964 37.864-14.557 37.864-14.557s9.911-30.431 19.203-24.469c2.865 1.859 13.163 24.778 13.163 24.778s10.996-6.426 12.235-4.026c6.659 12.931 7.433 37.632 4.49 52.654-4.955 24.778-17.344 38.096-22.3 46.459-1.161 1.936 13.319 8.053 22.456 33.373 8.44 23.152.929 42.587 2.245 44.756.232.387.31.542.31.542s9.679.774 29.114-11.228c10.376-6.427 22.688-13.628 36.703-13.783 13.55-.232 14.247 15.719 4.104 18.12z" fill="#2c8ebb" stroke-width=".774"/></symbol><symbol viewBox="0 0 24 24" id="zip" xmlns="http://www.w3.org/2000/svg"><path d="M14 17h-2v-2h-2v-2h2v2h2m0-6h-2v2h2v2h-2v-2h-2V9h2V7h-2V5h2v2h2m5-4H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#afb42b"/></symbol></svg> \ No newline at end of file
diff --git a/app/assets/images/multi-editor-off.png b/app/assets/images/multi-editor-off.png
new file mode 100644
index 00000000000..82a6127f853
--- /dev/null
+++ b/app/assets/images/multi-editor-off.png
Binary files differ
diff --git a/app/assets/images/multi-editor-on.png b/app/assets/images/multi-editor-on.png
new file mode 100644
index 00000000000..d51b68da985
--- /dev/null
+++ b/app/assets/images/multi-editor-on.png
Binary files differ
diff --git a/app/assets/images/new_nav.png b/app/assets/images/new_nav.png
deleted file mode 100644
index 8879d26d341..00000000000
--- a/app/assets/images/new_nav.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/new_repo.png b/app/assets/images/new_repo.png
new file mode 100644
index 00000000000..ed3af06ab1d
--- /dev/null
+++ b/app/assets/images/new_repo.png
Binary files differ
diff --git a/app/assets/images/old_nav.png b/app/assets/images/old_nav.png
deleted file mode 100644
index 23fae7aa19e..00000000000
--- a/app/assets/images/old_nav.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/old_repo.png b/app/assets/images/old_repo.png
new file mode 100644
index 00000000000..c3c3b791ad9
--- /dev/null
+++ b/app/assets/images/old_repo.png
Binary files differ
diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js
deleted file mode 100644
index 346de4ad11e..00000000000
--- a/app/assets/javascripts/abuse_reports.js
+++ /dev/null
@@ -1,37 +0,0 @@
-const MAX_MESSAGE_LENGTH = 500;
-const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
-
-class AbuseReports {
- constructor() {
- $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
- $(document)
- .off('click', MESSAGE_CELL_SELECTOR)
- .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
- }
-
- truncateLongMessage() {
- const $messageCellElement = $(this);
- const reportMessage = $messageCellElement.text();
- if (reportMessage.length > MAX_MESSAGE_LENGTH) {
- $messageCellElement.data('original-message', reportMessage);
- $messageCellElement.data('message-truncated', 'true');
- $messageCellElement.text(window.gl.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
- }
- }
-
- toggleMessageTruncation() {
- const $messageCellElement = $(this);
- const originalMessage = $messageCellElement.data('original-message');
- if (!originalMessage) return;
- if ($messageCellElement.data('message-truncated') === 'true') {
- $messageCellElement.data('message-truncated', 'false');
- $messageCellElement.text(originalMessage);
- } else {
- $messageCellElement.data('message-truncated', 'true');
- $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
- }
- }
-}
-
-window.gl = window.gl || {};
-window.gl.AbuseReports = AbuseReports;
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 5d060165f4b..c117d080bda 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -1,9 +1,11 @@
/* eslint-disable no-param-reassign, class-methods-use-this */
-/* global Pager */
+import $ from 'jquery';
import Cookies from 'js-cookie';
+import Pager from './pager';
+import { localTimeAgo } from './lib/utils/datetime_utility';
-class Activities {
+export default class Activities {
constructor() {
Pager.init(20, true, false, data => data, this.updateTooltips);
@@ -15,7 +17,7 @@ class Activities {
}
updateTooltips() {
- gl.utils.localTimeAgo($('.js-timeago', '.content_list'));
+ localTimeAgo($('.js-timeago', '.content_list'));
}
reloadActivities() {
@@ -33,6 +35,3 @@ class Activities {
$sender.closest('li').toggleClass('active');
}
}
-
-window.gl = window.gl || {};
-window.gl.Activities = Activities;
diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js
deleted file mode 100644
index 34669dd13d6..00000000000
--- a/app/assets/javascripts/admin.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, no-else-return, prefer-arrow-callback, camelcase, quotes, comma-dangle, max-len */
-
-window.Admin = (function() {
- function Admin() {
- var modal, showBlacklistType;
- $('input#user_force_random_password').on('change', function(elem) {
- var elems;
- elems = $('#user_password, #user_password_confirmation');
- if ($(this).attr('checked')) {
- return elems.val('').attr('disabled', true);
- } else {
- return elems.removeAttr('disabled');
- }
- });
- $('body').on('click', '.js-toggle-colors-link', function(e) {
- e.preventDefault();
- return $('.js-toggle-colors-container').toggle();
- });
- $('.log-tabs a').click(function(e) {
- e.preventDefault();
- return $(this).tab('show');
- });
- $('.log-bottom').click(function(e) {
- var visible_log;
- e.preventDefault();
- visible_log = $(".file-content:visible");
- return visible_log.animate({
- scrollTop: visible_log.find('ol').height()
- }, "fast");
- });
- modal = $('.change-owner-holder');
- $('.change-owner-link').bind("click", function(e) {
- e.preventDefault();
- $(this).hide();
- return modal.show();
- });
- $('.change-owner-cancel-link').bind("click", function(e) {
- e.preventDefault();
- modal.hide();
- return $('.change-owner-link').show();
- });
- $('li.project_member').bind('ajax:success', function() {
- return gl.utils.refreshCurrentPage();
- });
- $('li.group_member').bind('ajax:success', function() {
- return gl.utils.refreshCurrentPage();
- });
- showBlacklistType = function() {
- if ($("input[name='blacklist_type']:checked").val() === 'file') {
- $('.blacklist-file').show();
- return $('.blacklist-raw').hide();
- } else {
- $('.blacklist-file').hide();
- return $('.blacklist-raw').show();
- }
- };
- $("input[name='blacklist_type']").click(showBlacklistType);
- showBlacklistType();
- }
-
- return Admin;
-})();
diff --git a/app/assets/javascripts/ajax_loading_spinner.js b/app/assets/javascripts/ajax_loading_spinner.js
index 38a8317dbd7..bd08308904c 100644
--- a/app/assets/javascripts/ajax_loading_spinner.js
+++ b/app/assets/javascripts/ajax_loading_spinner.js
@@ -1,4 +1,6 @@
-class AjaxLoadingSpinner {
+import $ from 'jquery';
+
+export default class AjaxLoadingSpinner {
static init() {
const $elements = $('.js-ajax-loading-spinner');
@@ -10,7 +12,7 @@ class AjaxLoadingSpinner {
e.target.setAttribute('disabled', '');
const iconElement = e.target.querySelector('i');
// get first fa- icon
- const originalIcon = iconElement.className.match(/(fa-)([^\s]+)/g).first();
+ const originalIcon = iconElement.className.match(/(fa-)([^\s]+)/g)[0];
iconElement.dataset.icon = originalIcon;
AjaxLoadingSpinner.toggleLoadingIcon(iconElement);
$(e.target).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
@@ -30,6 +32,3 @@ class AjaxLoadingSpinner {
classList.toggle('fa-spin');
}
}
-
-window.gl = window.gl || {};
-gl.AjaxLoadingSpinner = AjaxLoadingSpinner;
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 56fa0d71a9a..cbcefb2c18f 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,151 +1,194 @@
import $ from 'jquery';
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
- groupPath: '/api/:version/groups/:id.json',
+ groupPath: '/api/:version/groups/:id',
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',
+ projectsPath: '/api/:version/projects.json',
+ projectPath: '/api/:version/projects/:id',
+ projectLabelsPath: '/:namespace_path/:project_path/labels',
+ groupLabelsPath: '/groups/:namespace_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',
+ commitPath: '/api/:version/projects/:id/repository/commits',
+ branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
+ createBranchPath: '/api/:version/projects/:id/repository/branches',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath)
.replace(':id', groupId);
- return $.ajax({
- url,
- dataType: 'json',
- })
- .done(group => callback(group));
+ return axios.get(url)
+ .then(({ data }) => {
+ callback(data);
+
+ return data;
+ });
},
// Return groups list. Filtered by query
- groups(query, options, callback) {
+ groups(query, options, callback = $.noop) {
const url = Api.buildUrl(Api.groupsPath);
- return $.ajax({
- url,
- data: Object.assign({
+ return axios.get(url, {
+ params: Object.assign({
search: query,
per_page: 20,
}, options),
- dataType: 'json',
})
- .done(groups => callback(groups));
+ .then(({ data }) => {
+ callback(data);
+
+ return data;
+ });
},
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);
- return $.ajax({
- url,
- data: {
+ return axios.get(url, {
+ params: {
search: query,
per_page: 20,
},
- dataType: 'json',
- }).done(namespaces => callback(namespaces));
+ })
+ .then(({ data }) => callback(data));
},
// Return projects list. Filtered by query
- projects(query, options, callback) {
+ projects(query, options, callback = _.noop) {
const url = Api.buildUrl(Api.projectsPath);
- return $.ajax({
- url,
- data: Object.assign({
- search: query,
- per_page: 20,
- membership: true,
- }, options),
- dataType: 'json',
+ const defaults = {
+ search: query,
+ per_page: 20,
+ simple: true,
+ };
+
+ if (gon.current_user_id) {
+ defaults.membership = true;
+ }
+
+ return axios.get(url, {
+ params: Object.assign(defaults, options),
})
- .done(projects => callback(projects));
+ .then(({ data }) => {
+ callback(data);
+
+ return data;
+ });
+ },
+
+ // Return single project
+ project(projectPath) {
+ const url = Api.buildUrl(Api.projectPath)
+ .replace(':id', encodeURIComponent(projectPath));
+
+ return axios.get(url);
},
newLabel(namespacePath, projectPath, data, callback) {
- const url = Api.buildUrl(Api.labelsPath)
- .replace(':namespace_path', namespacePath)
- .replace(':project_path', projectPath);
- return $.ajax({
- url,
- type: 'POST',
- data: { label: data },
- dataType: 'json',
+ let url;
+
+ if (projectPath) {
+ url = Api.buildUrl(Api.projectLabelsPath)
+ .replace(':namespace_path', namespacePath)
+ .replace(':project_path', projectPath);
+ } else {
+ url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
+ }
+
+ return axios.post(url, {
+ label: data,
})
- .done(label => callback(label))
- .fail(message => callback(message.responseJSON));
+ .then(res => callback(res.data))
+ .catch(e => callback(e.response.data));
},
// Return group projects list. Filtered by query
groupProjects(groupId, query, callback) {
const url = Api.buildUrl(Api.groupProjectsPath)
.replace(':id', groupId);
- return $.ajax({
- url,
- data: {
+ return axios.get(url, {
+ params: {
search: query,
per_page: 20,
},
- dataType: 'json',
})
- .done(projects => callback(projects));
+ .then(({ data }) => callback(data));
+ },
+
+ commitMultiple(id, data) {
+ // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
+ const url = Api.buildUrl(Api.commitPath)
+ .replace(':id', encodeURIComponent(id));
+ return axios.post(url, JSON.stringify(data), {
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ },
+ });
+ },
+
+ branchSingle(id, branch) {
+ const url = Api.buildUrl(Api.branchSinglePath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':branch', branch);
+
+ return axios.get(url);
},
// Return text for a specific license
licenseText(key, data, callback) {
const url = Api.buildUrl(Api.licensePath)
.replace(':key', key);
- return $.ajax({
- url,
- data,
+ return axios.get(url, {
+ params: data,
})
- .done(license => callback(license));
+ .then(res => callback(res.data));
},
gitignoreText(key, callback) {
const url = Api.buildUrl(Api.gitignorePath)
.replace(':key', key);
- return $.get(url, gitignore => callback(gitignore));
+ return axios.get(url)
+ .then(({ data }) => callback(data));
},
gitlabCiYml(key, callback) {
const url = Api.buildUrl(Api.gitlabCiYmlPath)
.replace(':key', key);
- return $.get(url, file => callback(file));
+ return axios.get(url)
+ .then(({ data }) => callback(data));
},
dockerfileYml(key, callback) {
const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
- $.get(url, callback);
+ return axios.get(url)
+ .then(({ data }) => callback(data));
},
issueTemplate(namespacePath, projectPath, key, type, callback) {
const url = Api.buildUrl(Api.issuableTemplatePath)
- .replace(':key', key)
+ .replace(':key', encodeURIComponent(key))
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
- $.ajax({
- url,
- dataType: 'json',
- })
- .done(file => callback(null, file))
- .fail(callback);
+ return axios.get(url)
+ .then(({ data }) => callback(null, data))
+ .catch(callback);
},
users(query, options) {
const url = Api.buildUrl(this.usersPath);
- return Api.wrapAjaxCall({
- url,
- data: Object.assign({
+ return axios.get(url, {
+ params: Object.assign({
search: query,
per_page: 20,
}, options),
- dataType: 'json',
});
},
@@ -156,20 +199,6 @@ const Api = {
}
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);
- },
- );
- });
- },
};
export default Api;
diff --git a/app/assets/javascripts/aside.js b/app/assets/javascripts/aside.js
deleted file mode 100644
index 88756884d16..00000000000
--- a/app/assets/javascripts/aside.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, prefer-arrow-callback, no-var, one-var, one-var-declaration-per-line, no-else-return, max-len */
-
-window.Aside = (function() {
- function Aside() {
- $(document).off("click", "a.show-aside");
- $(document).on("click", 'a.show-aside', function(e) {
- var btn, icon;
- e.preventDefault();
- btn = $(e.currentTarget);
- icon = btn.find('i');
- if (icon.hasClass('fa-angle-left')) {
- btn.parent().find('section').hide();
- btn.parent().find('aside').fadeIn();
- return icon.removeClass('fa-angle-left').addClass('fa-angle-right');
- } else {
- btn.parent().find('aside').hide();
- btn.parent().find('section').fadeIn();
- return icon.removeClass('fa-angle-right').addClass('fa-angle-left');
- }
- });
- }
-
- return Aside;
-})();
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index cfab6c40b34..0da872db7e5 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,55 +1,54 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */
+/* eslint-disable no-param-reassign, prefer-template, no-var, no-void, consistent-return */
+
import AccessorUtilities from './lib/utils/accessor';
-window.Autosave = (function() {
- function Autosave(field, key) {
+export default class Autosave {
+ constructor(field, key) {
this.field = field;
- this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
if (key.join != null) {
- key = key.join("/");
+ key = key.join('/');
}
- this.key = "autosave/" + key;
- this.field.data("autosave", this);
+ this.key = 'autosave/' + key;
+ this.field.data('autosave', this);
this.restore();
- this.field.on("input", (function(_this) {
- return function() {
- return _this.save();
- };
- })(this));
+ this.field.on('input', () => this.save());
}
- Autosave.prototype.restore = function() {
- var text;
-
+ restore() {
if (!this.isLocalStorageAvailable) return;
+ if (!this.field.length) return;
- text = window.localStorage.getItem(this.key);
+ const text = window.localStorage.getItem(this.key);
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
- return this.field.trigger("input");
- };
- Autosave.prototype.save = function() {
- var text;
- text = this.field.val();
+ this.field.trigger('input');
+ // v-model does not update with jQuery trigger
+ // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
+ const event = new Event('change', { bubbles: true, cancelable: false });
+ const field = this.field.get(0);
+ field.dispatchEvent(event);
+ }
+
+ save() {
+ if (!this.field.length) return;
+
+ const text = this.field.val();
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text);
}
return this.reset();
- };
+ }
- Autosave.prototype.reset = function() {
+ reset() {
if (!this.isLocalStorageAvailable) return;
return window.localStorage.removeItem(this.key);
- };
-
- return Autosave;
-})();
-
-export default window.Autosave;
+ }
+}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index ebe722061d7..6da33a26e58 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,12 +1,12 @@
/* eslint-disable class-methods-use-this */
-/* global Flash */
+import $ from 'jquery';
+import _ from 'underscore';
import Cookies from 'js-cookie';
-
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-import { glEmojiTag } from './behaviors/gl_emoji';
-import isEmojiNameValid from './behaviors/gl_emoji/is_emoji_name_valid';
+import { __ } from './locale';
+import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -17,8 +17,6 @@ const requestAnimationFrame = window.requestAnimationFrame ||
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence
-let categoryMap = null;
-
const categoryLabelMap = {
activity: 'Activity',
people: 'People',
@@ -30,49 +28,13 @@ const categoryLabelMap = {
flags: 'Flags',
};
-function buildCategoryMap() {
- return Object.keys(emojiMap).reduce((currentCategoryMap, emojiNameKey) => {
- const emojiInfo = emojiMap[emojiNameKey];
- if (currentCategoryMap[emojiInfo.category]) {
- currentCategoryMap[emojiInfo.category].push(emojiNameKey);
- }
-
- return currentCategoryMap;
- }, {
- activity: [],
- people: [],
- nature: [],
- food: [],
- travel: [],
- objects: [],
- symbols: [],
- flags: [],
- });
-}
-
-function renderCategory(name, emojiList, opts = {}) {
- return `
- <h5 class="emoji-menu-title">
- ${name}
- </h5>
- <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
- ${emojiList.map(emojiName => `
- <li class="emoji-menu-list-item">
- <button class="emoji-menu-btn text-center js-emoji-btn" type="button">
- ${glEmojiTag(emojiName, {
- sprite: true,
- })}
- </button>
- </li>
- `).join('\n')}
- </ul>
- `;
-}
+const IS_VISIBLE = 'is-visible';
+const IS_RENDERED = 'is-rendered';
-export default class AwardsHandler {
- constructor() {
+class AwardsHandler {
+ constructor(emoji) {
+ this.emoji = emoji;
this.eventListeners = [];
- this.aliases = emojiAliases;
// If the user shows intent let's pre-build the menu
this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
const $menu = $('.emoji-menu');
@@ -81,8 +43,6 @@ export default class AwardsHandler {
this.createEmojiMenu();
});
}
- // Prebuild the categoryMap
- categoryMap = categoryMap || buildCategoryMap();
});
this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
e.stopPropagation();
@@ -92,13 +52,11 @@ export default class AwardsHandler {
this.registerEventListener('on', $('html'), 'click', (e) => {
const $target = $(e.target);
- if (!$target.closest('.emoji-menu-content').length) {
- $('.js-awards-block.current').removeClass('current');
- }
if (!$target.closest('.emoji-menu').length) {
+ $('.js-awards-block.current').removeClass('current');
if ($('.emoji-menu').is(':visible')) {
$('.js-add-award.is-active').removeClass('is-active');
- $('.emoji-menu').removeClass('is-visible');
+ this.hideMenuElement($('.emoji-menu'));
}
}
});
@@ -107,10 +65,10 @@ export default class AwardsHandler {
const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji');
const $spriteIconElement = $target.find('.icon');
- const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
+ const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
$target.closest('.js-awards-block').addClass('current');
- this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji);
+ this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName);
});
}
@@ -135,12 +93,12 @@ export default class AwardsHandler {
if ($menu.length) {
if ($menu.is('.is-visible')) {
$addBtn.removeClass('is-active');
- $menu.removeClass('is-visible');
+ this.hideMenuElement($menu);
$('.js-emoji-menu-search').blur();
} else {
$addBtn.addClass('is-active');
this.positionMenu($menu, $addBtn);
- $menu.addClass('is-visible');
+ this.showMenuElement($menu);
$('.js-emoji-menu-search').focus();
}
} else {
@@ -150,13 +108,14 @@ export default class AwardsHandler {
$addBtn.removeClass('is-loading');
this.positionMenu($createdMenu, $addBtn);
return setTimeout(() => {
- $createdMenu.addClass('is-visible');
+ this.showMenuElement($createdMenu);
$('.js-emoji-menu-search').focus();
}, 200);
});
}
$thumbsBtn.toggleClass('disabled', $userAuthored);
+ $thumbsBtn.prop('disabled', $userAuthored);
}
// Create the emoji menu with the first category of emojis.
@@ -168,16 +127,16 @@ export default class AwardsHandler {
this.isCreatingEmojiMenu = true;
// Render the first category
- categoryMap = categoryMap || buildCategoryMap();
+ const categoryMap = this.emoji.getEmojiCategoryMap();
const categoryNameKey = Object.keys(categoryMap)[0];
const emojisInCategory = categoryMap[categoryNameKey];
- const firstCategory = renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
+ const firstCategory = this.renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
// Render the frequently used
const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
let frequentlyUsedCatgegory = '';
if (frequentlyUsedEmojis.length > 0) {
- frequentlyUsedCatgegory = renderCategory('Frequently used', frequentlyUsedEmojis, {
+ frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, {
menuListClass: 'frequent-emojis',
});
}
@@ -208,7 +167,7 @@ export default class AwardsHandler {
}
this.isAddingRemainingEmojiMenuCategories = true;
- categoryMap = categoryMap || buildCategoryMap();
+ const categoryMap = this.emoji.getEmojiCategoryMap();
// Avoid the jank and render the remaining categories separately
// This will take more time, but makes UI more responsive
@@ -220,7 +179,7 @@ export default class AwardsHandler {
promiseChain.then(() =>
new Promise((resolve) => {
const emojisInCategory = categoryMap[categoryNameKey];
- const categoryMarkup = renderCategory(
+ const categoryMarkup = this.renderCategory(
categoryLabelMap[categoryNameKey],
emojisInCategory,
);
@@ -245,6 +204,25 @@ export default class AwardsHandler {
});
}
+ renderCategory(name, emojiList, opts = {}) {
+ return `
+ <h5 class="emoji-menu-title">
+ ${name}
+ </h5>
+ <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
+ ${emojiList.map(emojiName => `
+ <li class="emoji-menu-list-item">
+ <button class="emoji-menu-btn text-center js-emoji-btn" type="button">
+ ${this.emoji.glEmojiTag(emojiName, {
+ sprite: true,
+ })}
+ </button>
+ </li>
+ `).join('\n')}
+ </ul>
+ `;
+ }
+
positionMenu($menu, $addBtn) {
const position = $addBtn.data('position');
// The menu could potentially be off-screen or in a hidden overflow element
@@ -262,33 +240,44 @@ export default class AwardsHandler {
return $menu.css(css);
}
- addAward(
- votesBlock,
- awardUrl,
- emoji,
- checkMutuality,
- callback,
- ) {
- const normalizedEmoji = this.normalizeEmojiName(emoji);
+ addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
+ const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
+
+ if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
+ const id = votesBlock.attr('id').replace('note_', '');
+
+ this.hideMenuElement($('.emoji-menu'));
+
+ $('.js-add-award.is-active').removeClass('is-active');
+ const toggleAwardEvent = new CustomEvent('toggleAward', {
+ detail: {
+ awardName: emoji,
+ noteId: id,
+ },
+ });
+
+ document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
+ }
+
+ const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
- $('.emoji-menu').removeClass('is-visible');
- $('.js-add-award.is-active').removeClass('is-active');
+
+ this.hideMenuElement($('.emoji-menu'));
+
+ return $('.js-add-award.is-active').removeClass('is-active');
}
- addAwardToEmojiBar(
- votesBlock,
- emoji,
- checkForMutuality,
- ) {
+ addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
if (checkForMutuality || checkForMutuality === null) {
this.checkMutuality(votesBlock, emoji);
}
this.addEmojiToFrequentlyUsedList(emoji);
- const normalizedEmoji = this.normalizeEmojiName(emoji);
+ const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
if ($emojiButton.length > 0) {
if (this.isActive($emojiButton)) {
@@ -306,7 +295,23 @@ export default class AwardsHandler {
}
}
+ isVueMRDiscussions() {
+ return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
+ }
+
+ isInVueNoteablePage() {
+ return isInIssuePage() || this.isVueMRDiscussions();
+ }
+
getVotesBlock() {
+ if (this.isInVueNoteablePage()) {
+ const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
+
+ if ($el.length) {
+ return $el;
+ }
+ }
+
const currentBlock = $('.js-awards-block.current');
let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) {
@@ -317,7 +322,7 @@ export default class AwardsHandler {
}
getAwardUrl() {
- return this.getVotesBlock().data('award-url');
+ return this.getVotesBlock().data('awardUrl');
}
checkMutuality(votesBlock, emoji) {
@@ -413,7 +418,7 @@ export default class AwardsHandler {
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
<button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
- ${glEmojiTag(emojiName)}
+ ${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
`;
@@ -446,13 +451,15 @@ export default class AwardsHandler {
if (this.isUserAuthored($emojiButton)) {
this.userAuthored($emojiButton);
} else {
- $.post(awardUrl, {
+ axios.post(awardUrl, {
name: emoji,
- }, (data) => {
+ })
+ .then(({ data }) => {
if (data.ok) {
callback();
}
- }).fail(() => new Flash('Something went wrong on our end.'));
+ })
+ .catch(() => flash(__('Something went wrong on our end.')));
}
}
@@ -463,11 +470,11 @@ export default class AwardsHandler {
userAuthored($emojiButton) {
const oldTitle = this.getAwardTooltip($emojiButton);
const newTitle = 'You cannot vote on your own issue, MR and note';
- gl.utils.updateTooltipTitle($emojiButton, newTitle).tooltip('show');
+ updateTooltipTitle($emojiButton, newTitle).tooltip('show');
// Restore tooltip back to award list
return setTimeout(() => {
$emojiButton.tooltip('hide');
- gl.utils.updateTooltipTitle($emojiButton, oldTitle);
+ updateTooltipTitle($emojiButton, oldTitle);
}, 2800);
}
@@ -478,12 +485,8 @@ export default class AwardsHandler {
return $('body, html').animate(options, 200);
}
- normalizeEmojiName(emoji) {
- return Object.prototype.hasOwnProperty.call(this.aliases, emoji) ? this.aliases[emoji] : emoji;
- }
-
addEmojiToFrequentlyUsedList(emoji) {
- if (isEmojiNameValid(emoji)) {
+ if (this.emoji.isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
@@ -493,7 +496,7 @@ export default class AwardsHandler {
return this.frequentlyUsedEmojis || (() => {
const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
- inputName => isEmojiNameValid(inputName),
+ inputName => this.emoji.isEmojiNameValid(inputName),
);
return this.frequentlyUsedEmojis;
@@ -535,22 +538,39 @@ export default class AwardsHandler {
}
}
- findMatchingEmojiElements(term) {
- const safeTerm = term.toLowerCase();
+ findMatchingEmojiElements(query) {
+ const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
+ const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
+ const $matchingElements = $emojiElements
+ .filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0);
+ return $matchingElements.closest('li').clone();
+ }
+
+ /* showMenuElement and hideMenuElement are performance optimizations. We use
+ * opacity to show/hide the emoji menu, because we can animate it. But opacity
+ * leaves hidden elements in the render tree, which is unacceptable given the number
+ * of emoji elements in the emoji menu (5k+). To get the best of both worlds, we separately
+ * apply IS_RENDERED to add/remove the menu from the render tree and IS_VISIBLE to animate
+ * the menu being opened and closed. */
+
+ showMenuElement($emojiMenu) {
+ $emojiMenu.addClass(IS_RENDERED);
- const namesMatchingAlias = [];
- Object.keys(emojiAliases).forEach((alias) => {
- if (alias.indexOf(safeTerm) >= 0) {
- namesMatchingAlias.push(emojiAliases[alias]);
+ // enqueues animation as a microtask, so it begins ASAP once IS_RENDERED added
+ return Promise.resolve()
+ .then(() => $emojiMenu.addClass(IS_VISIBLE));
+ }
+
+ hideMenuElement($emojiMenu) {
+ $emojiMenu.on(transitionEndEventString, (e) => {
+ if (e.currentTarget === e.target) {
+ $emojiMenu
+ .removeClass(IS_RENDERED)
+ .off(transitionEndEventString);
}
});
- const $matchingElements = namesMatchingAlias.concat(safeTerm)
- .reduce(
- ($result, searchTerm) =>
- $result.add($(`.emoji-menu-list:not(.frequent-emojis) [data-name*="${searchTerm}"]`)),
- $([]),
- );
- return $matchingElements.closest('li').clone();
+
+ $emojiMenu.removeClass(IS_VISIBLE);
}
destroy() {
@@ -560,3 +580,12 @@ export default class AwardsHandler {
$('.emoji-menu').remove();
}
}
+
+let awardsHandlerPromise = null;
+export default function loadAwardsHandler(reload = false) {
+ if (!awardsHandlerPromise || reload) {
+ awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji')
+ .then(Emoji => new AwardsHandler(Emoji));
+ }
+ return awardsHandlerPromise;
+}
diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js
index 3bea460dcc6..add43b81f6d 100644
--- a/app/assets/javascripts/behaviors/autosize.js
+++ b/app/assets/javascripts/behaviors/autosize.js
@@ -1,23 +1,8 @@
-import autosize from 'vendor/autosize';
+import Autosize from 'autosize';
-$(() => {
- const $fields = $('.js-autosize');
+document.addEventListener('DOMContentLoaded', () => {
+ const autosizeEls = document.querySelectorAll('.js-autosize');
- $fields.on('autosize:resized', function resized() {
- const $field = $(this);
- $field.data('height', $field.outerHeight());
- });
-
- $fields.on('resize.autosize', function resize() {
- const $field = $(this);
- if ($field.data('height') !== $field.outerHeight()) {
- $field.data('height', $field.outerHeight());
- autosize.destroy($field);
- $field.css('max-height', window.outerHeight);
- }
- });
-
- autosize($fields);
- autosize.update($fields);
- $fields.css('resize', 'vertical');
+ Autosize(autosizeEls);
+ Autosize.update(autosizeEls);
});
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
new file mode 100644
index 00000000000..e2a73a1797c
--- /dev/null
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -0,0 +1,74 @@
+import $ from 'jquery';
+import Clipboard from 'clipboard';
+
+function showTooltip(target, title) {
+ const $target = $(target);
+ const originalTitle = $target.data('originalTitle');
+
+ if (!$target.data('hideTooltip')) {
+ $target
+ .attr('title', title)
+ .tooltip('fixTitle')
+ .tooltip('show')
+ .attr('title', originalTitle)
+ .tooltip('fixTitle');
+ }
+}
+
+function genericSuccess(e) {
+ showTooltip(e.trigger, 'Copied');
+ // Clear the selection and blur the trigger so it loses its border
+ e.clearSelection();
+ $(e.trigger).blur();
+}
+
+/**
+ * Safari > 10 doesn't support `execCommand`, so instead we inform the user to copy manually.
+ * See http://clipboardjs.com/#browser-support
+ */
+function genericError(e) {
+ let key;
+ if (/Mac/i.test(navigator.userAgent)) {
+ key = '&#8984;'; // Command
+ } else {
+ key = 'Ctrl';
+ }
+ showTooltip(e.trigger, `Press ${key}-C to copy`);
+}
+
+export default function initCopyToClipboard() {
+ const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
+ clipboard.on('success', genericSuccess);
+ clipboard.on('error', genericError);
+
+ /**
+ * This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting
+ * of plain text or GFM. The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and
+ * `gfm` keys into the `data-clipboard-text` attribute that ClipboardJS reads from.
+ * When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly`
+ * attribute`), sets its value to the value of this data attribute, focusses on it, and finally
+ * programmatically issues the 'Copy' command, this code intercepts the copy command/event at
+ * the last minute to deconstruct this JSON hash and set the `text/plain` and `text/x-gfm` copy
+ * data types to the intended values.
+ */
+ $(document).on('copy', 'body > textarea[readonly]', (e) => {
+ const clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ const text = e.target.value;
+
+ let json;
+ try {
+ json = JSON.parse(text);
+ } catch (ex) {
+ return;
+ }
+
+ if (!json.text || !json.gfm) return;
+
+ e.preventDefault();
+
+ clipboardData.setData('text/plain', json.text);
+ clipboardData.setData('text/x-gfm', json.gfm);
+ });
+}
diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js
index 7c9dbcc8d6e..1d63f5baeee 100644
--- a/app/assets/javascripts/behaviors/details_behavior.js
+++ b/app/assets/javascripts/behaviors/details_behavior.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
$(() => {
$('body').on('click', '.js-details-target', function target() {
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 36ce4fddb72..7e98e04303a 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,75 +1,9 @@
import installCustomElements from 'document-register-element';
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-import { getUnicodeSupportMap } from './gl_emoji/unicode_support_map';
-import { isEmojiUnicodeSupported } from './gl_emoji/is_emoji_unicode_supported';
+import isEmojiUnicodeSupported from '../emoji/support';
installCustomElements(window);
-const generatedUnicodeSupportMap = getUnicodeSupportMap();
-
-function emojiImageTag(name, src) {
- return `<img class="emoji" title=":${name}:" alt=":${name}:" src="${src}" width="20" height="20" align="absmiddle" />`;
-}
-
-function assembleFallbackImageSrc(inputName) {
- let name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ?
- emojiAliases[inputName] : inputName;
- let emojiInfo = emojiMap[name];
- // Fallback to question mark for unknown emojis
- if (!emojiInfo) {
- name = 'grey_question';
- emojiInfo = emojiMap[name];
- }
- const fallbackImageSrc = `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${emojiInfo.digest}.png`;
-
- return fallbackImageSrc;
-}
-const glEmojiTagDefaults = {
- sprite: false,
- forceFallback: false,
-};
-function glEmojiTag(inputName, options) {
- const opts = Object.assign({}, glEmojiTagDefaults, options);
- let name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ?
- emojiAliases[inputName] : inputName;
- let emojiInfo = emojiMap[name];
- // Fallback to question mark for unknown emojis
- if (!emojiInfo) {
- name = 'grey_question';
- emojiInfo = emojiMap[name];
- }
-
- const fallbackImageSrc = assembleFallbackImageSrc(name);
- const fallbackSpriteClass = `emoji-${name}`;
-
- const classList = [];
- if (opts.forceFallback && opts.sprite) {
- classList.push('emoji-icon');
- classList.push(fallbackSpriteClass);
- }
- const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
- const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : '';
- let contents = emojiInfo.moji;
- if (opts.forceFallback && !opts.sprite) {
- contents = emojiImageTag(name, fallbackImageSrc);
- }
-
- return `
- <gl-emoji
- ${classAttribute}
- data-name="${name}"
- data-fallback-src="${fallbackImageSrc}"
- ${fallbackSpriteAttribute}
- data-unicode-version="${emojiInfo.unicodeVersion}"
- title="${emojiInfo.description}"
- >
- ${contents}
- </gl-emoji>
- `;
-}
-
-function installGlEmojiElement() {
+export default function installGlEmojiElement() {
const GlEmojiElementProto = Object.create(HTMLElement.prototype);
GlEmojiElementProto.createdCallback = function createdCallback() {
const emojiUnicode = this.textContent.trim();
@@ -90,18 +24,26 @@ function installGlEmojiElement() {
if (
emojiUnicode &&
isEmojiUnicode &&
- !isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion)
+ !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
) {
// CSS sprite fallback takes precedence over image fallback
if (hasCssSpriteFalback) {
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
- } else if (hasImageFallback) {
- this.innerHTML = emojiImageTag(name, fallbackSrc);
} else {
- const src = assembleFallbackImageSrc(name);
- this.innerHTML = emojiImageTag(name, src);
+ import(/* webpackChunkName: 'emoji' */ '../emoji')
+ .then(({ emojiImageTag, emojiFallbackImageSrc }) => {
+ if (hasImageFallback) {
+ this.innerHTML = emojiImageTag(name, fallbackSrc);
+ } else {
+ const src = emojiFallbackImageSrc(name);
+ this.innerHTML = emojiImageTag(name, src);
+ }
+ })
+ .catch(() => {
+ // do nothing
+ });
}
}
};
@@ -110,9 +52,3 @@ function installGlEmojiElement() {
prototype: GlEmojiElementProto,
});
}
-
-export {
- installGlEmojiElement,
- glEmojiTag,
- emojiImageTag,
-};
diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js
deleted file mode 100644
index be4aeb32c46..00000000000
--- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-
-function isEmojiNameValid(inputName) {
- const name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ?
- emojiAliases[inputName] : inputName;
-
- return name && emojiMap[name];
-}
-
-export default isEmojiNameValid;
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
deleted file mode 100644
index 4f8884d05ac..00000000000
--- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
+++ /dev/null
@@ -1,120 +0,0 @@
-// On Windows, flags render as two-letter country codes, see http://emojipedia.org/flags/
-const flagACodePoint = 127462; // parseInt('1F1E6', 16)
-const flagZCodePoint = 127487; // parseInt('1F1FF', 16)
-function isFlagEmoji(emojiUnicode) {
- const cp = emojiUnicode.codePointAt(0);
- // Length 4 because flags are made of 2 characters which are surrogate pairs
- return emojiUnicode.length === 4 && cp >= flagACodePoint && cp <= flagZCodePoint;
-}
-
-// Chrome <57 renders keycaps oddly
-// See https://bugs.chromium.org/p/chromium/issues/detail?id=632294
-// Same issue on Windows also fixed in Chrome 57, http://i.imgur.com/rQF7woO.png
-function isKeycapEmoji(emojiUnicode) {
- return emojiUnicode.length === 3 && emojiUnicode[2] === '\u20E3';
-}
-
-// Check for a skin tone variation emoji which aren't always supported
-const tone1 = 127995;// parseInt('1F3FB', 16)
-const tone5 = 127999;// parseInt('1F3FF', 16)
-function isSkinToneComboEmoji(emojiUnicode) {
- return emojiUnicode.length > 2 && Array.from(emojiUnicode).some((char) => {
- const cp = char.codePointAt(0);
- return cp >= tone1 && cp <= tone5;
- });
-}
-
-// macOS supports most skin tone emoji's but
-// doesn't support the skin tone versions of horse racing
-const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
-function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
- const firstCharacter = Array.from(emojiUnicode)[0];
- return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint &&
- isSkinToneComboEmoji(emojiUnicode);
-}
-
-// Check for `family_*`, `kiss_*`, `couple_*`
-// For ex. Windows 8.1 Firefox 51.0.1, doesn't support these
-const zwj = 8205; // parseInt('200D', 16)
-const personStartCodePoint = 128102; // parseInt('1F466', 16)
-const personEndCodePoint = 128105; // parseInt('1F469', 16)
-function isPersonZwjEmoji(emojiUnicode) {
- let hasPersonEmoji = false;
- let hasZwj = false;
- Array.from(emojiUnicode).forEach((character) => {
- const cp = character.codePointAt(0);
- if (cp === zwj) {
- hasZwj = true;
- } else if (cp >= personStartCodePoint && cp <= personEndCodePoint) {
- hasPersonEmoji = true;
- }
- });
-
- return hasPersonEmoji && hasZwj;
-}
-
-// Helper so we don't have to run `isFlagEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
- const isFlagResult = isFlagEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.flag && isFlagResult) ||
- !isFlagResult
- );
-}
-
-// Helper so we don't have to run `isSkinToneComboEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) {
- const isSkinToneResult = isSkinToneComboEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.skinToneModifier && isSkinToneResult) ||
- !isSkinToneResult
- );
-}
-
-// Helper func so we don't have to run `isHorceRacingSkinToneComboEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) {
- const isHorseRacingSkinToneResult = isHorceRacingSkinToneComboEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.horseRacing && isHorseRacingSkinToneResult) ||
- !isHorseRacingSkinToneResult
- );
-}
-
-// Helper so we don't have to run `isPersonZwjEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode) {
- const isPersonZwjResult = isPersonZwjEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.personZwj && isPersonZwjResult) ||
- !isPersonZwjResult
- );
-}
-
-// Takes in a support map and determines whether
-// the given unicode emoji is supported on the platform.
-//
-// Combines all the edge case tests into a one-stop shop method
-function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVersion) {
- const isOlderThanChrome57 = unicodeSupportMap.meta && unicodeSupportMap.meta.isChrome &&
- unicodeSupportMap.meta.chromeVersion < 57;
-
- // For comments about each scenario, see the comments above each individual respective function
- return unicodeSupportMap[unicodeVersion] &&
- !(isOlderThanChrome57 && isKeycapEmoji(emojiUnicode)) &&
- checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) &&
- checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) &&
- checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) &&
- checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode);
-}
-
-export {
- isEmojiUnicodeSupported,
- isFlagEmoji,
- isKeycapEmoji,
- isSkinToneComboEmoji,
- isHorceRacingSkinToneComboEmoji,
- isPersonZwjEmoji,
-};
diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
deleted file mode 100644
index 257df55e54f..00000000000
--- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import AccessorUtilities from '../../lib/utils/accessor';
-
-const unicodeSupportTestMap = {
- // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
- // occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
- // woman, biking (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
- // sexZwj: '\u{1F6B4}\u{200D}\u{2640}',
- // family_mwgb
- // Windows 8.1, Firefox 51.0.1 does not support `family_`, `kiss_`, `couple_`
- personZwj: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
- // horse_racing_tone5
- // Special case that is not supported on macOS 10.12 even though `skinToneModifier` succeeds
- horseRacing: '\u{1F3C7}\u{1F3FF}',
- // US flag, http://emojipedia.org/flags/
- flag: '\u{1F1FA}\u{1F1F8}',
- // http://emojipedia.org/modifiers/
- skinToneModifier: [
- // spy_tone5
- '\u{1F575}\u{1F3FF}',
- // person_with_ball_tone5
- '\u{26F9}\u{1F3FF}',
- // angel_tone5
- '\u{1F47C}\u{1F3FF}',
- ],
- // rofl, http://emojipedia.org/unicode-9.0/
- '9.0': '\u{1F923}',
- // metal, http://emojipedia.org/unicode-8.0/
- '8.0': '\u{1F918}',
- // spy, http://emojipedia.org/unicode-7.0/
- '7.0': '\u{1F575}',
- // expressionless, http://emojipedia.org/unicode-6.1/
- 6.1: '\u{1F611}',
- // japanese_goblin, http://emojipedia.org/unicode-6.0/
- '6.0': '\u{1F47A}',
- // sailboat, http://emojipedia.org/unicode-5.2/
- 5.2: '\u{26F5}',
- // mahjong, http://emojipedia.org/unicode-5.1/
- 5.1: '\u{1F004}',
- // gear, http://emojipedia.org/unicode-4.1/
- 4.1: '\u{2699}',
- // zap, http://emojipedia.org/unicode-4.0/
- '4.0': '\u{26A1}',
- // recycle, http://emojipedia.org/unicode-3.2/
- 3.2: '\u{267B}',
- // information_source, http://emojipedia.org/unicode-3.0/
- '3.0': '\u{2139}',
- // heart, http://emojipedia.org/unicode-1.1/
- 1.1: '\u{2764}',
-};
-
-function checkPixelInImageDataArray(pixelOffset, imageDataArray) {
- // `4 *` because RGBA
- const indexOffset = 4 * pixelOffset;
- const hasColor = imageDataArray[indexOffset + 0] ||
- imageDataArray[indexOffset + 1] ||
- imageDataArray[indexOffset + 2];
- const isVisible = imageDataArray[indexOffset + 3];
- // Check for some sort of color other than black
- if (hasColor && isVisible) {
- return true;
- }
- return false;
-}
-
-const chromeMatches = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
-const isChrome = chromeMatches && chromeMatches.length > 0;
-const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatches[1], 10);
-
-// We use 16px because mobile Safari (iOS 9.3) doesn't properly scale emojis :/
-// See 32px, https://i.imgur.com/htY6Zym.png
-// See 16px, https://i.imgur.com/FPPsIF8.png
-const fontSize = 16;
-function generateUnicodeSupportMap(testMap) {
- const testMapKeys = Object.keys(testMap);
- const numTestEntries = testMapKeys
- .reduce((list, testKey) => list.concat(testMap[testKey]), []).length;
-
- const canvas = document.createElement('canvas');
- (window.gl || window).testEmojiUnicodeSupportMapCanvas = canvas;
- const ctx = canvas.getContext('2d');
- canvas.width = (2 * fontSize);
- canvas.height = (numTestEntries * fontSize);
- ctx.fillStyle = '#000000';
- ctx.textBaseline = 'middle';
- ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`;
- // Write each emoji to the canvas vertically
- let writeIndex = 0;
- testMapKeys.forEach((testKey) => {
- const testEntry = testMap[testKey];
- [].concat(testEntry).forEach((emojiUnicode) => {
- ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2));
- writeIndex += 1;
- });
- });
-
- // Read from the canvas
- const resultMap = {};
- let readIndex = 0;
- testMapKeys.forEach((testKey) => {
- const testEntry = testMap[testKey];
- // This needs to be a `reduce` instead of `every` because we need to
- // keep the `readIndex` in sync from the writes by running all entries
- const isTestSatisfied = [].concat(testEntry).reduce((isSatisfied) => {
- // Sample along the vertical-middle for a couple of characters
- const imageData = ctx.getImageData(
- 0,
- (readIndex * fontSize) + (fontSize / 2),
- 2 * fontSize,
- 1,
- ).data;
-
- let isValidEmoji = false;
- for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) {
- const isLookingAtFirstChar = currentPixel < fontSize;
- const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2));
- // Check for the emoji somewhere along the row
- if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) {
- isValidEmoji = true;
-
- // Check to see that nothing is rendered next to the first character
- // to ensure that the ZWJ sequence rendered as one piece
- } else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) {
- isValidEmoji = false;
- break;
- }
- }
-
- readIndex += 1;
- return isSatisfied && isValidEmoji;
- }, true);
-
- resultMap[testKey] = isTestSatisfied;
- });
-
- resultMap.meta = {
- isChrome,
- chromeVersion,
- };
-
- return resultMap;
-}
-
-function getUnicodeSupportMap() {
- let unicodeSupportMap;
- let userAgentFromCache;
-
- const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-
- if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
-
- try {
- unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
- } catch (err) {
- // swallow
- }
-
- if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) {
- unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap);
-
- if (isLocalStorageAvailable) {
- window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
- window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
- }
- }
-
- return unicodeSupportMap;
-}
-
-export {
- getUnicodeSupportMap,
- generateUnicodeSupportMap,
-};
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 5b931e6cfa6..84fef4d8b4f 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,9 +1,15 @@
import './autosize';
import './bind_in_out';
+import './markdown/render_gfm';
+import initCopyAsGFM from './markdown/copy_as_gfm';
+import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior';
-import { installGlEmojiElement } from './gl_emoji';
+import installGlEmojiElement from './gl_emoji';
import './quick_submit';
import './requires_input';
import './toggler_behavior';
+import '../preview_markdown';
installGlEmojiElement();
+initCopyAsGFM();
+initCopyToClipboard();
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
new file mode 100644
index 00000000000..75cf90de0b5
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -0,0 +1,501 @@
+/* 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 */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
+import { placeholderImage } from '~/lazy_loader';
+
+const gfmRules = {
+ // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
+ // GitLab Flavored Markdown (GFM) to HTML.
+ // These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
+ // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
+ // from GFM should have a handler here, in reverse order.
+ // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
+ InlineDiffFilter: {
+ 'span.idiff.addition'(el, text) {
+ return `{+${text}+}`;
+ },
+ 'span.idiff.deletion'(el, text) {
+ return `{-${text}-}`;
+ },
+ },
+ TaskListFilter: {
+ 'input[type=checkbox].task-list-item-checkbox'(el) {
+ return `[${el.checked ? 'x' : ' '}]`;
+ },
+ },
+ ReferenceFilter: {
+ '.tooltip'(el) {
+ return '';
+ },
+ 'a.gfm:not([data-link=true])'(el, text) {
+ return el.dataset.original || text;
+ },
+ },
+ AutolinkFilter: {
+ 'a'(el, text) {
+ // Fallback on the regular MarkdownFilter's `a` handler.
+ if (text !== el.getAttribute('href')) return false;
+
+ return text;
+ },
+ },
+ TableOfContentsFilter: {
+ 'ul.section-nav'(el) {
+ return '[[_TOC_]]';
+ },
+ },
+ EmojiFilter: {
+ 'img.emoji'(el) {
+ return el.getAttribute('alt');
+ },
+ 'gl-emoji'(el) {
+ return `:${el.getAttribute('data-name')}:`;
+ },
+ },
+ ImageLinkFilter: {
+ 'a.no-attachment-icon'(el, text) {
+ return text;
+ },
+ },
+ ImageLazyLoadFilter: {
+ 'img'(el, text) {
+ return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
+ },
+ },
+ VideoLinkFilter: {
+ '.video-container'(el) {
+ const videoEl = el.querySelector('video');
+ if (!videoEl) return false;
+
+ return CopyAsGFM.nodeToGFM(videoEl);
+ },
+ 'video'(el) {
+ return `![${el.dataset.title}](${el.getAttribute('src')})`;
+ },
+ },
+ MermaidFilter: {
+ 'svg.mermaid'(el, text) {
+ const sourceEl = el.querySelector('text.source');
+ if (!sourceEl) return false;
+
+ return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``;
+ },
+ 'svg.mermaid style, svg.mermaid g'(el, text) {
+ // We don't want to include the content of these elements in the copied text.
+ return '';
+ },
+ },
+ MathFilter: {
+ 'pre.code.math[data-math-style=display]'(el, text) {
+ return `\`\`\`math\n${text.trim()}\n\`\`\``;
+ },
+ 'code.code.math[data-math-style=inline]'(el, text) {
+ return `$\`${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) {
+ const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
+ if (!mathAnnotation) return false;
+
+ return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
+ },
+ 'span.katex-html'(el) {
+ // We don't want to include the content of this element in the copied text.
+ return '';
+ },
+ 'annotation[encoding="application/x-tex"]'(el, text) {
+ return text.trim();
+ },
+ },
+ SanitizationFilter: {
+ 'a[name]:not([href]):empty'(el) {
+ return el.outerHTML;
+ },
+ 'dl'(el, text) {
+ let lines = text.trim().split('\n');
+ // Add two spaces to the front of subsequent list items lines,
+ // or leave the line entirely blank.
+ lines = lines.map((l) => {
+ const line = l.trim();
+ if (line.length === 0) return '';
+
+ return ` ${line}`;
+ });
+
+ return `<dl>\n${lines.join('\n')}\n</dl>`;
+ },
+ 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) {
+ const tag = el.nodeName.toLowerCase();
+ return `<${tag}>${text}</${tag}>`;
+ },
+ },
+ SyntaxHighlightFilter: {
+ 'pre.code.highlight'(el, t) {
+ const text = t.trimRight();
+
+ let lang = el.getAttribute('lang');
+ if (!lang || lang === 'plaintext') {
+ lang = '';
+ }
+
+ // Prefixes lines with 4 spaces if the code contains triple backticks
+ if (lang === '' && text.match(/^```/gm)) {
+ return text.split('\n').map((l) => {
+ const line = l.trim();
+ if (line.length === 0) return '';
+
+ return ` ${line}`;
+ }).join('\n');
+ }
+
+ return `\`\`\`${lang}\n${text}\n\`\`\``;
+ },
+ 'pre > code'(el, text) {
+ // Don't wrap code blocks in ``
+ return text;
+ },
+ },
+ MarkdownFilter: {
+ 'br'(el) {
+ // Two spaces at the end of a line are turned into a BR
+ return ' ';
+ },
+ 'code'(el, text) {
+ let backtickCount = 1;
+ const backtickMatch = text.match(/`+/);
+ if (backtickMatch) {
+ backtickCount = backtickMatch[0].length + 1;
+ }
+
+ const backticks = Array(backtickCount + 1).join('`');
+ const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
+
+ return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
+ },
+ 'blockquote'(el, text) {
+ return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
+ },
+ 'img'(el) {
+ const imageSrc = el.src;
+ const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || '');
+ return `![${el.getAttribute('alt')}](${imageUrl})`;
+ },
+ 'a.anchor'(el, text) {
+ // Don't render a Markdown link for the anchor link inside a heading
+ return text;
+ },
+ 'a'(el, text) {
+ return `[${text}](${el.getAttribute('href')})`;
+ },
+ 'li'(el, text) {
+ const lines = text.trim().split('\n');
+ const firstLine = `- ${lines.shift()}`;
+ // Add four spaces to the front of subsequent list items lines,
+ // or leave the line entirely blank.
+ const nextLines = lines.map((s) => {
+ if (s.trim().length === 0) return '';
+
+ return ` ${s}`;
+ });
+
+ return `${firstLine}\n${nextLines.join('\n')}`;
+ },
+ 'ul'(el, text) {
+ return text;
+ },
+ 'ol'(el, text) {
+ // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
+ return text.replace(/^- /mg, '1. ');
+ },
+ 'h1'(el, text) {
+ return `# ${text.trim()}`;
+ },
+ 'h2'(el, text) {
+ return `## ${text.trim()}`;
+ },
+ 'h3'(el, text) {
+ return `### ${text.trim()}`;
+ },
+ 'h4'(el, text) {
+ return `#### ${text.trim()}`;
+ },
+ 'h5'(el, text) {
+ return `##### ${text.trim()}`;
+ },
+ 'h6'(el, text) {
+ return `###### ${text.trim()}`;
+ },
+ 'strong'(el, text) {
+ return `**${text}**`;
+ },
+ 'em'(el, text) {
+ return `_${text}_`;
+ },
+ 'del'(el, text) {
+ return `~~${text}~~`;
+ },
+ 'sup'(el, text) {
+ return `^${text}`;
+ },
+ 'hr'(el) {
+ return '-----';
+ },
+ 'table'(el) {
+ const theadEl = el.querySelector('thead');
+ const tbodyEl = el.querySelector('tbody');
+ if (!theadEl || !tbodyEl) return false;
+
+ const theadText = CopyAsGFM.nodeToGFM(theadEl);
+ const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
+
+ return [theadText, tbodyText].join('\n');
+ },
+ 'thead'(el, text) {
+ const cells = _.map(el.querySelectorAll('th'), (cell) => {
+ let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
+
+ let before = '';
+ let after = '';
+ switch (cell.style.textAlign) {
+ case 'center':
+ before = ':';
+ after = ':';
+ chars -= 2;
+ break;
+ case 'right':
+ after = ':';
+ chars -= 1;
+ break;
+ default:
+ break;
+ }
+
+ chars = Math.max(chars, 3);
+
+ const middle = Array(chars + 1).join('-');
+
+ return before + middle + after;
+ });
+
+ const separatorRow = `|${cells.join('|')}|`;
+
+ return [text, separatorRow].join('\n');
+ },
+ '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(' | ')} |`;
+ },
+ },
+};
+
+export class CopyAsGFM {
+ constructor() {
+ // iOS currently does not support clipboardData.setData(). This bug should
+ // be fixed in iOS 12, but for now we'll disable this for all iOS browsers
+ // ref: https://trac.webkit.org/changeset/222228/webkit
+ const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
+ const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
+ if (isIOS) return;
+
+ $(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);
+ }
+
+ static copyAsGFM(e, transformer) {
+ const clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ const documentFragment = getSelectedFragment();
+ if (!documentFragment) return;
+
+ const el = transformer(documentFragment.cloneNode(true), e.currentTarget);
+ if (!el) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ clipboardData.setData('text/plain', el.textContent);
+ clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
+ }
+
+ 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, (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) {
+ const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
+ switch (gfmElements.length) {
+ case 0: {
+ return documentFragment;
+ }
+ case 1: {
+ return gfmElements[0];
+ }
+ default: {
+ const allGfmElement = document.createElement('div');
+
+ for (let i = 0; i < gfmElements.length; i += 1) {
+ const gfmElement = gfmElements[i];
+ allGfmElement.appendChild(gfmElement);
+ allGfmElement.appendChild(document.createTextNode('\n\n'));
+ }
+
+ return allGfmElement;
+ }
+ }
+ }
+
+ static transformCodeSelection(documentFragment, target) {
+ let lineSelector = '.line';
+
+ if (target) {
+ const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0];
+ if (lineClass) {
+ lineSelector = `.line_content.${lineClass} ${lineSelector}`;
+ }
+ }
+
+ const lineElements = documentFragment.querySelectorAll(lineSelector);
+
+ let codeElement;
+ if (lineElements.length > 1) {
+ codeElement = document.createElement('pre');
+ codeElement.className = 'code highlight';
+
+ const lang = lineElements[0].getAttribute('lang');
+ if (lang) {
+ codeElement.setAttribute('lang', lang);
+ }
+ } else {
+ codeElement = document.createElement('code');
+ }
+
+ if (lineElements.length > 0) {
+ for (let i = 0; i < lineElements.length; i += 1) {
+ const lineElement = lineElements[i];
+ codeElement.appendChild(lineElement);
+ codeElement.appendChild(document.createTextNode('\n'));
+ }
+ } else {
+ codeElement.appendChild(documentFragment);
+ }
+
+ return codeElement;
+ }
+
+ static nodeToGFM(node, respectWhitespaceParam = false) {
+ if (node.nodeType === Node.COMMENT_NODE) {
+ return '';
+ }
+
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.textContent;
+ }
+
+ const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
+
+ const text = this.innerGFM(node, respectWhitespace);
+
+ if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+ return text;
+ }
+
+ for (const filter in gfmRules) {
+ const rules = gfmRules[filter];
+
+ for (const selector in rules) {
+ const func = rules[selector];
+
+ if (!nodeMatchesSelector(node, selector)) continue;
+
+ 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;
+ }
+ }
+
+ return text;
+ }
+
+ static innerGFM(parentNode, respectWhitespace = false) {
+ const nodes = parentNode.childNodes;
+
+ const clonedParentNode = parentNode.cloneNode(true);
+ const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
+
+ for (let i = 0; i < nodes.length; i += 1) {
+ const node = nodes[i];
+ const clonedNode = clonedNodes[i];
+
+ const text = this.nodeToGFM(node, respectWhitespace);
+
+ // `clonedNode.replaceWith(text)` is not yet widely supported
+ clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
+ }
+
+ let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
+
+ if (!respectWhitespace) {
+ nodeText = nodeText.trim();
+ }
+
+ return nodeText;
+ }
+}
+
+// Export CopyAsGFM as a global for rspec to access
+// see /spec/features/copy_as_gfm_spec.rb
+if (process.env.NODE_ENV !== 'production') {
+ window.CopyAsGFM = CopyAsGFM;
+}
+
+export default function initCopyAsGFM() {
+ return new CopyAsGFM();
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
new file mode 100644
index 00000000000..dbff2bd4b10
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import syntaxHighlight from '~/syntax_highlight';
+import renderMath from './render_math';
+import renderMermaid from './render_mermaid';
+
+// Render Gitlab flavoured Markdown
+//
+// Delegates to syntax highlight and render math & mermaid diagrams.
+//
+$.fn.renderGFM = function renderGFM() {
+ syntaxHighlight(this.find('.js-syntax-highlight'));
+ renderMath(this.find('.js-render-math'));
+ renderMermaid(this.find('.js-render-mermaid'));
+ return this;
+};
+
+$(() => $('body').renderGFM());
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
new file mode 100644
index 00000000000..7dcf1aeed17
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -0,0 +1,38 @@
+import $ from 'jquery';
+import { __ } from '~/locale';
+import flash from '~/flash';
+
+// Renders math using KaTeX in any element with the
+// `js-render-math` class
+//
+// ### Example Markup
+//
+// <code class="js-render-math"></div>
+//
+
+// Loop over all math elements and render math
+function renderWithKaTeX(elements, katex) {
+ elements.each(function katexElementsLoop() {
+ const mathNode = $('<span></span>');
+ const $this = $(this);
+
+ const display = $this.attr('data-math-style') === 'display';
+ try {
+ katex.render($this.text(), mathNode.get(0), { displayMode: display, throwOnError: false });
+ mathNode.insertAfter($this);
+ $this.remove();
+ } catch (err) {
+ throw err;
+ }
+ });
+}
+
+export default function renderMath($els) {
+ if (!$els.length) return;
+ Promise.all([
+ import(/* webpackChunkName: 'katex' */ 'katex'),
+ import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.css'),
+ ]).then(([katex]) => {
+ renderWithKaTeX($els, katex);
+ }).catch(() => flash(__('An error occurred while rendering KaTeX')));
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
new file mode 100644
index 00000000000..56b1896e9f1
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -0,0 +1,57 @@
+import flash from '~/flash';
+
+// Renders diagrams and flowcharts from text using Mermaid in any element with the
+// `js-render-mermaid` class.
+//
+// Example markup:
+//
+// <pre class="js-render-mermaid">
+// graph TD;
+// A-- > B;
+// A-- > C;
+// B-- > D;
+// C-- > D;
+// </pre>
+//
+
+export default function renderMermaid($els) {
+ if (!$els.length) return;
+
+ import(/* webpackChunkName: 'mermaid' */ 'blackst0ne-mermaid').then((mermaid) => {
+ mermaid.initialize({
+ // mermaid core options
+ mermaid: {
+ startOnLoad: false,
+ },
+ // mermaidAPI options
+ theme: 'neutral',
+ });
+
+ $els.each((i, el) => {
+ const source = el.textContent;
+
+ // Remove any extra spans added by the backend syntax highlighting.
+ Object.assign(el, { textContent: source });
+
+ mermaid.init(undefined, el, (id) => {
+ const svg = document.getElementById(id);
+
+ svg.classList.add('mermaid');
+
+ // pre > code > svg
+ svg.closest('pre').replaceWith(svg);
+
+ // We need to add the original source into the DOM to allow Copy-as-GFM
+ // to access it.
+ const sourceEl = document.createElement('text');
+ sourceEl.classList.add('source');
+ sourceEl.setAttribute('display', 'none');
+ sourceEl.textContent = source;
+
+ svg.appendChild(sourceEl);
+ });
+ });
+ }).catch((err) => {
+ flash(`Can't load mermaid module: ${err}`);
+ });
+}
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 1f9e0448084..3ec932bdb73 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -1,4 +1,6 @@
+import $ from 'jquery';
import '../commons/bootstrap';
+import { isInIssuePage } from '../lib/utils/common_utils';
// Quick Submit behavior
//
@@ -40,11 +42,14 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
e.preventDefault();
const $form = $(e.target).closest('form');
- const $submitButton = $form.find('input[type=submit], button[type=submit]');
+ const $submitButton = $form.find('input[type=submit], button[type=submit]').first();
- if (!$submitButton.attr('disabled')) {
+ if (!$submitButton.prop('disabled')) {
$submitButton.trigger('click', [e]);
- $submitButton.disable();
+
+ if (!isInIssuePage()) {
+ $submitButton.disable();
+ }
}
});
@@ -68,5 +73,5 @@ $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-q
title,
trigger: 'manual',
});
- $this.tooltip('show').one('blur', () => $this.tooltip('hide'));
+ $this.tooltip('show').one('blur click', () => $this.tooltip('hide'));
});
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index b20d108aa25..ffff4ddb71a 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+import _ from 'underscore';
import '../commons/bootstrap';
// Requires Input behavior
@@ -39,7 +41,7 @@ $.fn.requiresInput = function requiresInput() {
// based on the option selected
function hideOrShowHelpBlock(form) {
const selected = $('.js-select-namespace option:selected');
- if (selected.length && selected.data('options-parent') === 'groups') {
+ if (selected.length && selected.data('optionsParent') === 'groups') {
form.find('.help-block').hide();
} else if (selected.length) {
form.find('.help-block').show();
@@ -48,7 +50,9 @@ function hideOrShowHelpBlock(form) {
$(() => {
const $form = $('form.js-requires-input');
- $form.requiresInput();
- hideOrShowHelpBlock($form);
- $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
+ if ($form) {
+ $form.requiresInput();
+ hideOrShowHelpBlock($form);
+ $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
+ }
});
diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js
new file mode 100644
index 00000000000..0d6e0dbefcc
--- /dev/null
+++ b/app/assets/javascripts/behaviors/secret_values.js
@@ -0,0 +1,47 @@
+import { n__ } from '../locale';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+
+export default class SecretValues {
+ constructor({
+ container,
+ valueSelector = '.js-secret-value',
+ placeholderSelector = '.js-secret-value-placeholder',
+ }) {
+ this.container = container;
+ this.valueSelector = valueSelector;
+ this.placeholderSelector = placeholderSelector;
+ }
+
+ init() {
+ this.revealButton = this.container.querySelector('.js-secret-value-reveal-button');
+
+ if (this.revealButton) {
+ const isRevealed = convertPermissionToBoolean(this.revealButton.dataset.secretRevealStatus);
+ this.updateDom(isRevealed);
+
+ this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this));
+ }
+ }
+
+ onRevealButtonClicked() {
+ const previousIsRevealed = convertPermissionToBoolean(
+ this.revealButton.dataset.secretRevealStatus,
+ );
+ this.updateDom(!previousIsRevealed);
+ }
+
+ updateDom(isRevealed) {
+ const values = this.container.querySelectorAll(this.valueSelector);
+ values.forEach((value) => {
+ value.classList.toggle('hide', !isRevealed);
+ });
+
+ const placeholders = this.container.querySelectorAll(this.placeholderSelector);
+ placeholders.forEach((placeholder) => {
+ placeholder.classList.toggle('hide', isRevealed);
+ });
+
+ this.revealButton.textContent = isRevealed ? n__('Hide value', 'Hide values', values.length) : n__('Reveal value', 'Reveal values', values.length);
+ this.revealButton.dataset.secretRevealStatus = isRevealed;
+ }
+}
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 77e92ff8caf..4446be0e52f 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+import { getLocationHash } from '../lib/utils/url_utility';
// Toggle button. Show/hide content inside parent container.
// Button does not change visibility. If button has icon - it changes chevron style.
@@ -12,7 +14,7 @@ $(() => {
const $container = $(container);
$container
- .find('.js-toggle-button .fa')
+ .find('.js-toggle-button .fa-chevron-up, .js-toggle-button .fa-chevron-down')
.toggleClass('fa-chevron-up', toggleState)
.toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
@@ -22,7 +24,7 @@ $(() => {
}
$('body').on('click', '.js-toggle-button', function toggleButton(e) {
- e.target.classList.toggle('open');
+ e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'open');
toggleContainer($(this).closest('.js-toggle-container'));
const targetTag = e.currentTarget.tagName.toLowerCase();
@@ -33,7 +35,7 @@ $(() => {
// If we're accessing a permalink, ensure it is not inside a
// closed js-toggle-container!
- const hash = window.gl.utils.getLocationHash();
+ const hash = getLocationHash();
const anchor = hash && document.getElementById(hash);
const container = anchor && $(anchor).closest('.js-toggle-container');
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
index 8641a6fdae6..06ef86ecb77 100644
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -1,14 +1,13 @@
-/* global Flash */
-
+import Flash from '../flash';
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
function onError() {
- const flash = new window.Flash('Balsamiq file could not be loaded.');
+ const flash = new Flash('Balsamiq file could not be loaded.');
return flash;
}
-function loadBalsamiqFile() {
+export default function loadBalsamiqFile() {
const viewer = document.getElementById('js-balsamiq-viewer');
if (!(viewer instanceof Element)) return;
@@ -18,5 +17,3 @@ function loadBalsamiqFile() {
const balsamiqViewer = new BalsamiqViewer(viewer);
balsamiqViewer.loadFile(endpoint).catch(onError);
}
-
-$(loadBalsamiqFile);
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index dc636050221..ff1739b1679 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,9 +1,28 @@
/* eslint-disable func-names, object-shorthand, prefer-arrow-callback */
-/* global Dropzone */
+import $ from 'jquery';
+import Dropzone from 'dropzone';
+import { visitUrl } from '../lib/utils/url_utility';
+import { HIDDEN_CLASS } from '../lib/utils/constants';
+import csrf from '../lib/utils/csrf';
+
+Dropzone.autoDiscover = false;
+
+function toggleLoading($el, $icon, loading) {
+ if (loading) {
+ $el.disable();
+ $icon.removeClass(HIDDEN_CLASS);
+ } else {
+ $el.enable();
+ $icon.addClass(HIDDEN_CLASS);
+ }
+}
export default class BlobFileDropzone {
constructor(form, method) {
const formDropzone = form.find('.dropzone');
+ const submitButton = form.find('#submit-all');
+ const submitButtonLoadingIcon = submitButton.find('.js-loading-icon');
+ const dropzoneMessage = form.find('.dz-message');
Dropzone.autoDiscover = false;
const dropzone = formDropzone.dropzone({
@@ -21,17 +40,23 @@ export default class BlobFileDropzone {
maxFiles: 1,
addRemoveLinks: true,
previewsContainer: '.dropzone-previews',
- headers: {
- 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content'),
- },
+ headers: csrf.headers,
init: function () {
this.on('addedfile', function () {
+ toggleLoading(submitButton, submitButtonLoadingIcon, false);
+ dropzoneMessage.addClass(HIDDEN_CLASS);
$('.dropzone-alerts').html('').hide();
});
+ this.on('removedfile', function () {
+ toggleLoading(submitButton, submitButtonLoadingIcon, false);
+ dropzoneMessage.removeClass(HIDDEN_CLASS);
+ });
this.on('success', function (header, response) {
- window.location.href = response.filePath;
+ $('#modal-upload-blob').modal('hide');
+ visitUrl(response.filePath);
});
this.on('maxfilesexceeded', function (file) {
+ dropzoneMessage.addClass(HIDDEN_CLASS);
this.removeFile(file);
});
this.on('sending', function (file, xhr, formData) {
@@ -48,14 +73,15 @@ export default class BlobFileDropzone {
},
});
- const submitButton = form.find('#submit-all')[0];
- submitButton.addEventListener('click', function (e) {
+ submitButton.on('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
// eslint-disable-next-line no-alert
alert('Please select a file');
+ return false;
}
+ toggleLoading(submitButton, submitButtonLoadingIcon, true);
dropzone[0].dropzone.processQueue();
return false;
});
diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js
index 47c431fb809..476b9405a9e 100644
--- a/app/assets/javascripts/blob/blob_fork_suggestion.js
+++ b/app/assets/javascripts/blob/blob_fork_suggestion.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
const defaults = {
// Buttons that will show the `suggestionSections`
// has `data-fork-path`, and `data-action`
diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js
index c8f68860fbd..d36d9f0de2d 100644
--- a/app/assets/javascripts/blob/blob_line_permalink_updater.js
+++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js
@@ -1,7 +1,9 @@
+import { getLocationHash } from '../lib/utils/url_utility';
+
const lineNumberRe = /^L[0-9]+/;
const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => {
- const hash = gl.utils.getLocationHash();
+ const hash = getLocationHash();
if (hash && lineNumberRe.test(hash)) {
const hashUrlString = `#${hash}`;
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index a20c6ca7a21..030ca1907e5 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
-/* global Flash */
+import $ from 'jquery';
+import Flash from '../flash';
import FileTemplateTypeSelector from './template_selectors/type_selector';
import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
import DockerfileSelector from './template_selectors/dockerfile_selector';
@@ -236,7 +237,7 @@ export default class FileTemplateMediator {
}
setFilename(name) {
- this.$filenameInput.val(name);
+ this.$filenameInput.val(name).trigger('change');
}
getSelected() {
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index 5ae30990aea..e52cf249f3a 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class FileTemplateSelector {
constructor(mediator) {
this.mediator = mediator;
diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index 36fe8a7184f..6f1350e80fc 100644
--- a/app/assets/javascripts/blob/notebook/index.js
+++ b/app/assets/javascripts/blob/notebook/index.js
@@ -1,15 +1,16 @@
/* eslint-disable no-new */
import Vue from 'vue';
-import VueResource from 'vue-resource';
+import axios from '../../lib/utils/axios_utils';
import notebookLab from '../../notebook/index.vue';
-Vue.use(VueResource);
-
export default () => {
const el = document.getElementById('js-notebook-viewer');
new Vue({
el,
+ components: {
+ notebookLab,
+ },
data() {
return {
error: false,
@@ -18,8 +19,41 @@ export default () => {
json: {},
};
},
- components: {
- notebookLab,
+ mounted() {
+ if (gon.katex_css_url) {
+ const katexStyles = document.createElement('link');
+ katexStyles.setAttribute('rel', 'stylesheet');
+ katexStyles.setAttribute('href', gon.katex_css_url);
+ document.head.appendChild(katexStyles);
+ }
+
+ if (gon.katex_js_url) {
+ const katexScript = document.createElement('script');
+ katexScript.addEventListener('load', () => {
+ this.loadFile();
+ });
+ katexScript.setAttribute('src', gon.katex_js_url);
+ document.head.appendChild(katexScript);
+ } else {
+ this.loadFile();
+ }
+ },
+ methods: {
+ loadFile() {
+ axios.get(el.dataset.endpoint)
+ .then(res => res.data)
+ .then((data) => {
+ this.json = data;
+ this.loading = false;
+ })
+ .catch((e) => {
+ if (e.status !== 200) {
+ this.loadError = true;
+ }
+
+ this.error = true;
+ });
+ },
},
template: `
<div class="container-fluid md prepend-top-default append-bottom-default">
@@ -40,48 +74,13 @@ export default () => {
class="text-center"
v-if="error">
<span v-if="loadError">
- An error occured whilst loading the file. Please try again later.
+ An error occurred whilst loading the file. Please try again later.
</span>
<span v-else>
- An error occured whilst parsing the file.
+ An error occurred whilst parsing the file.
</span>
</p>
</div>
`,
- methods: {
- loadFile() {
- this.$http.get(el.dataset.endpoint)
- .then((res) => {
- this.json = res.json();
- this.loading = false;
- })
- .catch((e) => {
- if (e.status) {
- this.loadError = true;
- }
-
- this.error = true;
- });
- },
- },
- mounted() {
- if (gon.katex_css_url) {
- const katexStyles = document.createElement('link');
- katexStyles.setAttribute('rel', 'stylesheet');
- katexStyles.setAttribute('href', gon.katex_css_url);
- document.head.appendChild(katexStyles);
- }
-
- if (gon.katex_js_url) {
- const katexScript = document.createElement('script');
- katexScript.addEventListener('load', () => {
- this.loadFile();
- });
- katexScript.setAttribute('src', gon.katex_js_url);
- document.head.appendChild(katexScript);
- } else {
- this.loadFile();
- }
- },
});
};
diff --git a/app/assets/javascripts/blob/notebook_viewer.js b/app/assets/javascripts/blob/notebook_viewer.js
index b7a0a195a92..226ae69893e 100644
--- a/app/assets/javascripts/blob/notebook_viewer.js
+++ b/app/assets/javascripts/blob/notebook_viewer.js
@@ -1,3 +1,3 @@
import renderNotebook from './notebook';
-document.addEventListener('DOMContentLoaded', renderNotebook);
+export default renderNotebook;
diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js
index 0ed915c1ac9..70136cc4087 100644
--- a/app/assets/javascripts/blob/pdf/index.js
+++ b/app/assets/javascripts/blob/pdf/index.js
@@ -7,6 +7,9 @@ export default () => {
return new Vue({
el,
+ components: {
+ pdfLab,
+ },
data() {
return {
error: false,
@@ -15,9 +18,6 @@ export default () => {
pdf: el.dataset.endpoint,
};
},
- components: {
- pdfLab,
- },
methods: {
onLoad() {
this.loading = false;
@@ -48,10 +48,10 @@ export default () => {
class="text-center"
v-if="error">
<span v-if="loadError">
- An error occured whilst loading the file. Please try again later.
+ An error occurred whilst loading the file. Please try again later.
</span>
<span v-else>
- An error occured whilst decoding the file.
+ An error occurred whilst decoding the file.
</span>
</p>
</div>
diff --git a/app/assets/javascripts/blob/pdf_viewer.js b/app/assets/javascripts/blob/pdf_viewer.js
index 91abe9dd699..cabbb396ea7 100644
--- a/app/assets/javascripts/blob/pdf_viewer.js
+++ b/app/assets/javascripts/blob/pdf_viewer.js
@@ -1,3 +1,3 @@
import renderPDF from './pdf';
-document.addEventListener('DOMContentLoaded', renderPDF);
+export default renderPDF;
diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js
index 0640dd26855..2c1c6339fdb 100644
--- a/app/assets/javascripts/blob/sketch_viewer.js
+++ b/app/assets/javascripts/blob/sketch_viewer.js
@@ -1,8 +1,8 @@
/* eslint-disable no-new */
import SketchLoader from './sketch';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const el = document.getElementById('js-sketch-viewer');
new SketchLoader(el);
-});
+};
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
index f611c4fe640..63236b6477f 100644
--- a/app/assets/javascripts/blob/stl_viewer.js
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -1,6 +1,6 @@
import Renderer from './3d_viewer';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const viewer = new Renderer(document.getElementById('js-stl-viewer'));
[].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => {
@@ -16,4 +16,4 @@ document.addEventListener('DOMContentLoaded', () => {
viewer.changeObjectMaterials(target.dataset.type);
});
});
-});
+};
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 888883163c5..9dfdb06007d 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,5 +1,7 @@
/* eslint-disable class-methods-use-this, no-unused-vars */
+import $ from 'jquery';
+
export default class TemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
this.pattern = pattern;
@@ -76,7 +78,7 @@ export default class TemplateSelector {
if (!skipFocus) this.editor.focus();
- if (this.editor instanceof jQuery) {
+ if (this.editor instanceof $) {
this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent);
}
}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 187fab084fd..137e1f5a099 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,7 +1,12 @@
-/* global Flash */
+import $ from 'jquery';
+import Flash from '../../flash';
+import { handleLocationHash } from '../../lib/utils/common_utils';
+import axios from '../../lib/utils/axios_utils';
+
export default class BlobViewer {
constructor() {
BlobViewer.initAuxiliaryViewer();
+ BlobViewer.initRichViewer();
this.initMainViewers();
}
@@ -13,6 +18,38 @@ export default class BlobViewer {
BlobViewer.loadViewer(auxiliaryViewer);
}
+ static initRichViewer() {
+ const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
+ if (!viewer || !viewer.dataset.richType) return;
+
+ const initViewer = promise => promise
+ .then(module => module.default(viewer))
+ .catch((error) => {
+ Flash('Error loading file viewer.');
+ throw error;
+ });
+
+ switch (viewer.dataset.richType) {
+ case 'balsamiq':
+ initViewer(import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer'));
+ break;
+ case 'notebook':
+ initViewer(import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'));
+ break;
+ case 'pdf':
+ initViewer(import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer'));
+ break;
+ case 'sketch':
+ initViewer(import(/* webpackChunkName: 'sketch_viewer' */ '../sketch_viewer'));
+ break;
+ case 'stl':
+ initViewer(import(/* webpackChunkName: 'stl_viewer' */ '../stl_viewer'));
+ break;
+ default:
+ break;
+ }
+ }
+
initMainViewers() {
this.$fileHolder = $('.file-holder');
if (!this.$fileHolder.length) return;
@@ -114,7 +151,7 @@ export default class BlobViewer {
$(viewer).renderGFM();
this.$fileHolder.trigger('highlight:line');
- gl.utils.handleLocationHash();
+ handleLocationHash();
this.toggleCopyButtonState();
})
@@ -125,25 +162,18 @@ export default class BlobViewer {
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;
- }
+ if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
+ return Promise.resolve(viewer);
+ }
- viewer.setAttribute('data-loading', 'true');
+ viewer.setAttribute('data-loading', 'true');
- $.ajax({
- url,
- dataType: 'JSON',
- })
- .fail(reject)
- .done((data) => {
+ return axios.get(url)
+ .then(({ data }) => {
viewer.innerHTML = data.html;
viewer.setAttribute('data-loaded', 'true');
- resolve(viewer);
+ return viewer;
});
- });
}
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 1c64ccf536f..4424232f642 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,19 +1,21 @@
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, vars-on-top, no-unused-vars, no-new, max-len */
/* global EditBlob */
-/* global NewCommitForm */
+import $ from 'jquery';
+import NewCommitForm from '../new_commit_form';
import EditBlob from './edit_blob';
import BlobFileDropzone from '../blob/blob_file_dropzone';
-$(() => {
+export default () => {
const editBlobForm = $('.js-edit-blob-form');
const uploadBlobForm = $('.js-upload-blob-form');
+ const deleteBlobForm = $('.js-delete-blob-form');
if (editBlobForm.length) {
- const urlRoot = editBlobForm.data('relative-url-root');
- const assetsPath = editBlobForm.data('assets-prefix');
- const blobLanguage = editBlobForm.data('blob-language');
- const currentAction = $('.js-file-title').data('current-action');
+ const urlRoot = editBlobForm.data('relativeUrlRoot');
+ const assetsPath = editBlobForm.data('assetsPrefix');
+ const blobLanguage = editBlobForm.data('blobLanguage');
+ const currentAction = $('.js-file-title').data('currentAction');
new EditBlob(`${urlRoot}${assetsPath}`, blobLanguage, currentAction);
new NewCommitForm(editBlobForm);
@@ -30,4 +32,8 @@ $(() => {
'.btn-upload-file',
);
}
-});
+
+ if (deleteBlobForm.length) {
+ new NewCommitForm(deleteBlobForm);
+ }
+};
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index b37988a674d..82a3d494b67 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,5 +1,9 @@
/* global ace */
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
import TemplateSelectorMediator from '../blob/file_template_mediator';
export default class EditBlob {
@@ -56,12 +60,14 @@ export default class EditBlob {
if (paneId === '#preview') {
this.$toggleButton.hide();
- return $.post(currentLink.data('preview-url'), {
+ axios.post(currentLink.data('previewUrl'), {
content: this.editor.getValue(),
- }, (response) => {
- currentPane.empty().append(response);
- return currentPane.renderGFM();
- });
+ })
+ .then(({ data }) => {
+ currentPane.empty().append(data);
+ currentPane.renderGFM();
+ })
+ .catch(() => createFlash(__('An error occurred previewing the blob')));
}
this.$toggleButton.show();
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
deleted file mode 100644
index b94009ee76b..00000000000
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
-/* global BoardService */
-/* global Flash */
-
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import FilteredSearchBoards from './filtered_search_boards';
-import eventHub from './eventhub';
-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);
-
-$(() => {
- const $boardApp = document.getElementById('board-app');
- const Store = gl.issueBoards.BoardsStore;
- const ModalStore = gl.issueBoards.ModalStore;
-
- window.gl = window.gl || {};
-
- if (gl.IssueBoardsApp) {
- gl.IssueBoardsApp.$destroy(true);
- }
-
- Store.create();
-
- // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore
- gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args);
- gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args);
-
- gl.IssueBoardsApp = new Vue({
- el: $boardApp,
- components: {
- 'board': gl.issueBoards.Board,
- 'board-sidebar': gl.issueBoards.BoardSidebar,
- 'board-add-issues-modal': gl.issueBoards.IssuesModal,
- },
- data: {
- state: Store.state,
- loading: true,
- endpoint: $boardApp.dataset.endpoint,
- boardId: $boardApp.dataset.boardId,
- disabled: $boardApp.dataset.disabled === 'true',
- issueLinkBase: $boardApp.dataset.issueLinkBase,
- rootPath: $boardApp.dataset.rootPath,
- bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: Store.detail,
- defaultAvatar: $boardApp.dataset.defaultAvatar,
- },
- computed: {
- detailIssueVisible () {
- return Object.keys(this.detailIssue.issue).length;
- },
- },
- created () {
- 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);
- },
- beforeDestroy() {
- eventHub.$off('updateTokens', this.updateTokens);
- },
- mounted () {
- Store.disabled = this.disabled;
- gl.boardService.all()
- .then((resp) => {
- resp.json().forEach((board) => {
- const list = Store.addList(board, this.defaultAvatar);
-
- 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;
- }
- });
-
- this.state.lists = _.sortBy(this.state.lists, 'position');
-
- Store.addBlankState();
- this.loading = false;
- }).catch(() => new Flash('An error occurred. Please try again.'));
- },
- methods: {
- updateTokens() {
- this.filterManager.updateTokens();
- }
- },
- });
-
- gl.IssueBoardsSearch = new Vue({
- el: document.getElementById('js-add-list'),
- data: {
- filters: Store.state.filters
- },
- mounted () {
- gl.issueBoards.newListDropdownInit();
- }
- });
-
- gl.IssueBoardsModalAddBtn = new Vue({
- mixins: [gl.issueBoards.ModalMixins],
- el: document.getElementById('js-add-issues-btn'),
- data: {
- modal: ModalStore.store,
- store: Store.state,
- },
- watch: {
- disabled() {
- this.updateTooltip();
- },
- },
- computed: {
- disabled() {
- return !this.store.lists.filter(list => !list.preset).length;
- },
- tooltipTitle() {
- if (this.disabled) {
- return 'Please add a list to your board first';
- }
-
- return '';
- },
- },
- methods: {
- updateTooltip() {
- const $tooltip = $(this.$el);
-
- this.$nextTick(() => {
- if (this.disabled) {
- $tooltip.tooltip();
- } else {
- $tooltip.tooltip('destroy');
- }
- });
- },
- openModal() {
- if (!this.disabled) {
- this.toggleModal(true);
- }
- },
- },
- mounted() {
- this.updateTooltip();
- },
- template: `
- <button
- class="btn btn-create pull-right prepend-left-10"
- type="button"
- data-placement="bottom"
- :class="{ 'disabled': disabled }"
- :title="tooltipTitle"
- :aria-disabled="disabled"
- @click="openModal">
- Add issues
- </button>
- `,
- });
-});
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index adb7360327c..3cffd91716a 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,8 +1,10 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
-/* global Sortable */
+
+import $ from 'jquery';
+import Sortable from 'vendor/Sortable';
import Vue from 'vue';
import AccessorUtilities from '../../lib/utils/accessor';
-import boardList from './board_list';
+import boardList from './board_list.vue';
import boardBlankState from './board_blank_state';
import './board_delete';
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js
index 870e115bd1a..72db626d3c7 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ b/app/assets/javascripts/boards/components/board_blank_state.js
@@ -1,5 +1,6 @@
/* global ListLabel */
+import _ from 'underscore';
import Cookies from 'js-cookie';
const Store = gl.issueBoards.BoardsStore;
@@ -64,8 +65,9 @@ export default {
// Save the labels
gl.boardService.generateDefaultLists()
- .then((resp) => {
- resp.json().forEach((listObj) => {
+ .then(res => res.data)
+ .then((data) => {
+ data.forEach((listObj) => {
const list = Store.findList('title', listObj.title);
list.id = listObj.id;
diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js
deleted file mode 100644
index 079fb6438b9..00000000000
--- a/app/assets/javascripts/boards/components/board_card.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import './issue_card_inner';
-
-const Store = gl.issueBoards.BoardsStore;
-
-export default {
- name: 'BoardsIssueCard',
- template: `
- <li class="card"
- :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
- :index="index"
- :data-issue-id="issue.id"
- @mousedown="mouseDown"
- @mousemove="mouseMove"
- @mouseup="showIssue($event)">
- <issue-card-inner
- :list="list"
- :issue="issue"
- :issue-link-base="issueLinkBase"
- :root-path="rootPath"
- :update-filters="true" />
- </li>
- `,
- components: {
- 'issue-card-inner': gl.issueBoards.IssueCardInner,
- },
- props: {
- list: Object,
- issue: Object,
- issueLinkBase: String,
- disabled: Boolean,
- index: Number,
- rootPath: String,
- },
- data() {
- return {
- showDetail: false,
- detailIssue: Store.detail,
- };
- },
- computed: {
- issueDetailVisible() {
- return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
- },
- },
- methods: {
- mouseDown() {
- this.showDetail = true;
- },
- mouseMove() {
- this.showDetail = false;
- },
- showIssue(e) {
- if (e.target.classList.contains('js-no-trigger')) return;
-
- if (this.showDetail) {
- this.showDetail = false;
-
- if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
- Store.detail.issue = {};
- } else {
- Store.detail.issue = this.issue;
- Store.detail.list = this.list;
- }
- }
- },
- },
-};
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
new file mode 100644
index 00000000000..84885ca9306
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -0,0 +1,100 @@
+<script>
+/* eslint-disable vue/require-default-prop */
+import './issue_card_inner';
+import eventHub from '../eventhub';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+ name: 'BoardsIssueCard',
+ components: {
+ 'issue-card-inner': gl.issueBoards.IssueCardInner,
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ },
+ issue: {
+ type: Object,
+ default: () => ({}),
+ },
+ issueLinkBase: {
+ type: String,
+ default: '',
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
+ index: {
+ type: Number,
+ default: 0,
+ },
+ rootPath: {
+ type: String,
+ default: '',
+ },
+ groupId: {
+ type: Number,
+ },
+ },
+ data() {
+ return {
+ showDetail: false,
+ detailIssue: Store.detail,
+ };
+ },
+ computed: {
+ issueDetailVisible() {
+ return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
+ },
+ },
+ methods: {
+ mouseDown() {
+ this.showDetail = true;
+ },
+ mouseMove() {
+ this.showDetail = false;
+ },
+ showIssue(e) {
+ if (e.target.classList.contains('js-no-trigger')) return;
+
+ if (this.showDetail) {
+ this.showDetail = false;
+
+ if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
+ eventHub.$emit('clearDetailIssue');
+ } else {
+ eventHub.$emit('newDetailIssue', this.issue);
+ Store.detail.list = this.list;
+ }
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ class="card"
+ :class="{
+ 'user-can-drag': !disabled && issue.id,
+ 'is-disabled': disabled || !issue.id,
+ 'is-active': issueDetailVisible
+ }"
+ :index="index"
+ :data-issue-id="issue.id"
+ @mousedown="mouseDown"
+ @mousemove="mouseMove"
+ @mouseup="showIssue($event)">
+ <issue-card-inner
+ :list="list"
+ :issue="issue"
+ :issue-link-base="issueLinkBase"
+ :group-id="groupId"
+ :root-path="rootPath"
+ :update-filters="true"
+ />
+ </li>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js
index 8a1b177bba8..7be98825fda 100644
--- a/app/assets/javascripts/boards/components/board_delete.js
+++ b/app/assets/javascripts/boards/components/board_delete.js
@@ -1,5 +1,6 @@
/* eslint-disable comma-dangle, space-before-function-paren, no-alert */
+import $ from 'jquery';
import Vue from 'vue';
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
deleted file mode 100644
index bebca17fb1e..00000000000
--- a/app/assets/javascripts/boards/components/board_list.js
+++ /dev/null
@@ -1,209 +0,0 @@
-/* global Sortable */
-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;
-
-export default {
- name: 'BoardList',
- props: {
- disabled: {
- type: Boolean,
- required: true,
- },
- list: {
- type: Object,
- required: true,
- },
- issues: {
- type: Array,
- required: true,
- },
- loading: {
- type: Boolean,
- required: true,
- },
- issueLinkBase: {
- type: String,
- required: true,
- },
- rootPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- scrollOffset: 250,
- filters: Store.state.filters,
- showCount: false,
- showIssueForm: false,
- };
- },
- components: {
- boardCard,
- boardNewIssue,
- loadingIcon,
- },
- methods: {
- listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
- },
- scrollHeight() {
- return this.$refs.list.scrollHeight;
- },
- scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
- },
- scrollToTop() {
- this.$refs.list.scrollTop = 0;
- },
- loadNextPage() {
- const getIssues = this.list.nextPage();
- const loadingDone = () => {
- this.list.loadingMore = false;
- };
-
- if (getIssues) {
- this.list.loadingMore = true;
- getIssues
- .then(loadingDone)
- .catch(loadingDone);
- }
- },
- toggleForm() {
- this.showIssueForm = !this.showIssueForm;
- },
- onScroll() {
- if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
- this.loadNextPage();
- }
- },
- },
- watch: {
- filters: {
- handler() {
- this.list.loadingMore = false;
- this.$refs.list.scrollTop = 0;
- },
- deep: true,
- },
- issues() {
- this.$nextTick(() => {
- if (this.scrollHeight() <= this.listHeight() &&
- this.list.issuesSize > this.list.issues.length) {
- this.list.page += 1;
- this.list.getIssues(false)
- .catch(() => {
- // TODO: handle request error
- });
- }
-
- if (this.scrollHeight() > Math.ceil(this.listHeight())) {
- this.showCount = true;
- } else {
- this.showCount = false;
- }
- });
- },
- },
- 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({
- scroll: document.querySelectorAll('.boards-list')[0],
- group: 'issues',
- disabled: this.disabled,
- filter: '.board-list-count, .is-disabled',
- dataIdAttr: 'data-issue-id',
- onStart: (e) => {
- const card = this.$refs.issue[e.oldIndex];
-
- card.showDetail = false;
- Store.moving.list = card.list;
- Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
-
- gl.issueBoards.onStart();
- },
- onAdd: (e) => {
- gl.issueBoards.BoardsStore
- .moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
-
- this.$nextTick(() => {
- e.item.remove();
- });
- },
- onUpdate: (e) => {
- const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
- gl.issueBoards.BoardsStore
- .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
- },
- onMove(e) {
- return !e.related.classList.contains('board-list-count');
- },
- });
-
- this.sortable = Sortable.create(this.$refs.list, options);
-
- // Scroll event on list to load more
- this.$refs.list.addEventListener('scroll', this.onScroll);
- },
- 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: `
- <div class="board-list-component">
- <div
- class="board-list-loading text-center"
- aria-label="Loading issues"
- v-if="loading">
- <loading-icon />
- </div>
- <transition name="slide-down">
- <board-new-issue
- :list="list"
- v-if="list.type !== 'closed' && showIssueForm"/>
- </transition>
- <ul
- class="board-list"
- v-show="!loading"
- ref="list"
- :data-board="list.id"
- :class="{ 'is-smaller': showIssueForm }">
- <board-card
- v-for="(issue, index) in issues"
- ref="issue"
- :index="index"
- :list="list"
- :issue="issue"
- :issue-link-base="issueLinkBase"
- :root-path="rootPath"
- :disabled="disabled"
- :key="issue.id" />
- <li
- class="board-list-count text-center"
- v-if="showCount"
- data-id="-1">
-
- <loading-icon
- v-show="list.loadingMore"
- label="Loading more issues"
- />
-
- <span v-if="list.issues.length === list.issuesSize">
- Showing all issues
- </span>
- <span v-else>
- Showing {{ list.issues.length }} of {{ list.issuesSize }} issues
- </span>
- </li>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
new file mode 100644
index 00000000000..0d03c1c419c
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -0,0 +1,219 @@
+<script>
+import Sortable from 'vendor/Sortable';
+import boardNewIssue from './board_new_issue.vue';
+import boardCard from './board_card.vue';
+import eventHub from '../eventhub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+ name: 'BoardList',
+ components: {
+ boardCard,
+ boardNewIssue,
+ loadingIcon,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: true,
+ },
+ issues: {
+ type: Array,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ issueLinkBase: {
+ type: String,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scrollOffset: 250,
+ filters: Store.state.filters,
+ showCount: false,
+ showIssueForm: false,
+ };
+ },
+ watch: {
+ filters: {
+ handler() {
+ this.list.loadingMore = false;
+ this.$refs.list.scrollTop = 0;
+ },
+ deep: true,
+ },
+ issues() {
+ this.$nextTick(() => {
+ if (this.scrollHeight() <= this.listHeight() &&
+ this.list.issuesSize > this.list.issues.length) {
+ this.list.page += 1;
+ this.list.getIssues(false)
+ .catch(() => {
+ // TODO: handle request error
+ });
+ }
+
+ if (this.scrollHeight() > Math.ceil(this.listHeight())) {
+ this.showCount = true;
+ } else {
+ this.showCount = false;
+ }
+ });
+ },
+ },
+ 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({
+ scroll: true,
+ group: 'issues',
+ disabled: this.disabled,
+ filter: '.board-list-count, .is-disabled',
+ dataIdAttr: 'data-issue-id',
+ onStart: (e) => {
+ const card = this.$refs.issue[e.oldIndex];
+
+ card.showDetail = false;
+ Store.moving.list = card.list;
+ Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
+
+ gl.issueBoards.onStart();
+ },
+ onAdd: (e) => {
+ gl.issueBoards.BoardsStore
+ .moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
+
+ this.$nextTick(() => {
+ e.item.remove();
+ });
+ },
+ onUpdate: (e) => {
+ const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
+ gl.issueBoards.BoardsStore
+ .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
+ },
+ onMove(e) {
+ return !e.related.classList.contains('board-list-count');
+ },
+ });
+
+ this.sortable = Sortable.create(this.$refs.list, options);
+
+ // Scroll event on list to load more
+ this.$refs.list.addEventListener('scroll', this.onScroll);
+ },
+ 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);
+ },
+ methods: {
+ listHeight() {
+ return this.$refs.list.getBoundingClientRect().height;
+ },
+ scrollHeight() {
+ return this.$refs.list.scrollHeight;
+ },
+ scrollTop() {
+ return this.$refs.list.scrollTop + this.listHeight();
+ },
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
+ loadNextPage() {
+ const getIssues = this.list.nextPage();
+ const loadingDone = () => {
+ this.list.loadingMore = false;
+ };
+
+ if (getIssues) {
+ this.list.loadingMore = true;
+ getIssues
+ .then(loadingDone)
+ .catch(loadingDone);
+ }
+ },
+ toggleForm() {
+ this.showIssueForm = !this.showIssueForm;
+ },
+ onScroll() {
+ if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
+ this.loadNextPage();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="board-list-component">
+ <div
+ class="board-list-loading text-center"
+ aria-label="Loading issues"
+ v-if="loading">
+ <loading-icon />
+ </div>
+ <board-new-issue
+ :group-id="groupId"
+ :list="list"
+ v-if="list.type !== 'closed' && showIssueForm"/>
+ <ul
+ class="board-list"
+ v-show="!loading"
+ ref="list"
+ :data-board="list.id"
+ :class="{ 'is-smaller': showIssueForm }">
+ <board-card
+ v-for="(issue, index) in issues"
+ ref="issue"
+ :index="index"
+ :list="list"
+ :issue="issue"
+ :issue-link-base="issueLinkBase"
+ :group-id="groupId"
+ :root-path="rootPath"
+ :disabled="disabled"
+ :key="issue.id" />
+ <li
+ class="board-list-count text-center"
+ v-if="showCount"
+ data-issue-id="-1">
+ <loading-icon
+ v-show="list.loadingMore"
+ label="Loading more issues"
+ />
+ <span
+ v-if="list.issues.length === list.issuesSize"
+ >
+ Showing all issues
+ </span>
+ <span
+ v-else
+ >
+ Showing {{ list.issues.length }} of {{ list.issuesSize }} issues
+ </span>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
deleted file mode 100644
index 4af8b0c7713..00000000000
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ /dev/null
@@ -1,97 +0,0 @@
-/* global ListIssue */
-import eventHub from '../eventhub';
-
-const Store = gl.issueBoards.BoardsStore;
-
-export default {
- name: 'BoardNewIssue',
- props: {
- list: Object,
- },
- data() {
- return {
- title: '',
- error: false,
- };
- },
- methods: {
- submit(e) {
- e.preventDefault();
- if (this.title.trim() === '') return Promise.resolve();
-
- this.error = false;
-
- const labels = this.list.label ? [this.list.label] : [];
- const issue = new ListIssue({
- title: this.title,
- labels,
- subscribed: true,
- assignees: [],
- });
-
- eventHub.$emit(`scroll-board-list-${this.list.id}`);
- this.cancel();
-
- return this.list.newIssue(issue)
- .then(() => {
- // Need this because our jQuery very kindly disables buttons on ALL form submissions
- $(this.$refs.submitButton).enable();
-
- Store.detail.issue = issue;
- Store.detail.list = this.list;
- })
- .catch(() => {
- // Need this because our jQuery very kindly disables buttons on ALL form submissions
- $(this.$refs.submitButton).enable();
-
- // Remove the issue
- this.list.removeIssue(issue);
-
- // Show error message
- this.error = true;
- });
- },
- cancel() {
- this.title = '';
- eventHub.$emit(`hide-issue-form-${this.list.id}`);
- },
- },
- mounted() {
- this.$refs.input.focus();
- },
- template: `
- <div class="card board-new-issue-form">
- <form @submit="submit($event)">
- <div class="flash-container"
- v-if="error">
- <div class="flash-alert">
- An error occured. Please try again.
- </div>
- </div>
- <label class="label-light"
- :for="list.id + '-title'">
- Title
- </label>
- <input class="form-control"
- 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"
- type="submit"
- :disabled="title === ''"
- ref="submit-button">
- Submit issue
- </button>
- <button class="btn btn-default pull-right"
- type="button"
- @click="cancel">
- Cancel
- </button>
- </div>
- </form>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
new file mode 100644
index 00000000000..8d84c1735b8
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -0,0 +1,144 @@
+<script>
+import $ from 'jquery';
+import eventHub from '../eventhub';
+import ProjectSelect from './project_select.vue';
+import ListIssue from '../models/issue';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+ name: 'BoardNewIssue',
+ components: {
+ ProjectSelect,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ list: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ title: '',
+ error: false,
+ selectedProject: {},
+ };
+ },
+ computed: {
+ disabled() {
+ if (this.groupId) {
+ return this.title === '' || !this.selectedProject.name;
+ }
+ return this.title === '';
+ },
+ },
+ mounted() {
+ this.$refs.input.focus();
+ eventHub.$on('setSelectedProject', this.setSelectedProject);
+ },
+ methods: {
+ submit(e) {
+ e.preventDefault();
+ if (this.title.trim() === '') return Promise.resolve();
+
+ this.error = false;
+
+ const labels = this.list.label ? [this.list.label] : [];
+ const issue = new ListIssue({
+ title: this.title,
+ labels,
+ subscribed: true,
+ assignees: [],
+ project_id: this.selectedProject.id,
+ });
+
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
+ this.cancel();
+
+ return this.list.newIssue(issue)
+ .then(() => {
+ // Need this because our jQuery very kindly disables buttons on ALL form submissions
+ $(this.$refs.submitButton).enable();
+
+ Store.detail.issue = issue;
+ Store.detail.list = this.list;
+ })
+ .catch(() => {
+ // Need this because our jQuery very kindly disables buttons on ALL form submissions
+ $(this.$refs.submitButton).enable();
+
+ // Remove the issue
+ this.list.removeIssue(issue);
+
+ // Show error message
+ this.error = true;
+ });
+ },
+ cancel() {
+ this.title = '';
+ eventHub.$emit(`hide-issue-form-${this.list.id}`);
+ },
+ setSelectedProject(selectedProject) {
+ this.selectedProject = selectedProject;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="board-new-issue-form">
+ <div class="card">
+ <form @submit="submit($event)">
+ <div
+ class="flash-container"
+ v-if="error"
+ >
+ <div class="flash-alert">
+ An error occurred. Please try again.
+ </div>
+ </div>
+ <label
+ class="label-light"
+ :for="list.id + '-title'"
+ >
+ Title
+ </label>
+ <input
+ class="form-control"
+ type="text"
+ v-model="title"
+ ref="input"
+ autocomplete="off"
+ :id="list.id + '-title'"
+ />
+ <project-select
+ v-if="groupId"
+ :group-id="groupId"
+ />
+ <div class="clearfix prepend-top-10">
+ <button
+ class="btn btn-success pull-left"
+ type="submit"
+ :disabled="disabled"
+ ref="submit-button"
+ >
+ Submit issue
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="cancel"
+ >
+ Cancel
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</template>
+
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index c7afd4ead6b..a44969272a1 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -1,15 +1,19 @@
/* eslint-disable comma-dangle, space-before-function-paren, no-new */
-/* global IssuableContext */
-/* global MilestoneSelect */
-/* global LabelsSelect */
-/* global Sidebar */
-/* global Flash */
+import $ from 'jquery';
import Vue from 'vue';
+import Flash from '../../flash';
+import { __ } from '../../locale';
+import Sidebar from '../../right_sidebar';
import eventHub from '../../sidebar/event_hub';
-import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
-import Assignees from '../../sidebar/components/assignees/assignees';
+import assigneeTitle from '../../sidebar/components/assignees/assignee_title.vue';
+import assignees from '../../sidebar/components/assignees/assignees.vue';
+import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue';
+import IssuableContext from '../../issuable_context';
+import LabelsSelect from '../../labels_select';
+import subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue';
+import MilestoneSelect from '../../milestone_select';
const Store = gl.issueBoards.BoardsStore;
@@ -34,7 +38,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
milestoneTitle() {
return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
- }
+ },
+ canRemove() {
+ return !this.list.preset;
+ },
},
watch: {
detail: {
@@ -90,7 +97,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
})
.catch(() => {
this.loadingAssignees = false;
- return new Flash('An error occurred while saving assignees');
+ Flash(__('An error occurred while saving assignees'));
});
},
},
@@ -110,14 +117,14 @@ gl.issueBoards.BoardSidebar = Vue.extend({
mounted () {
new IssuableContext(this.currentUser);
new MilestoneSelect();
- new gl.DueDateSelectors();
+ new DueDateSelectors();
new LabelsSelect();
new Sidebar();
- gl.Subscription.bindAll('.subscription');
},
components: {
+ assigneeTitle,
+ assignees,
removeBtn: gl.issueBoards.RemoveIssueBtn,
- 'assignee-title': AssigneeTitle,
- assignees: Assignees,
+ subscriptions,
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index daef01bc93d..8aee5b23c76 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -1,5 +1,6 @@
+import $ from 'jquery';
import Vue from 'vue';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
@@ -31,6 +32,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false,
default: false,
},
+ groupId: {
+ type: Number,
+ required: false,
+ },
},
data() {
return {
@@ -40,7 +45,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
};
},
components: {
- userAvatarLink,
+ UserAvatarLink,
},
computed: {
numberOverLimit() {
@@ -64,10 +69,19 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
- return `${this.issueLinkBase}/${this.issue.id}`;
+ let baseUrl = this.issueLinkBase;
+
+ if (this.groupId && this.issue.project) {
+ baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path);
+ }
+
+ return `${baseUrl}/${this.issue.iid}`;
},
issueId() {
- return `#${this.issue.id}`;
+ if (this.issue.iid) {
+ return `#${this.issue.iid}`;
+ }
+ return false;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
@@ -97,9 +111,8 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return `Avatar for ${assignee.name}`;
},
showLabel(label) {
- if (!this.list) return true;
-
- return !this.list.label || label.id !== this.list.label.id;
+ if (!label.id) return false;
+ return true;
},
filterByLabel(label, e) {
if (!this.updateFilters) return;
@@ -144,9 +157,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
- v-if="issue.id"
+ v-if="issueId"
>
- {{ issueId }}
+ <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }}
</span>
</h4>
<div class="card-assignee">
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js
index 13569df0c20..e571b11a83d 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.js
+++ b/app/assets/javascripts/boards/components/modal/empty_state.js
@@ -8,11 +8,11 @@ gl.issueBoards.ModalEmptyState = Vue.extend({
return ModalStore.store;
},
props: {
- image: {
+ newIssuePath: {
type: String,
required: true,
},
- newIssuePath: {
+ emptyStateSvg: {
type: String,
required: true,
},
@@ -42,7 +42,7 @@ gl.issueBoards.ModalEmptyState = Vue.extend({
<section class="empty-state">
<div class="row">
<div class="col-xs-12 col-sm-6 col-sm-push-6">
- <aside class="svg-content" v-html="image"></aside>
+ <aside class="svg-content"><img :src="emptyStateSvg"/></aside>
</div>
<div class="col-xs-12 col-sm-6 col-sm-pull-6">
<div class="text-content">
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index 478a1335b2b..03cd7ef65cb 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -1,8 +1,8 @@
-/* eslint-disable no-new */
-/* global Flash */
-
import Vue from 'vue';
+import Flash from '../../../flash';
+import { __ } from '../../../locale';
import './lists_dropdown';
+import { pluralize } from '../../../lib/utils/text_utility';
const ModalStore = gl.issueBoards.ModalStore;
@@ -21,7 +21,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
submitText() {
const count = ModalStore.selectedCount();
- return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`;
+ return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`;
},
},
methods: {
@@ -29,13 +29,13 @@ gl.issueBoards.ModalFooter = Vue.extend({
const firstListIndex = 1;
const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
- const issueIds = selectedIssues.map(issue => issue.globalId);
+ const issueIds = selectedIssues.map(issue => issue.id);
// Post the data to the backend
gl.boardService.bulkUpdate(issueIds, {
add_label_ids: [list.label.id],
}).catch(() => {
- new Flash('Failed to update issues, please try again.', 'alert');
+ Flash(__('Failed to update issues, please try again.'));
selectedIssues.forEach((issue) => {
list.removeIssue(issue);
diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js
index 6356c266ee2..d825ff38587 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -1,8 +1,8 @@
/* global ListIssue */
import Vue from 'vue';
-import queryData from '../../utils/query_data';
-import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+import queryData from '~/boards/utils/query_data';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import './header';
import './list';
import './footer';
@@ -12,11 +12,11 @@ const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.IssuesModal = Vue.extend({
props: {
- blankStateImage: {
+ newIssuePath: {
type: String,
required: true,
},
- newIssuePath: {
+ emptyStateSvg: {
type: String,
required: true,
},
@@ -88,9 +88,9 @@ gl.issueBoards.IssuesModal = Vue.extend({
return gl.boardService.getBacklog(queryData(this.filter.path, {
page: this.page,
per: this.perPage,
- })).then((res) => {
- const data = res.json();
-
+ }))
+ .then(res => res.data)
+ .then((data) => {
if (clearIssues) {
this.issues = [];
}
@@ -150,14 +150,14 @@ gl.issueBoards.IssuesModal = Vue.extend({
:label-path="labelPath">
</modal-header>
<modal-list
- :image="blankStateImage"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
+ :empty-state-svg="emptyStateSvg"
v-if="!loading && showList && !filterLoading"></modal-list>
<empty-state
v-if="showEmptyState"
- :image="blankStateImage"
- :new-issue-path="newIssuePath"></empty-state>
+ :new-issue-path="newIssuePath"
+ :empty-state-svg="emptyStateSvg"></empty-state>
<section
class="add-issues-list text-center"
v-if="loading || filterLoading">
diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js
index 363269c0d5d..7c62134b3a3 100644
--- a/app/assets/javascripts/boards/components/modal/list.js
+++ b/app/assets/javascripts/boards/components/modal/list.js
@@ -1,7 +1,7 @@
/* global ListIssue */
-/* global bp */
import Vue from 'vue';
+import bp from '../../../breakpoints';
const ModalStore = gl.issueBoards.ModalStore;
@@ -15,7 +15,7 @@ gl.issueBoards.ModalList = Vue.extend({
type: String,
required: true,
},
- image: {
+ emptyStateSvg: {
type: String,
required: true,
},
@@ -119,8 +119,8 @@ gl.issueBoards.ModalList = Vue.extend({
class="empty-state add-issues-empty-state-filter text-center"
v-if="issuesCount > 0 && issues.length === 0">
<div
- class="svg-content"
- v-html="image">
+ class="svg-content">
+ <img :src="emptyStateSvg"/>
</div>
<div class="text-content">
<h4>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index f29b6caa1ac..71f49319c36 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -1,5 +1,9 @@
-/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var,
- promise/catch-or-return */
+/* eslint-disable func-names, no-new, space-before-function-paren, one-var, promise/catch-or-return, max-len */
+
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import _ from 'underscore';
+import CreateLabelDropdown from '../../create_label';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -14,21 +18,21 @@ $(document).off('created.label').on('created.label', (e, label) => {
label: {
id: label.id,
title: label.title,
- color: label.color
- }
+ color: label.color,
+ },
});
});
gl.issueBoards.newListDropdownInit = () => {
$('.js-new-board-list').each(function () {
const $this = $(this);
- new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path'));
+ new CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespacePath'), $this.data('projectPath'));
$this.glDropdown({
data(term, callback) {
- $.get($this.attr('data-labels'))
- .then((resp) => {
- callback(resp);
+ axios.get($this.attr('data-list-labels-path'))
+ .then(({ data }) => {
+ callback(data);
});
},
renderRow (label) {
@@ -37,17 +41,17 @@ gl.issueBoards.newListDropdownInit = () => {
const $a = $('<a />', {
class: (active ? `is-active js-board-list-${active.id}` : ''),
text: label.title,
- href: '#'
+ href: '#',
});
const $labelColor = $('<span />', {
class: 'dropdown-label-box',
- style: `background-color: ${label.color}`
+ style: `background-color: ${label.color}`,
});
return $li.append($a.prepend($labelColor));
},
search: {
- fields: ['title']
+ fields: ['title'],
},
filterable: true,
selectable: true,
@@ -65,13 +69,13 @@ gl.issueBoards.newListDropdownInit = () => {
label: {
id: label.id,
title: label.title,
- color: label.color
- }
+ color: label.color,
+ },
});
Store.state.lists = _.sortBy(Store.state.lists, 'position');
}
- }
+ },
});
});
};
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
new file mode 100644
index 00000000000..371774098b9
--- /dev/null
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -0,0 +1,129 @@
+<script>
+ /* global ListIssue */
+
+ import $ from 'jquery';
+ import _ from 'underscore';
+ import eventHub from '../eventhub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import Api from '../../api';
+
+ export default {
+ name: 'BoardProjectSelect',
+ components: {
+ loadingIcon,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ selectedProject: {},
+ };
+ },
+ computed: {
+ selectedProjectName() {
+ return this.selectedProject.name || 'Select a project';
+ },
+ },
+ mounted() {
+ $(this.$refs.projectsDropdown).glDropdown({
+ filterable: true,
+ filterRemote: true,
+ search: {
+ fields: ['name_with_namespace'],
+ },
+ clicked: ({ $el, e }) => {
+ e.preventDefault();
+ this.selectedProject = {
+ id: $el.data('project-id'),
+ name: $el.data('project-name'),
+ };
+ eventHub.$emit('setSelectedProject', this.selectedProject);
+ },
+ selectable: true,
+ data: (term, callback) => {
+ this.loading = true;
+ return Api.groupProjects(this.groupId, term, (projects) => {
+ this.loading = false;
+ callback(projects);
+ });
+ },
+ renderRow(project) {
+ return `
+ <li>
+ <a href='#' class='dropdown-menu-link' data-project-id="${project.id}" data-project-name="${project.name}">
+ ${_.escape(project.name)}
+ </a>
+ </li>
+ `;
+ },
+ text: project => project.name,
+ });
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <label class="label-light prepend-top-10">
+ Project
+ </label>
+ <div
+ ref="projectsDropdown"
+ class="dropdown"
+ >
+ <button
+ class="dropdown-menu-toggle wide"
+ type="button"
+ data-toggle="dropdown"
+ aria-expanded="false"
+ >
+ {{ selectedProjectName }}
+ <i
+ class="fa fa-chevron-down"
+ aria-hidden="true"
+ >
+ </i>
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
+ <div class="dropdown-title">
+ <span>Projects</span>
+ <button
+ aria-label="Close"
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon"
+ >
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ class="dropdown-input-field"
+ type="search"
+ placeholder="Search projects"
+ />
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-search dropdown-input-search"
+ >
+ </i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-loading">
+ <loading-icon />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
index 5597f128b80..09c683ff621 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
@@ -1,7 +1,6 @@
-/* eslint-disable no-new */
-/* global Flash */
-
import Vue from 'vue';
+import Flash from '../../../flash';
+import { __ } from '../../../locale';
const Store = gl.issueBoards.BoardsStore;
@@ -18,18 +17,38 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
+ issueUpdate: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ updateUrl() {
+ return this.issueUpdate.replace(':project_path', this.issue.project.path);
+ },
},
methods: {
removeIssue() {
const issue = this.issue;
const lists = issue.getLists();
- const labelIds = lists.map(list => list.label.id);
+ const listLabelIds = lists.map(list => list.label.id);
+
+ let labelIds = issue.labels
+ .map(label => label.id)
+ .filter(id => !listLabelIds.includes(id));
+ if (labelIds.length === 0) {
+ labelIds = [''];
+ }
+
+ const data = {
+ issue: {
+ label_ids: labelIds,
+ },
+ };
// Post the remove data
- gl.boardService.bulkUpdate([issue.globalId], {
- remove_label_ids: labelIds,
- }).catch(() => {
- new Flash('Failed to remove issue from board, please try again.', 'alert');
+ Vue.http.patch(this.updateUrl, data).catch(() => {
+ Flash(__('Failed to remove issue from board, please try again.'));
lists.forEach((list) => {
list.addIssue(issue);
@@ -46,8 +65,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
},
template: `
<div
- class="block list"
- v-if="list.type !== 'closed'">
+ class="block list">
<button
class="btn btn-default btn-block"
type="button"
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 3f083655f95..fb40b9f5565 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,9 +1,13 @@
/* eslint-disable class-methods-use-this */
import FilteredSearchContainer from '../filtered_search/container';
+import FilteredSearchManager from '../filtered_search/filtered_search_manager';
-export default class FilteredSearchBoards extends gl.FilteredSearchManager {
+export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
- super('boards');
+ super({
+ page: 'boards',
+ stateFiltersSelector: '.issues-state-filters',
+ });
this.store = store;
this.updateUrl = updateUrl;
@@ -11,7 +15,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;
+ this.cantEdit = cantEdit.filter(i => typeof i === 'string');
+ this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object');
}
updateObject(path) {
@@ -42,7 +47,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new Event('input'));
}
- canEdit(tokenName) {
- return this.cantEdit.indexOf(tokenName) === -1;
+ canEdit(tokenName, tokenValue) {
+ if (this.cantEdit.includes(tokenName)) return false;
+ return this.cantEditWithValue.findIndex(token => token.name === tokenName &&
+ token.value === tokenValue) === -1;
}
}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
new file mode 100644
index 00000000000..8b1c14c04ff
--- /dev/null
+++ b/app/assets/javascripts/boards/index.js
@@ -0,0 +1,245 @@
+/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import Vue from 'vue';
+
+import Flash from '~/flash';
+import { __ } from '~/locale';
+import '~/vue_shared/models/label';
+
+import FilteredSearchBoards from './filtered_search_boards';
+import eventHub from './eventhub';
+import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import/first
+import './models/issue';
+import './models/list';
+import './models/milestone';
+import './models/project';
+import './models/assignee';
+import './stores/boards_store';
+import './stores/modal_store';
+import BoardService from './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'; // eslint-disable-line import/first
+
+export default () => {
+ const $boardApp = document.getElementById('board-app');
+ const Store = gl.issueBoards.BoardsStore;
+ const ModalStore = gl.issueBoards.ModalStore;
+
+ window.gl = window.gl || {};
+
+ if (gl.IssueBoardsApp) {
+ gl.IssueBoardsApp.$destroy(true);
+ }
+
+ Store.create();
+
+ // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore
+ gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args);
+ gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args);
+
+ gl.IssueBoardsApp = new Vue({
+ el: $boardApp,
+ components: {
+ 'board': gl.issueBoards.Board,
+ 'board-sidebar': gl.issueBoards.BoardSidebar,
+ 'board-add-issues-modal': gl.issueBoards.IssuesModal,
+ },
+ data: {
+ state: Store.state,
+ loading: true,
+ boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ listsEndpoint: $boardApp.dataset.listsEndpoint,
+ boardId: $boardApp.dataset.boardId,
+ disabled: $boardApp.dataset.disabled === 'true',
+ issueLinkBase: $boardApp.dataset.issueLinkBase,
+ rootPath: $boardApp.dataset.rootPath,
+ bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
+ detailIssue: Store.detail,
+ defaultAvatar: $boardApp.dataset.defaultAvatar,
+ },
+ computed: {
+ detailIssueVisible () {
+ return Object.keys(this.detailIssue.issue).length;
+ },
+ },
+ created () {
+ gl.boardService = new BoardService({
+ boardsEndpoint: this.boardsEndpoint,
+ listsEndpoint: this.listsEndpoint,
+ bulkUpdatePath: this.bulkUpdatePath,
+ boardId: this.boardId,
+ });
+ Store.rootPath = this.boardsEndpoint;
+
+ eventHub.$on('updateTokens', this.updateTokens);
+ eventHub.$on('newDetailIssue', this.updateDetailIssue);
+ eventHub.$on('clearDetailIssue', this.clearDetailIssue);
+ sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
+ },
+ beforeDestroy() {
+ eventHub.$off('updateTokens', this.updateTokens);
+ eventHub.$off('newDetailIssue', this.updateDetailIssue);
+ eventHub.$off('clearDetailIssue', this.clearDetailIssue);
+ sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
+ },
+ mounted () {
+ this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
+ this.filterManager.setup();
+
+ Store.disabled = this.disabled;
+ gl.boardService.all()
+ .then(res => res.data)
+ .then((data) => {
+ data.forEach((board) => {
+ const list = Store.addList(board, this.defaultAvatar);
+
+ if (list.type === 'closed') {
+ list.position = Infinity;
+ } else if (list.type === 'backlog') {
+ list.position = -1;
+ }
+ });
+
+ this.state.lists = _.sortBy(this.state.lists, 'position');
+
+ Store.addBlankState();
+ this.loading = false;
+ })
+ .catch(() => {
+ Flash('An error occurred while fetching the board lists. Please try again.');
+ });
+ },
+ methods: {
+ updateTokens() {
+ this.filterManager.updateTokens();
+ },
+ updateDetailIssue(newIssue) {
+ const sidebarInfoEndpoint = newIssue.sidebarInfoEndpoint;
+ if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
+ newIssue.setFetchingState('subscriptions', true);
+ BoardService.getIssueInfo(sidebarInfoEndpoint)
+ .then(res => res.data)
+ .then((data) => {
+ newIssue.setFetchingState('subscriptions', false);
+ newIssue.updateData({
+ subscribed: data.subscribed,
+ });
+ })
+ .catch(() => {
+ newIssue.setFetchingState('subscriptions', false);
+ Flash(__('An error occurred while fetching sidebar data'));
+ });
+ }
+
+ Store.detail.issue = newIssue;
+ },
+ clearDetailIssue() {
+ Store.detail.issue = {};
+ },
+ toggleSubscription(id) {
+ const issue = Store.detail.issue;
+ if (issue.id === id && issue.toggleSubscriptionEndpoint) {
+ issue.setFetchingState('subscriptions', true);
+ BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
+ .then(() => {
+ issue.setFetchingState('subscriptions', false);
+ issue.updateData({
+ subscribed: !issue.subscribed,
+ });
+ })
+ .catch(() => {
+ issue.setFetchingState('subscriptions', false);
+ Flash(__('An error occurred when toggling the notification subscription'));
+ });
+ }
+ }
+ },
+ });
+
+ gl.IssueBoardsSearch = new Vue({
+ el: document.getElementById('js-add-list'),
+ data: {
+ filters: Store.state.filters,
+ },
+ mounted () {
+ gl.issueBoards.newListDropdownInit();
+ },
+ });
+
+ gl.IssueBoardsModalAddBtn = new Vue({
+ el: document.getElementById('js-add-issues-btn'),
+ mixins: [gl.issueBoards.ModalMixins],
+ data() {
+ return {
+ modal: ModalStore.store,
+ store: Store.state,
+ canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
+ };
+ },
+ computed: {
+ disabled() {
+ if (!this.store) {
+ return true;
+ }
+ return !this.store.lists.filter(list => !list.preset).length;
+ },
+ tooltipTitle() {
+ if (this.disabled) {
+ return 'Please add a list to your board first';
+ }
+
+ return '';
+ },
+ },
+ watch: {
+ disabled() {
+ this.updateTooltip();
+ },
+ },
+ mounted() {
+ this.updateTooltip();
+ },
+ methods: {
+ updateTooltip() {
+ const $tooltip = $(this.$refs.addIssuesButton);
+
+ this.$nextTick(() => {
+ if (this.disabled) {
+ $tooltip.tooltip();
+ } else {
+ $tooltip.tooltip('destroy');
+ }
+ });
+ },
+ openModal() {
+ if (!this.disabled) {
+ this.toggleModal(true);
+ }
+ },
+ },
+ template: `
+ <div class="board-extra-actions">
+ <button
+ class="btn btn-create prepend-left-10"
+ type="button"
+ data-placement="bottom"
+ ref="addIssuesButton"
+ :class="{ 'disabled': disabled }"
+ :title="tooltipTitle"
+ :aria-disabled="disabled"
+ v-if="canAdminList"
+ @click="openModal">
+ Add issues
+ </button>
+ </div>
+ `,
+ });
+};
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 38a0eb12f92..ac316c31deb 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -1,6 +1,9 @@
/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */
/* global DocumentTouch */
+import $ from 'jquery';
+import sortableConfig from '../../sortable/sortable_config';
+
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -18,19 +21,14 @@ gl.issueBoards.onEnd = () => {
gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch;
gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
- const defaultSortOptions = {
- animation: 200,
- forceFallback: true,
- fallbackClass: 'is-dragging',
- fallbackOnBody: true,
- ghostClass: 'is-ghost',
+ const defaultSortOptions = Object.assign({}, sortableConfig, {
filter: '.board-delete, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: gl.issueBoards.onStart,
- onEnd: gl.issueBoards.onEnd
- };
+ onEnd: gl.issueBoards.onEnd,
+ });
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
return defaultSortOptions;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 6c2d8a3781b..4c5079efc8b 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -4,11 +4,12 @@
/* global ListAssignee */
import Vue from 'vue';
+import IssueProject from './project';
class ListIssue {
constructor (obj, defaultAvatar) {
- this.globalId = obj.id;
- this.id = obj.iid;
+ this.id = obj.id;
+ this.iid = obj.iid;
this.title = obj.title;
this.confidential = obj.confidential;
this.dueDate = obj.due_date;
@@ -17,6 +18,18 @@ class ListIssue {
this.assignees = [];
this.selected = false;
this.position = obj.relative_position || Infinity;
+ this.isFetching = {
+ subscriptions: true,
+ };
+ this.isLoading = {};
+ this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
+ this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
+ this.milestone_id = obj.milestone_id;
+ this.project_id = obj.project_id;
+
+ if (obj.project) {
+ this.project = new IssueProject(obj.project);
+ }
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
@@ -73,6 +86,18 @@ class ListIssue {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
}
+ updateData(newData) {
+ Object.assign(this, newData);
+ }
+
+ setFetchingState(key, value) {
+ this.isFetching[key] = value;
+ }
+
+ setLoadingState(key, value) {
+ this.isLoading[key] = value;
+ }
+
update (url) {
const data = {
issue: {
@@ -87,8 +112,11 @@ class ListIssue {
data.issue.label_ids = [''];
}
- return Vue.http.patch(url, data);
+ const projectPath = this.project ? this.project.path : '';
+ return Vue.http.patch(url.replace(':project_path', projectPath), data);
}
}
window.ListIssue = ListIssue;
+
+export default ListIssue;
diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js
deleted file mode 100644
index 9af88d167d6..00000000000
--- a/app/assets/javascripts/boards/models/label.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/* eslint-disable no-unused-vars, space-before-function-paren */
-
-class ListLabel {
- constructor (obj) {
- this.id = obj.id;
- this.title = obj.title;
- this.color = obj.color;
- this.textColor = obj.text_color;
- this.description = obj.description;
- this.priority = (obj.priority !== null) ? obj.priority : Infinity;
- }
-}
-
-window.ListLabel = ListLabel;
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index b4b09b3876e..e210d69895e 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -40,9 +40,8 @@ class List {
save () {
return gl.boardService.createList(this.label.id)
- .then((resp) => {
- const data = resp.json();
-
+ .then(res => res.data)
+ .then((data) => {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
@@ -91,8 +90,8 @@ class List {
}
return gl.boardService.getIssuesForList(this.id, data)
- .then((resp) => {
- const data = resp.json();
+ .then(res => res.data)
+ .then((data) => {
this.loading = false;
this.issuesSize = data.size;
@@ -109,13 +108,15 @@ class List {
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
- .then((resp) => {
- const data = resp.json();
- issue.id = data.iid;
+ .then(res => res.data)
+ .then((data) => {
+ issue.id = data.id;
+ issue.iid = data.iid;
+ issue.project = data.project;
if (this.issuesSize > 1) {
- const moveBeforeIid = this.issues[1].id;
- gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
+ const moveBeforeId = this.issues[1].id;
+ gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
}
});
}
@@ -127,19 +128,19 @@ class List {
}
addIssue (issue, listFrom, newIndex) {
- let moveBeforeIid = null;
- let moveAfterIid = null;
+ let moveBeforeId = null;
+ let moveAfterId = null;
if (!this.findIssue(issue.id)) {
if (newIndex !== undefined) {
this.issues.splice(newIndex, 0, issue);
if (this.issues[newIndex - 1]) {
- moveBeforeIid = this.issues[newIndex - 1].id;
+ moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex + 1]) {
- moveAfterIid = this.issues[newIndex + 1].id;
+ moveAfterId = this.issues[newIndex + 1].id;
}
} else {
this.issues.push(issue);
@@ -152,30 +153,30 @@ class List {
if (listFrom) {
this.issuesSize += 1;
- this.updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid);
+ this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
}
}
}
- moveIssue (issue, oldIndex, newIndex, moveBeforeIid, moveAfterIid) {
+ moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
- gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid)
+ gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
- updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) {
- gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid)
+ updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
+ gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
findIssue (id) {
- return this.issues.filter(issue => issue.id === id)[0];
+ return this.issues.find(issue => issue.id === id);
}
removeIssue (removeIssue) {
diff --git a/app/assets/javascripts/boards/models/project.js b/app/assets/javascripts/boards/models/project.js
new file mode 100644
index 00000000000..a3d5c7af7ac
--- /dev/null
+++ b/app/assets/javascripts/boards/models/project.js
@@ -0,0 +1,6 @@
+export default class IssueProject {
+ constructor(obj) {
+ this.id = obj.id;
+ this.path = obj.path;
+ }
+}
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index db9bced2f89..d78d4701974 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -1,87 +1,79 @@
-/* eslint-disable space-before-function-paren, comma-dangle, no-param-reassign, camelcase, max-len, no-unused-vars */
+import axios from '../../lib/utils/axios_utils';
+import { mergeUrlParams } from '../../lib/utils/url_utility';
+
+export default class BoardService {
+ constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
+ this.boardsEndpoint = boardsEndpoint;
+ this.boardId = boardId;
+ this.listsEndpoint = listsEndpoint;
+ this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
+ this.bulkUpdatePath = bulkUpdatePath;
+ }
-import Vue from 'vue';
+ generateBoardsPath(id) {
+ return `${this.boardsEndpoint}${id ? `/${id}` : ''}.json`;
+ }
-class BoardService {
- constructor (root, bulkUpdatePath, boardId) {
- this.boards = Vue.resource(`${root}{/id}.json`, {}, {
- issues: {
- method: 'GET',
- url: `${root}/${boardId}/issues.json`
- }
- });
- this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, {
- generate: {
- method: 'POST',
- url: `${root}/${boardId}/lists/generate.json`
- }
- });
- this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {});
- this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, {
- bulkUpdate: {
- method: 'POST',
- url: bulkUpdatePath,
- },
- });
+ generateIssuesPath(id) {
+ return `${this.listsEndpoint}${id ? `/${id}` : ''}/issues`;
+ }
- Vue.http.interceptors.push((request, next) => {
- request.headers['X-CSRF-Token'] = $.rails.csrfToken();
- next();
- });
+ static generateIssuePath(boardId, id) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`;
}
- all () {
- return this.lists.get();
+ all() {
+ return axios.get(this.listsEndpoint);
}
- generateDefaultLists () {
- return this.lists.generate({});
+ generateDefaultLists() {
+ return axios.post(this.listsEndpointGenerate, {});
}
- createList (label_id) {
- return this.lists.save({}, {
+ createList(labelId) {
+ return axios.post(this.listsEndpoint, {
list: {
- label_id
- }
+ label_id: labelId,
+ },
});
}
- updateList (id, position) {
- return this.lists.update({ id }, {
+ updateList(id, position) {
+ return axios.put(`${this.listsEndpoint}/${id}`, {
list: {
- position
- }
+ position,
+ },
});
}
- destroyList (id) {
- return this.lists.delete({ id });
+ destroyList(id) {
+ return axios.delete(`${this.listsEndpoint}/${id}`);
}
- getIssuesForList (id, filter = {}) {
+ getIssuesForList(id, filter = {}) {
const data = { id };
Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
- return this.issues.get(data);
+ return axios.get(mergeUrlParams(data, this.generateIssuesPath(id)));
}
- moveIssue (id, from_list_id = null, to_list_id = null, move_before_iid = null, move_after_iid = null) {
- return this.issue.update({ id }, {
- from_list_id,
- to_list_id,
- move_before_iid,
- move_after_iid,
+ moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
+ return axios.put(BoardService.generateIssuePath(this.boardId, id), {
+ from_list_id: fromListId,
+ to_list_id: toListId,
+ move_before_id: moveBeforeId,
+ move_after_id: moveAfterId,
});
}
- newIssue (id, issue) {
- return this.issues.save({ id }, {
- issue
+ newIssue(id, issue) {
+ return axios.post(this.generateIssuesPath(id), {
+ issue,
});
}
getBacklog(data) {
- return this.boards.issues(data);
+ return axios.get(mergeUrlParams(data, `${gon.relative_url_root}/-/boards/${this.boardId}/issues.json`));
}
bulkUpdate(issueIds, extraData = {}) {
@@ -91,7 +83,15 @@ class BoardService {
}),
};
- return this.issues.bulkUpdate(data);
+ return axios.post(this.bulkUpdatePath, data);
+ }
+
+ static getIssueInfo(endpoint) {
+ return axios.get(endpoint);
+ }
+
+ static toggleIssueSubscription(endpoint) {
+ return axios.post(endpoint);
}
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 1e12d4ca415..20e78edf2a2 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,7 +1,10 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var, no-shadow, dot-notation, max-len */
/* global List */
+import $ from 'jquery';
+import _ from 'underscore';
import Cookies from 'js-cookie';
+import { getUrlParamsArray } from '~/lib/utils/common_utils';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -13,16 +16,18 @@ gl.issueBoards.BoardsStore = {
},
state: {},
detail: {
- issue: {}
+ issue: {},
},
moving: {
issue: {},
- list: {}
+ list: {},
},
create () {
this.state.lists = [];
- this.filter.path = gl.utils.getUrlParamsArray().join('&');
- this.detail = { issue: {} };
+ this.filter.path = getUrlParamsArray().join('&');
+ this.detail = {
+ issue: {},
+ };
},
addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
diff --git a/app/assets/javascripts/boards/utils/query_data.js b/app/assets/javascripts/boards/utils/query_data.js
index 2cd3c146f11..65315979df7 100644
--- a/app/assets/javascripts/boards/utils/query_data.js
+++ b/app/assets/javascripts/boards/utils/query_data.js
@@ -5,7 +5,7 @@ export default (path, extraData) => path.split('&').reduce((dataParam, filterPar
const paramSplit = filterParam.split('=');
const paramKeyNormalized = paramSplit[0].replace('[]', '');
const isArray = paramSplit[0].indexOf('[]');
- const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' ');
+ const value = decodeURIComponent(paramSplit[1].replace(/\+/g, ' '));
if (isArray !== -1) {
if (!data[paramKeyNormalized]) {
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
index af8bcdc1794..839e369eaf6 100644
--- a/app/assets/javascripts/branches/branches_delete_modal.js
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
const MODAL_SELECTOR = '#modal-delete-branch';
class DeleteModal {
@@ -7,6 +9,7 @@ class DeleteModal {
this.$branchName = $('.js-branch-name', this.$modal);
this.$confirmInput = $('.js-delete-branch-input', this.$modal);
this.$deleteBtn = $('.js-delete-branch', this.$modal);
+ this.$notMerged = $('.js-not-merged', this.$modal);
this.bindEvents();
}
@@ -16,8 +19,10 @@ class DeleteModal {
}
setModalData(e) {
- this.branchName = e.currentTarget.dataset.branchName || '';
- this.deletePath = e.currentTarget.dataset.deletePath || '';
+ const branchData = e.currentTarget.dataset;
+ this.branchName = branchData.branchName || '';
+ this.deletePath = branchData.deletePath || '';
+ this.isMerged = !!branchData.isMerged;
this.updateModal();
}
@@ -30,6 +35,7 @@ class DeleteModal {
this.$confirmInput.val('');
this.$deleteBtn.attr('href', this.deletePath);
this.$deleteBtn.attr('disabled', true);
+ this.$notMerged.toggleClass('hidden', this.isMerged);
}
}
diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js
new file mode 100644
index 00000000000..1474d93dde6
--- /dev/null
+++ b/app/assets/javascripts/breadcrumb.js
@@ -0,0 +1,30 @@
+import $ from 'jquery';
+
+export const addTooltipToEl = (el) => {
+ const textEl = el.querySelector('.js-breadcrumb-item-text');
+
+ if (textEl && textEl.scrollWidth > textEl.offsetWidth) {
+ el.setAttribute('title', el.textContent);
+ el.setAttribute('data-container', 'body');
+ el.classList.add('has-tooltip');
+ }
+};
+
+export default () => {
+ const breadcrumbs = document.querySelector('.js-breadcrumbs-list');
+
+ if (breadcrumbs) {
+ const topLevelLinks = [...breadcrumbs.children].filter(el => !el.classList.contains('dropdown'))
+ .map(el => el.querySelector('a'))
+ .filter(el => el);
+ const $expander = $('.js-breadcrumbs-collapsed-expander');
+
+ topLevelLinks.forEach(el => addTooltipToEl(el));
+
+ $expander.closest('.dropdown')
+ .on('show.bs.dropdown hide.bs.dropdown', (e) => {
+ $('.js-breadcrumbs-collapsed-expander', e.currentTarget).toggleClass('open')
+ .tooltip('hide');
+ });
+ }
+};
diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js
index 2c1f988d987..7951348d8b2 100644
--- a/app/assets/javascripts/breakpoints.js
+++ b/app/assets/javascripts/breakpoints.js
@@ -1,66 +1,19 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, quotes, no-shadow, prefer-arrow-callback, prefer-template, consistent-return, no-return-assign, new-parens, no-param-reassign, max-len */
+export const breakpoints = {
+ lg: 1200,
+ md: 992,
+ sm: 768,
+ xs: 0,
+};
-var Breakpoints = (function() {
- var BreakpointInstance, instance;
+const BreakpointInstance = {
+ windowWidth: () => window.innerWidth,
+ getBreakpointSize() {
+ const windowWidth = this.windowWidth();
- function Breakpoints() {}
+ const breakpoint = Object.keys(breakpoints).find(key => windowWidth > breakpoints[key]);
- instance = null;
+ return breakpoint;
+ },
+};
- BreakpointInstance = (function() {
- var BREAKPOINTS;
-
- BREAKPOINTS = ["xs", "sm", "md", "lg"];
-
- function BreakpointInstance() {
- this.setup();
- }
-
- BreakpointInstance.prototype.setup = function() {
- var allDeviceSelector, els;
- allDeviceSelector = BREAKPOINTS.map(function(breakpoint) {
- return ".device-" + breakpoint;
- });
- if ($(allDeviceSelector.join(",")).length) {
- return;
- }
- // Create all the elements
- els = $.map(BREAKPOINTS, function(breakpoint) {
- return "<div class='device-" + breakpoint + " visible-" + breakpoint + "'></div>";
- });
- return $("body").append(els.join(''));
- };
-
- BreakpointInstance.prototype.visibleDevice = function() {
- var allDeviceSelector;
- allDeviceSelector = BREAKPOINTS.map(function(breakpoint) {
- return ".device-" + breakpoint;
- });
- return $(allDeviceSelector.join(",")).filter(":visible");
- };
-
- BreakpointInstance.prototype.getBreakpointSize = function() {
- var $visibleDevice;
- $visibleDevice = this.visibleDevice;
- // TODO: Consider refactoring in light of turbolinks removal.
- // the page refreshed via turbolinks
- if (!$visibleDevice().length) {
- this.setup();
- }
- $visibleDevice = this.visibleDevice();
- return $visibleDevice.attr("class").split("visible-")[1];
- };
-
- return BreakpointInstance;
- })();
-
- Breakpoints.get = function() {
- return instance != null ? instance : instance = new BreakpointInstance;
- };
-
- return Breakpoints;
-})();
-
-$(() => { window.bp = Breakpoints.get(); });
-
-window.Breakpoints = Breakpoints;
+export default BreakpointInstance;
diff --git a/app/assets/javascripts/broadcast_message.js b/app/assets/javascripts/broadcast_message.js
deleted file mode 100644
index f73e489e7b2..00000000000
--- a/app/assets/javascripts/broadcast_message.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, no-else-return, object-shorthand, comma-dangle, max-len */
-
-$(function() {
- var previewPath;
- $('input#broadcast_message_color').on('input', function() {
- var previewColor;
- previewColor = $(this).val();
- return $('div.broadcast-message-preview').css('background-color', previewColor);
- });
- $('input#broadcast_message_font').on('input', function() {
- var previewColor;
- previewColor = $(this).val();
- return $('div.broadcast-message-preview').css('color', previewColor);
- });
- previewPath = $('textarea#broadcast_message_message').data('preview-path');
- return $('textarea#broadcast_message_message').on('input', function() {
- var message;
- message = $(this).val();
- if (message === '') {
- return $('.js-broadcast-message-preview').text("Your message here");
- } else {
- return $.ajax({
- url: previewPath,
- type: "POST",
- data: {
- broadcast_message: {
- message: message
- }
- }
- });
- }
- });
-});
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
deleted file mode 100644
index c28f6e151a0..00000000000
--- a/app/assets/javascripts/build.js
+++ /dev/null
@@ -1,317 +0,0 @@
-/* eslint-disable func-names, wrap-iife, no-use-before-define,
-consistent-return, prefer-rest-params */
-/* global Breakpoints */
-
-import _ from 'underscore';
-import { bytesToKiB } from './lib/utils/number_utils';
-
-window.Build = (function () {
- Build.timeout = null;
- Build.state = null;
-
- function Build(options) {
- this.options = options || $('.js-build-options').data();
-
- this.pageUrl = this.options.pageUrl;
- this.buildUrl = this.options.buildUrl;
- this.buildStatus = this.options.buildStatus;
- this.state = this.options.logState;
- this.buildStage = this.options.buildStage;
- this.$document = $(document);
- this.logBytes = 0;
- this.scrollOffsetPadding = 30;
- this.hasBeenScrolled = false;
-
- 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.$buildRefreshAnimation = $('.js-build-refresh');
- 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
- this.bp = Breakpoints.get();
-
- this.initSidebar();
- this.populateJobs(this.buildStage);
- this.updateStageDropdownText(this.buildStage);
- this.sidebarOnResize();
-
- this.$document
- .off('click', '.js-sidebar-build-toggle')
- .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
-
- this.$document
- .off('click', '.stage-item')
- .on('click', '.stage-item', this.updateDropdown);
-
- // 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));
-
- const scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
-
- this.$scrollContainer
- .off('scroll')
- .on('scroll', () => {
- this.hasBeenScrolled = true;
- scrollThrottled();
- });
-
- $(window)
- .off('resize.build')
- .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
-
- this.updateArtifactRemoveDate();
-
- // eslint-disable-next-line
- this.getBuildTrace()
- .then(() => this.toggleScroll())
- .then(() => {
- if (!this.hasBeenScrolled) {
- this.scrollToBottom();
- }
- });
-
- this.verifyTopPosition();
- }
-
- 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 currentPosition = this.$scrollContainer.scrollTop();
- const bottomScroll = currentPosition + this.$scrollContainer.innerHeight();
-
- if (this.canScroll()) {
- if (currentPosition === 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.hasBeenScrolled = true;
- this.$scrollContainer.scrollTop(0);
- this.toggleScroll();
- };
-
- Build.prototype.scrollToBottom = function () {
- this.hasBeenScrolled = true;
- this.$scrollContainer.scrollTop(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 $flashError = $('.alert-wrapper');
- const $header = $('.build-header', $buildPage);
- const $runnersStuck = $('.js-build-stuck', $buildPage);
- const $startsEnvironment = $('.js-environment-container', $buildPage);
- const $erased = $('.js-build-erased', $buildPage);
- const prependTopDefault = 20;
-
- // header + navigation + margin
- let topPostion = 168;
-
- if ($header.length) {
- topPostion += $header.outerHeight();
- }
-
- if ($runnersStuck.length) {
- topPostion += $runnersStuck.outerHeight();
- }
-
- if ($startsEnvironment.length) {
- topPostion += $startsEnvironment.outerHeight() + prependTopDefault;
- }
-
- if ($erased.length) {
- topPostion += $erased.outerHeight() + prependTopDefault;
- }
-
- if ($flashError.length) {
- topPostion += $flashError.outerHeight();
- }
-
- this.$buildTrace.css({
- top: topPostion,
- });
- };
-
- Build.prototype.initSidebar = function () {
- this.$sidebar = $('.js-build-sidebar');
- this.$sidebar.niceScroll();
- };
-
- Build.prototype.getBuildTrace = function () {
- return $.ajax({
- url: `${this.pageUrl}/trace.json`,
- data: this.state,
- })
- .done((log) => {
- gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
- if (log.state) {
- this.state = log.state;
- }
-
- if (log.append) {
- this.$buildTraceOutput.append(log.html);
- this.logBytes += log.size;
- } else {
- this.$buildTraceOutput.html(log.html);
- this.logBytes = log.size;
- }
-
- // if the incremental sum of logBytes we received is less than the total
- // we need to show a message warning the user about that.
- if (this.logBytes < log.total) {
- // size is in bytes, we need to calculate KiB
- const size = bytesToKiB(this.logBytes);
- $('.js-truncated-info-size').html(`${size}`);
- this.$truncatedInfo.removeClass('hidden');
- } else {
- this.$truncatedInfo.addClass('hidden');
- }
-
- if (!log.complete) {
- this.toggleScrollAnimation(true);
-
- Build.timeout = setTimeout(() => {
- //eslint-disable-next-line
- this.getBuildTrace()
- .then(() => {
- if (!this.hasBeenScrolled) {
- this.scrollToBottom();
- }
- });
- }, 4000);
- } else {
- this.$buildRefreshAnimation.remove();
- this.toggleScrollAnimation(false);
- }
-
- if (log.status !== this.buildStatus) {
- gl.utils.visitUrl(this.pageUrl);
- }
- })
- .fail(() => {
- this.$buildRefreshAnimation.remove();
- });
- };
-
- Build.prototype.shouldHideSidebarForViewport = function () {
- const bootstrapBreakpoint = this.bp.getBreakpointSize();
- return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
- };
-
- Build.prototype.toggleSidebar = function (shouldHide) {
- const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
- const $toggleButton = $('.js-sidebar-build-toggle-header');
-
- this.$buildTrace
- .toggleClass('sidebar-expanded', shouldShow)
- .toggleClass('sidebar-collapsed', shouldHide);
- this.$sidebar
- .toggleClass('right-sidebar-expanded', shouldShow)
- .toggleClass('right-sidebar-collapsed', shouldHide);
-
- $('.js-build-page')
- .toggleClass('sidebar-expanded', shouldShow)
- .toggleClass('sidebar-collapsed', shouldHide);
-
- if (this.$sidebar.hasClass('right-sidebar-expanded')) {
- $toggleButton.addClass('hidden');
- } else {
- $toggleButton.removeClass('hidden');
- }
- };
-
- Build.prototype.sidebarOnResize = function () {
- this.toggleSidebar(this.shouldHideSidebarForViewport());
-
- this.verifyTopPosition();
-
- if (this.canScroll()) {
- this.toggleScroll();
- }
- };
-
- Build.prototype.sidebarOnClick = function () {
- if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
- this.verifyTopPosition();
- };
-
- Build.prototype.updateArtifactRemoveDate = function () {
- const $date = $('.js-artifacts-remove');
- if ($date.length) {
- const date = $date.text();
- return $date.text(
- gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '),
- );
- }
- };
-
- Build.prototype.populateJobs = function (stage) {
- $('.build-job').hide();
- $(`.build-job[data-stage="${stage}"]`).show();
- };
-
- Build.prototype.updateStageDropdownText = function (stage) {
- $('.stage-selection').text(stage);
- };
-
- Build.prototype.updateDropdown = function (e) {
- e.preventDefault();
- const stage = e.currentTarget.text;
- this.updateStageDropdownText(stage);
- this.populateJobs(stage);
- };
-
- return Build;
-})();
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index bd479700fd3..3fa16517388 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,25 +1,47 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, no-return-assign, max-len */
+/* eslint-disable func-names, prefer-arrow-callback, no-return-assign */
-window.BuildArtifacts = (function() {
- function BuildArtifacts() {
+import $ from 'jquery';
+import { visitUrl } from './lib/utils/url_utility';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
+
+export default class BuildArtifacts {
+ constructor() {
this.disablePropagation();
this.setupEntryClick();
+ this.setupTooltips();
}
-
- BuildArtifacts.prototype.disablePropagation = function() {
- $('.top-block').on('click', '.download', function(e) {
+ // eslint-disable-next-line class-methods-use-this
+ disablePropagation() {
+ $('.top-block').on('click', '.download', function (e) {
return e.stopPropagation();
});
- return $('.tree-holder').on('click', 'tr[data-link] a', function(e) {
+ return $('.tree-holder').on('click', 'tr[data-link] a', function (e) {
return e.stopImmediatePropagation();
});
- };
-
- BuildArtifacts.prototype.setupEntryClick = function() {
- return $('.tree-holder').on('click', 'tr[data-link]', function(e) {
- return window.location = this.dataset.link;
+ }
+ // eslint-disable-next-line class-methods-use-this
+ setupEntryClick() {
+ return $('.tree-holder').on('click', 'tr[data-link]', function () {
+ visitUrl(this.dataset.link, convertPermissionToBoolean(this.dataset.externalLink));
+ });
+ }
+ // eslint-disable-next-line class-methods-use-this
+ setupTooltips() {
+ $('.js-artifact-tree-tooltip').tooltip({
+ placement: 'bottom',
+ // Stop the tooltip from hiding when we stop hovering the element directly
+ // We handle all the showing/hiding below
+ trigger: 'manual',
});
- };
- return BuildArtifacts;
-})();
+ // We want the tooltip to show if you hover anywhere on the row
+ // But be placed below and in the middle of the file name
+ $('.js-artifact-tree-row')
+ .on('mouseenter', (e) => {
+ $(e.currentTarget).find('.js-artifact-tree-tooltip').tooltip('show');
+ })
+ .on('mouseleave', (e) => {
+ $(e.currentTarget).find('.js-artifact-tree-tooltip').tooltip('hide');
+ });
+ }
+}
diff --git a/app/assets/javascripts/build_variables.js b/app/assets/javascripts/build_variables.js
index 99082b412e2..d398e4a4c83 100644
--- a/app/assets/javascripts/build_variables.js
+++ b/app/assets/javascripts/build_variables.js
@@ -1,8 +1,10 @@
-/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren */
+import $ from 'jquery';
-$(function() {
- $('.reveal-variables').off('click').on('click', function() {
- $('.js-build').toggle().niceScroll();
- $(this).hide();
- });
-});
+export default function handleRevealVariables() {
+ $('.js-reveal-variables')
+ .off('click')
+ .on('click', function click() {
+ $('.js-build-variables').toggle();
+ $(this).hide();
+ });
+}
diff --git a/app/assets/javascripts/ci_lint_editor.js b/app/assets/javascripts/ci_lint_editor.js
deleted file mode 100644
index dd4a08a2f31..00000000000
--- a/app/assets/javascripts/ci_lint_editor.js
+++ /dev/null
@@ -1,17 +0,0 @@
-
-window.gl = window.gl || {};
-
-class CILintEditor {
- constructor() {
- this.editor = window.ace.edit('ci-editor');
- this.textarea = document.querySelector('#content');
-
- this.editor.getSession().setMode('ace/mode/yaml');
- this.editor.on('input', () => {
- const content = this.editor.getSession().getValue();
- this.textarea.value = content;
- });
- }
-}
-
-gl.CILintEditor = CILintEditor;
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
new file mode 100644
index 00000000000..b33adff609f
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
@@ -0,0 +1,117 @@
+import _ from 'underscore';
+import axios from '../lib/utils/axios_utils';
+import { s__ } from '../locale';
+import Flash from '../flash';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import statusCodes from '../lib/utils/http_status';
+import VariableList from './ci_variable_list';
+
+function generateErrorBoxContent(errors) {
+ const errorList = [].concat(errors).map(errorString => `
+ <li>
+ ${_.escape(errorString)}
+ </li>
+ `);
+
+ return `
+ <p>
+ ${s__('CiVariable|Validation failed')}
+ </p>
+ <ul>
+ ${errorList.join('')}
+ </ul>
+ `;
+}
+
+// Used for the variable list on CI/CD projects/groups settings page
+export default class AjaxVariableList {
+ constructor({
+ container,
+ saveButton,
+ errorBox,
+ formField = 'variables',
+ saveEndpoint,
+ }) {
+ this.container = container;
+ this.saveButton = saveButton;
+ this.errorBox = errorBox;
+ this.saveEndpoint = saveEndpoint;
+
+ this.variableList = new VariableList({
+ container: this.container,
+ formField,
+ });
+
+ this.bindEvents();
+ this.variableList.init();
+ }
+
+ bindEvents() {
+ this.saveButton.addEventListener('click', this.onSaveClicked.bind(this));
+ }
+
+ onSaveClicked() {
+ const loadingIcon = this.saveButton.querySelector('.js-secret-variables-save-loading-icon');
+ loadingIcon.classList.toggle('hide', false);
+ this.errorBox.classList.toggle('hide', true);
+ // We use this to prevent a user from changing a key before we have a chance
+ // to match it up in `updateRowsWithPersistedVariables`
+ this.variableList.toggleEnableRow(false);
+
+ return axios.patch(this.saveEndpoint, {
+ variables_attributes: this.variableList.getAllData(),
+ }, {
+ // We want to be able to process the `res.data` from a 400 error response
+ // and print the validation messages such as duplicate variable keys
+ validateStatus: status => (
+ status >= statusCodes.OK &&
+ status < statusCodes.MULTIPLE_CHOICES
+ ) ||
+ status === statusCodes.BAD_REQUEST,
+ })
+ .then((res) => {
+ loadingIcon.classList.toggle('hide', true);
+ this.variableList.toggleEnableRow(true);
+
+ if (res.status === statusCodes.OK && res.data) {
+ this.updateRowsWithPersistedVariables(res.data.variables);
+ this.variableList.hideValues();
+ } else if (res.status === statusCodes.BAD_REQUEST) {
+ // Validation failed
+ this.errorBox.innerHTML = generateErrorBoxContent(res.data);
+ this.errorBox.classList.toggle('hide', false);
+ }
+ })
+ .catch(() => {
+ loadingIcon.classList.toggle('hide', true);
+ this.variableList.toggleEnableRow(true);
+ Flash(s__('CiVariable|Error occured while saving variables'));
+ });
+ }
+
+ updateRowsWithPersistedVariables(persistedVariables = []) {
+ const persistedVariableMap = [].concat(persistedVariables).reduce((variableMap, variable) => ({
+ ...variableMap,
+ [variable.key]: variable,
+ }), {});
+
+ this.container.querySelectorAll('.js-row').forEach((row) => {
+ // If we submitted a row that was destroyed, remove it so we don't try
+ // to destroy it again which would cause a BE error
+ const destroyInput = row.querySelector('.js-ci-variable-input-destroy');
+ if (convertPermissionToBoolean(destroyInput.value)) {
+ row.remove();
+ // Update the ID input so any future edits and `_destroy` will apply on the BE
+ } else {
+ const key = row.querySelector('.js-ci-variable-input-key').value;
+ const persistedVariable = persistedVariableMap[key];
+
+ if (persistedVariable) {
+ // eslint-disable-next-line no-param-reassign
+ row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id;
+ row.setAttribute('data-is-persisted', 'true');
+ }
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
new file mode 100644
index 00000000000..e177a3bfdc7
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -0,0 +1,222 @@
+import $ from 'jquery';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import { s__ } from '../locale';
+import setupToggleButtons from '../toggle_buttons';
+import CreateItemDropdown from '../create_item_dropdown';
+import SecretValues from '../behaviors/secret_values';
+
+const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments');
+
+function createEnvironmentItem(value) {
+ return {
+ title: value === '*' ? ALL_ENVIRONMENTS_STRING : value,
+ id: value,
+ text: value === '*' ? s__('CiVariable|* (All environments)') : value,
+ };
+}
+
+export default class VariableList {
+ constructor({
+ container,
+ formField,
+ }) {
+ this.$container = $(container);
+ this.formField = formField;
+ this.environmentDropdownMap = new WeakMap();
+
+ this.inputMap = {
+ id: {
+ selector: '.js-ci-variable-input-id',
+ default: '',
+ },
+ key: {
+ selector: '.js-ci-variable-input-key',
+ default: '',
+ },
+ secret_value: {
+ selector: '.js-ci-variable-input-value',
+ default: '',
+ },
+ protected: {
+ selector: '.js-ci-variable-input-protected',
+ default: 'false',
+ },
+ environment_scope: {
+ // We can't use a `.js-` class here because
+ // gl_dropdown replaces the <input> and doesn't copy over the class
+ // See https://gitlab.com/gitlab-org/gitlab-ce/issues/42458
+ selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`,
+ default: '*',
+ },
+ _destroy: {
+ selector: '.js-ci-variable-input-destroy',
+ default: '',
+ },
+ };
+
+ this.secretValues = new SecretValues({
+ container: this.$container[0],
+ valueSelector: '.js-row:not(:last-child) .js-secret-value',
+ placeholderSelector: '.js-row:not(:last-child) .js-secret-value-placeholder',
+ });
+ }
+
+ init() {
+ this.bindEvents();
+ this.secretValues.init();
+ }
+
+ bindEvents() {
+ this.$container.find('.js-row').each((index, rowEl) => {
+ this.initRow(rowEl);
+ });
+
+ this.$container.on('click', '.js-row-remove-button', (e) => {
+ e.preventDefault();
+ this.removeRow($(e.currentTarget).closest('.js-row'));
+ });
+
+ const inputSelector = Object.keys(this.inputMap)
+ .map(name => this.inputMap[name].selector)
+ .join(',');
+
+ // Remove any empty rows except the last row
+ this.$container.on('blur', inputSelector, (e) => {
+ const $row = $(e.currentTarget).closest('.js-row');
+
+ if ($row.is(':not(:last-child)') && !this.checkIfRowTouched($row)) {
+ this.removeRow($row);
+ }
+ });
+
+ // Always make sure there is an empty last row
+ this.$container.on('input trigger-change', inputSelector, () => {
+ const $lastRow = this.$container.find('.js-row').last();
+
+ if (this.checkIfRowTouched($lastRow)) {
+ this.insertRow($lastRow);
+ }
+ });
+ }
+
+ initRow(rowEl) {
+ const $row = $(rowEl);
+
+ setupToggleButtons($row[0]);
+
+ // Reset the resizable textarea
+ $row.find(this.inputMap.secret_value.selector).css('height', '');
+
+ const $environmentSelect = $row.find('.js-variable-environment-toggle');
+ if ($environmentSelect.length) {
+ const createItemDropdown = new CreateItemDropdown({
+ $dropdown: $environmentSelect,
+ defaultToggleLabel: ALL_ENVIRONMENTS_STRING,
+ fieldName: `${this.formField}[variables_attributes][][environment_scope]`,
+ getData: (term, callback) => callback(this.getEnvironmentValues()),
+ createNewItemFromValue: createEnvironmentItem,
+ onSelect: () => {
+ // Refresh the other dropdowns in the variable list
+ // so they have the new value we just picked
+ this.refreshDropdownData();
+
+ $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change');
+ },
+ });
+
+ // Clear out any data that might have been left-over from the row clone
+ createItemDropdown.clearDropdown();
+
+ this.environmentDropdownMap.set($row[0], createItemDropdown);
+ }
+ }
+
+ insertRow($row) {
+ const $rowClone = $row.clone();
+ $rowClone.removeAttr('data-is-persisted');
+
+ // Reset the inputs to their defaults
+ Object.keys(this.inputMap).forEach((name) => {
+ const entry = this.inputMap[name];
+ $rowClone.find(entry.selector).val(entry.default);
+ });
+
+ this.initRow($rowClone);
+
+ $row.after($rowClone);
+ }
+
+ removeRow(row) {
+ const $row = $(row);
+ const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted'));
+
+ if (isPersisted) {
+ $row.hide();
+ $row
+ // eslint-disable-next-line no-underscore-dangle
+ .find(this.inputMap._destroy.selector)
+ .val(true);
+ } else {
+ $row.remove();
+ }
+
+ // Refresh the other dropdowns in the variable list
+ // so any value with the variable deleted is gone
+ this.refreshDropdownData();
+ }
+
+ checkIfRowTouched($row) {
+ return Object.keys(this.inputMap).some((name) => {
+ const entry = this.inputMap[name];
+ const $el = $row.find(entry.selector);
+ return $el.length && $el.val() !== entry.default;
+ });
+ }
+
+ toggleEnableRow(isEnabled = true) {
+ this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled);
+ this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled);
+ }
+
+ hideValues() {
+ this.secretValues.updateDom(false);
+ }
+
+ getAllData() {
+ // Ignore the last empty row because we don't want to try persist
+ // a blank variable and run into validation problems.
+ const validRows = this.$container.find('.js-row').toArray().slice(0, -1);
+
+ return validRows.map((rowEl) => {
+ const resultant = {};
+ Object.keys(this.inputMap).forEach((name) => {
+ const entry = this.inputMap[name];
+ const $input = $(rowEl).find(entry.selector);
+ if ($input.length) {
+ resultant[name] = $input.val();
+ }
+ });
+
+ return resultant;
+ });
+ }
+
+ getEnvironmentValues() {
+ const valueMap = this.$container.find(this.inputMap.environment_scope.selector).toArray()
+ .reduce((prevValueMap, envInput) => ({
+ ...prevValueMap,
+ [envInput.value]: envInput.value,
+ }), {});
+
+ return Object.keys(valueMap).map(createEnvironmentItem);
+ }
+
+ refreshDropdownData() {
+ this.$container.find('.js-row').each((index, rowEl) => {
+ const environmentDropdown = this.environmentDropdownMap.get(rowEl);
+ if (environmentDropdown) {
+ environmentDropdown.refreshData();
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
new file mode 100644
index 00000000000..7cd5916ac9c
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
@@ -0,0 +1,27 @@
+import $ from 'jquery';
+import VariableList from './ci_variable_list';
+
+// Used for the variable list on scheduled pipeline edit page
+export default function setupNativeFormVariableList({
+ container,
+ formField = 'variables',
+}) {
+ const $container = $(container);
+
+ const variableList = new VariableList({
+ container: $container,
+ formField,
+ });
+ variableList.init();
+
+ // Clear out the names in the empty last row so it
+ // doesn't get submitted and throw validation errors
+ $container.closest('form').on('submit trigger-submit', () => {
+ const $lastRow = $container.find('.js-row').last();
+
+ const isTouched = variableList.checkIfRowTouched($lastRow);
+ if (!isTouched) {
+ $lastRow.find('input, textarea').attr('name', '');
+ }
+ });
+}
diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js
new file mode 100644
index 00000000000..882d20671cc
--- /dev/null
+++ b/app/assets/javascripts/close_reopen_report_toggle.js
@@ -0,0 +1,97 @@
+import DropLab from './droplab/drop_lab';
+import ISetter from './droplab/plugins/input_setter';
+
+// Todo: Remove this when fixing issue in input_setter plugin
+const InputSetter = Object.assign({}, ISetter);
+
+class CloseReopenReportToggle {
+ constructor(opts = {}) {
+ this.dropdownTrigger = opts.dropdownTrigger;
+ this.dropdownList = opts.dropdownList;
+ this.button = opts.button;
+ }
+
+ initDroplab() {
+ this.reopenItem = this.dropdownList.querySelector('.reopen-item');
+ this.closeItem = this.dropdownList.querySelector('.close-item');
+
+ this.droplab = new DropLab();
+
+ const config = this.setConfig();
+
+ this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
+ }
+
+ updateButton(isClosed) {
+ this.toggleButtonType(isClosed);
+
+ this.button.blur();
+ }
+
+ toggleButtonType(isClosed) {
+ const [showItem, hideItem] = this.getButtonTypes(isClosed);
+
+ showItem.classList.remove('hidden');
+ showItem.classList.add('droplab-item-selected');
+
+ hideItem.classList.add('hidden');
+ hideItem.classList.remove('droplab-item-selected');
+
+ showItem.click();
+ }
+
+ getButtonTypes(isClosed) {
+ return isClosed ? [this.reopenItem, this.closeItem] : [this.closeItem, this.reopenItem];
+ }
+
+ setDisable(shouldDisable = true) {
+ if (shouldDisable) {
+ this.button.setAttribute('disabled', 'true');
+ this.dropdownTrigger.setAttribute('disabled', 'true');
+ } else {
+ this.button.removeAttribute('disabled');
+ this.dropdownTrigger.removeAttribute('disabled');
+ }
+ }
+
+ setConfig() {
+ const config = {
+ InputSetter: [
+ {
+ input: this.button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'data-value',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'title',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-button-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: this.dropdownTrigger,
+ valueAttribute: 'data-toggle-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-url',
+ inputAttribute: 'href',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-method',
+ inputAttribute: 'data-method',
+ },
+ ],
+ };
+
+ return config;
+ }
+}
+
+export default CloseReopenReportToggle;
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
new file mode 100644
index 00000000000..01aec4f36af
--- /dev/null
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -0,0 +1,237 @@
+import Visibility from 'visibilityjs';
+import Vue from 'vue';
+import { s__, sprintf } from '../locale';
+import Flash from '../flash';
+import Poll from '../lib/utils/poll';
+import initSettingsPanels from '../settings_panels';
+import eventHub from './event_hub';
+import {
+ APPLICATION_INSTALLED,
+ REQUEST_LOADING,
+ REQUEST_SUCCESS,
+ REQUEST_FAILURE,
+} from './constants';
+import ClustersService from './services/clusters_service';
+import ClustersStore from './stores/clusters_store';
+import applications from './components/applications.vue';
+import setupToggleButtons from '../toggle_buttons';
+
+/**
+ * Cluster page has 2 separate parts:
+ * Toggle button and applications section
+ *
+ * - Polling status while creating or scheduled
+ * - Update status area with the response result
+ */
+
+export default class Clusters {
+ constructor() {
+ const {
+ statusPath,
+ installHelmPath,
+ installIngressPath,
+ installRunnerPath,
+ installPrometheusPath,
+ managePrometheusPath,
+ clusterStatus,
+ clusterStatusReason,
+ helpPath,
+ ingressHelpPath,
+ ingressDnsHelpPath,
+ } = document.querySelector('.js-edit-cluster-form').dataset;
+
+ this.store = new ClustersStore();
+ this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
+ this.store.setManagePrometheusPath(managePrometheusPath);
+ this.store.updateStatus(clusterStatus);
+ this.store.updateStatusReason(clusterStatusReason);
+ this.service = new ClustersService({
+ endpoint: statusPath,
+ installHelmEndpoint: installHelmPath,
+ installIngressEndpoint: installIngressPath,
+ installRunnerEndpoint: installRunnerPath,
+ installPrometheusEndpoint: installPrometheusPath,
+ });
+
+ this.installApplication = this.installApplication.bind(this);
+ this.showToken = this.showToken.bind(this);
+
+ this.errorContainer = document.querySelector('.js-cluster-error');
+ this.successContainer = document.querySelector('.js-cluster-success');
+ this.creatingContainer = document.querySelector('.js-cluster-creating');
+ this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
+ this.successApplicationContainer = document.querySelector('.js-cluster-application-notice');
+ this.showTokenButton = document.querySelector('.js-show-cluster-token');
+ this.tokenField = document.querySelector('.js-cluster-token');
+
+ initSettingsPanels();
+ setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area'));
+ this.initApplications();
+
+ if (this.store.state.status !== 'created') {
+ this.updateContainer(null, this.store.state.status, this.store.state.statusReason);
+ }
+
+ this.addListeners();
+ if (statusPath) {
+ this.initPolling();
+ }
+ }
+
+ initApplications() {
+ const store = this.store;
+ const el = document.querySelector('#js-cluster-applications');
+
+ this.applications = new Vue({
+ el,
+ components: {
+ applications,
+ },
+ data() {
+ return {
+ state: store.state,
+ };
+ },
+ render(createElement) {
+ return createElement('applications', {
+ props: {
+ applications: this.state.applications,
+ helpPath: this.state.helpPath,
+ ingressHelpPath: this.state.ingressHelpPath,
+ managePrometheusPath: this.state.managePrometheusPath,
+ ingressDnsHelpPath: this.state.ingressDnsHelpPath,
+ },
+ });
+ },
+ });
+ }
+
+ addListeners() {
+ if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
+ eventHub.$on('installApplication', this.installApplication);
+ }
+
+ removeListeners() {
+ if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
+ eventHub.$off('installApplication', this.installApplication);
+ }
+
+ initPolling() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'fetchData',
+ successCallback: data => this.handleSuccess(data),
+ errorCallback: () => Clusters.handleError(),
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ } else {
+ this.service.fetchData()
+ .then(data => this.handleSuccess(data))
+ .catch(() => Clusters.handleError());
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden() && !this.destroyed) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ }
+
+ static handleError() {
+ Flash(s__('ClusterIntegration|Something went wrong on our end.'));
+ }
+
+ handleSuccess(data) {
+ const prevStatus = this.store.state.status;
+ const prevApplicationMap = Object.assign({}, this.store.state.applications);
+
+ this.store.updateStateFromServer(data.data);
+
+ this.checkForNewInstalls(prevApplicationMap, this.store.state.applications);
+ this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
+ }
+
+ showToken() {
+ const type = this.tokenField.getAttribute('type');
+
+ if (type === 'password') {
+ this.tokenField.setAttribute('type', 'text');
+ } else {
+ this.tokenField.setAttribute('type', 'password');
+ }
+ }
+
+ hideAll() {
+ this.errorContainer.classList.add('hidden');
+ this.successContainer.classList.add('hidden');
+ this.creatingContainer.classList.add('hidden');
+ }
+
+ checkForNewInstalls(prevApplicationMap, newApplicationMap) {
+ const appTitles = Object.keys(newApplicationMap)
+ .filter(appId => newApplicationMap[appId].status === APPLICATION_INSTALLED &&
+ prevApplicationMap[appId].status !== APPLICATION_INSTALLED &&
+ prevApplicationMap[appId].status !== null)
+ .map(appId => newApplicationMap[appId].title);
+
+ if (appTitles.length > 0) {
+ const text = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster'), {
+ appList: appTitles.join(', '),
+ });
+ Flash(text, 'notice', this.successApplicationContainer);
+ }
+ }
+
+ updateContainer(prevStatus, status, error) {
+ this.hideAll();
+
+ // We poll all the time but only want the `created` banner to show when newly created
+ if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) {
+ switch (status) {
+ case 'created':
+ this.successContainer.classList.remove('hidden');
+ break;
+ case 'errored':
+ this.errorContainer.classList.remove('hidden');
+ this.errorReasonContainer.textContent = error;
+ break;
+ case 'scheduled':
+ case 'creating':
+ this.creatingContainer.classList.remove('hidden');
+ break;
+ default:
+ this.hideAll();
+ }
+ }
+ }
+
+ installApplication(appId) {
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING);
+ this.store.updateAppProperty(appId, 'requestReason', null);
+
+ this.service.installApplication(appId)
+ .then(() => {
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS);
+ })
+ .catch(() => {
+ this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
+ this.store.updateAppProperty(appId, 'requestReason', s__('ClusterIntegration|Request to begin installing failed'));
+ });
+ }
+
+ destroy() {
+ this.destroyed = true;
+
+ this.removeListeners();
+
+ if (this.poll) {
+ this.poll.stop();
+ }
+
+ this.applications.$destroy();
+ }
+}
diff --git a/app/assets/javascripts/clusters/clusters_index.js b/app/assets/javascripts/clusters/clusters_index.js
new file mode 100644
index 00000000000..2e3ad244375
--- /dev/null
+++ b/app/assets/javascripts/clusters/clusters_index.js
@@ -0,0 +1,20 @@
+import Flash from '../flash';
+import { s__ } from '../locale';
+import setupToggleButtons from '../toggle_buttons';
+import ClustersService from './services/clusters_service';
+
+export default () => {
+ const clusterList = document.querySelector('.js-clusters-list');
+ // The empty state won't have a clusterList
+ if (clusterList) {
+ setupToggleButtons(
+ document.querySelector('.js-clusters-list'),
+ (value, toggle) =>
+ ClustersService.updateCluster(toggle.dataset.endpoint, { cluster: { enabled: value } })
+ .catch((err) => {
+ Flash(s__('ClusterIntegration|Something went wrong on our end.'));
+ throw err;
+ }),
+ );
+ }
+};
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
new file mode 100644
index 00000000000..c2a35341eb2
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -0,0 +1,207 @@
+<script>
+ /* eslint-disable vue/require-default-prop */
+ import { s__, sprintf } from '../../locale';
+ import eventHub from '../event_hub';
+ import loadingButton from '../../vue_shared/components/loading_button.vue';
+ import {
+ APPLICATION_NOT_INSTALLABLE,
+ APPLICATION_SCHEDULED,
+ APPLICATION_INSTALLABLE,
+ APPLICATION_INSTALLING,
+ APPLICATION_INSTALLED,
+ APPLICATION_ERROR,
+ REQUEST_LOADING,
+ REQUEST_SUCCESS,
+ REQUEST_FAILURE,
+ } from '../constants';
+
+ export default {
+ components: {
+ loadingButton,
+ },
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ titleLink: {
+ type: String,
+ required: false,
+ },
+ manageLink: {
+ type: String,
+ required: false,
+ },
+ status: {
+ type: String,
+ required: false,
+ },
+ statusReason: {
+ type: String,
+ required: false,
+ },
+ requestStatus: {
+ type: String,
+ required: false,
+ },
+ requestReason: {
+ type: String,
+ required: false,
+ },
+ },
+ computed: {
+ rowJsClass() {
+ return `js-cluster-application-row-${this.id}`;
+ },
+ installButtonLoading() {
+ return !this.status ||
+ this.status === APPLICATION_SCHEDULED ||
+ this.status === APPLICATION_INSTALLING ||
+ this.requestStatus === REQUEST_LOADING;
+ },
+ installButtonDisabled() {
+ // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but
+ // we already made a request to install and are just waiting for the real-time
+ // to sync up.
+ return (this.status !== APPLICATION_INSTALLABLE
+ && this.status !== APPLICATION_ERROR) ||
+ this.requestStatus === REQUEST_LOADING ||
+ this.requestStatus === REQUEST_SUCCESS;
+ },
+ installButtonLabel() {
+ let label;
+ if (
+ this.status === APPLICATION_NOT_INSTALLABLE ||
+ this.status === APPLICATION_INSTALLABLE ||
+ this.status === APPLICATION_ERROR
+ ) {
+ label = s__('ClusterIntegration|Install');
+ } else if (this.status === APPLICATION_SCHEDULED ||
+ this.status === APPLICATION_INSTALLING) {
+ label = s__('ClusterIntegration|Installing');
+ } else if (this.status === APPLICATION_INSTALLED) {
+ label = s__('ClusterIntegration|Installed');
+ }
+
+ return label;
+ },
+ showManageButton() {
+ return this.manageLink && this.status === APPLICATION_INSTALLED;
+ },
+ manageButtonLabel() {
+ return s__('ClusterIntegration|Manage');
+ },
+ hasError() {
+ return this.status === APPLICATION_ERROR ||
+ this.requestStatus === REQUEST_FAILURE;
+ },
+ generalErrorDescription() {
+ return sprintf(
+ s__('ClusterIntegration|Something went wrong while installing %{title}'), {
+ title: this.title,
+ },
+ );
+ },
+ },
+ methods: {
+ installClicked() {
+ eventHub.$emit('installApplication', this.id);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="gl-responsive-table-row gl-responsive-table-row-col-span"
+ :class="rowJsClass"
+ >
+ <div
+ class="gl-responsive-table-row-layout"
+ role="row"
+ >
+ <a
+ v-if="titleLink"
+ :href="titleLink"
+ target="blank"
+ rel="noopener noreferrer"
+ role="gridcell"
+ class="table-section section-15 section-align-top js-cluster-application-title"
+ >
+ {{ title }}
+ </a>
+ <span
+ v-else
+ class="table-section section-15 section-align-top js-cluster-application-title"
+ >
+ {{ title }}
+ </span>
+ <div
+ class="table-section section-wrap"
+ role="gridcell"
+ >
+ <slot name="description"></slot>
+ </div>
+ <div
+ class="table-section table-button-footer section-align-top"
+ :class="{ 'section-20': showManageButton, 'section-15': !showManageButton }"
+ role="gridcell"
+ >
+ <div
+ v-if="showManageButton"
+ class="btn-group table-action-buttons"
+ >
+ <a
+ class="btn"
+ :href="manageLink"
+ >
+ {{ manageButtonLabel }}
+ </a>
+ </div>
+ <div class="btn-group table-action-buttons">
+ <loading-button
+ class="js-cluster-application-install-button"
+ :loading="installButtonLoading"
+ :disabled="installButtonDisabled"
+ :label="installButtonLabel"
+ @click="installClicked"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="hasError"
+ class="gl-responsive-table-row-layout"
+ role="row"
+ >
+ <div
+ class="alert alert-danger alert-block append-bottom-0 table-section section-100"
+ role="gridcell"
+ >
+ <div>
+ <p class="js-cluster-application-general-error-message">
+ {{ generalErrorDescription }}
+ </p>
+ <ul v-if="statusReason || requestReason">
+ <li
+ v-if="statusReason"
+ class="js-cluster-application-status-error-message"
+ >
+ {{ statusReason }}
+ </li>
+ <li
+ v-if="requestReason"
+ class="js-cluster-application-request-error-message"
+ >
+ {{ requestReason }}
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
new file mode 100644
index 00000000000..f8dcdf3f60a
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -0,0 +1,280 @@
+<script>
+ import _ from 'underscore';
+ import { s__, sprintf } from '../../locale';
+ import applicationRow from './application_row.vue';
+ import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
+ import {
+ APPLICATION_INSTALLED,
+ INGRESS,
+ } from '../constants';
+
+ export default {
+ components: {
+ applicationRow,
+ clipboardButton,
+ },
+ props: {
+ applications: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ ingressHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ ingressDnsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ managePrometheusPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ generalApplicationDescription() {
+ return sprintf(
+ _.escape(s__(
+ `ClusterIntegration|Install applications on your Kubernetes cluster.
+ Read more about %{helpLink}`,
+ )), {
+ helpLink: `<a href="${this.helpPath}">
+ ${_.escape(s__('ClusterIntegration|installing applications'))}
+ </a>`,
+ },
+ false,
+ );
+ },
+ ingressId() {
+ return INGRESS;
+ },
+ ingressInstalled() {
+ return this.applications.ingress.status === APPLICATION_INSTALLED;
+ },
+ ingressExternalIp() {
+ return this.applications.ingress.externalIp;
+ },
+ ingressDescription() {
+ const extraCostParagraph = sprintf(
+ _.escape(s__(
+ `ClusterIntegration|%{boldNotice} This will add some extra resources
+ like a load balancer, which may incur additional costs depending on
+ the hosting provider your Kubernetes cluster is installed on. If you are using GKE,
+ you can %{pricingLink}.`,
+ )), {
+ boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`,
+ pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`,
+ },
+ false,
+ );
+
+ const externalIpParagraph = sprintf(
+ _.escape(s__(
+ `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS
+ at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`,
+ )), {
+ ingressHelpLink: `<a href="${this.ingressHelpPath}">
+ ${_.escape(s__('ClusterIntegration|More information'))}
+ </a>`,
+ },
+ false,
+ );
+
+ return `
+ <p>
+ ${extraCostParagraph}
+ </p>
+ <p class="settings-message append-bottom-0">
+ ${externalIpParagraph}
+ </p>
+ `;
+ },
+ prometheusDescription() {
+ return sprintf(
+ _.escape(s__(
+ `ClusterIntegration|Prometheus is an open-source monitoring system
+ with %{gitlabIntegrationLink} to monitor deployed applications.`,
+ )), {
+ gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
+ target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`,
+ },
+ false,
+ );
+ },
+ },
+ };
+</script>
+
+<template>
+ <section
+ id="cluster-applications"
+ class="settings no-animate expanded"
+ >
+ <div class="settings-header">
+ <h4>
+ {{ s__('ClusterIntegration|Applications') }}
+ </h4>
+ <p
+ class="append-bottom-0"
+ v-html="generalApplicationDescription"
+ >
+ </p>
+ </div>
+
+ <div class="settings-content">
+ <div class="append-bottom-20">
+ <application-row
+ id="helm"
+ :title="applications.helm.title"
+ title-link="https://docs.helm.sh/"
+ :status="applications.helm.status"
+ :status-reason="applications.helm.statusReason"
+ :request-status="applications.helm.requestStatus"
+ :request-reason="applications.helm.requestReason"
+ >
+ <div slot="description">
+ {{ s__(`ClusterIntegration|Helm streamlines installing
+ and managing Kubernetes applications.
+ Tiller runs inside of your Kubernetes Cluster,
+ and manages releases of your charts.`) }}
+ </div>
+ </application-row>
+ <application-row
+ :id="ingressId"
+ :title="applications.ingress.title"
+ title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
+ :status="applications.ingress.status"
+ :status-reason="applications.ingress.statusReason"
+ :request-status="applications.ingress.requestStatus"
+ :request-reason="applications.ingress.requestReason"
+ >
+ <div slot="description">
+ <p>
+ {{ s__(`ClusterIntegration|Ingress gives you a way to route
+ requests to services based on the request host or path,
+ centralizing a number of services into a single entrypoint.`) }}
+ </p>
+
+ <template v-if="ingressInstalled">
+ <div class="form-group">
+ <label for="ingress-ip-address">
+ {{ s__('ClusterIntegration|Ingress IP Address') }}
+ </label>
+ <div
+ v-if="ingressExternalIp"
+ class="input-group"
+ >
+ <input
+ type="text"
+ id="ingress-ip-address"
+ class="form-control js-ip-address"
+ :value="ingressExternalIp"
+ readonly
+ />
+ <span class="input-group-btn">
+ <clipboard-button
+ :text="ingressExternalIp"
+ :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')"
+ class="js-clipboard-btn"
+ />
+ </span>
+ </div>
+ <input
+ v-else
+ type="text"
+ class="form-control js-ip-address"
+ readonly
+ value="?"
+ />
+ </div>
+
+ <p
+ v-if="!ingressExternalIp"
+ class="settings-message js-no-ip-message"
+ >
+ {{ s__(`ClusterIntegration|The IP address is in
+ the process of being assigned. Please check your Kubernetes
+ cluster or Quotas on GKE if it takes a long time.`) }}
+
+ <a
+ :href="ingressHelpPath"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('More information') }}
+ </a>
+ </p>
+
+ <p>
+ {{ s__(`ClusterIntegration|Point a wildcard DNS to this
+ generated IP address in order to access
+ your application after it has been deployed.`) }}
+ <a
+ :href="ingressDnsHelpPath"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('More information') }}
+ </a>
+ </p>
+
+ </template>
+ <div
+ v-else
+ v-html="ingressDescription"
+ >
+ </div>
+ </div>
+ </application-row>
+ <application-row
+ id="prometheus"
+ :title="applications.prometheus.title"
+ title-link="https://prometheus.io/docs/introduction/overview/"
+ :manage-link="managePrometheusPath"
+ :status="applications.prometheus.status"
+ :status-reason="applications.prometheus.statusReason"
+ :request-status="applications.prometheus.requestStatus"
+ :request-reason="applications.prometheus.requestReason"
+ >
+ <div
+ slot="description"
+ v-html="prometheusDescription"
+ >
+ </div>
+ </application-row>
+ <application-row
+ id="runner"
+ :title="applications.runner.title"
+ title-link="https://docs.gitlab.com/runner/"
+ :status="applications.runner.status"
+ :status-reason="applications.runner.statusReason"
+ :request-status="applications.runner.requestStatus"
+ :request-reason="applications.runner.requestReason"
+ >
+ <div slot="description">
+ {{ s__(`ClusterIntegration|GitLab Runner connects to this
+ project's repository and executes CI/CD jobs,
+ pushing results back and deploying,
+ applications to production.`) }}
+ </div>
+ </application-row>
+ <!--
+ NOTE: Don't forget to update `clusters.scss`
+ min-height for this block and uncomment `application_spec` tests
+ -->
+ <!-- Add GitLab Runner row, all other plumbing is complete -->
+ </div>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
new file mode 100644
index 00000000000..b7179f52bb3
--- /dev/null
+++ b/app/assets/javascripts/clusters/constants.js
@@ -0,0 +1,13 @@
+// These need to match what is returned from the server
+export const APPLICATION_NOT_INSTALLABLE = 'not_installable';
+export const APPLICATION_INSTALLABLE = 'installable';
+export const APPLICATION_SCHEDULED = 'scheduled';
+export const APPLICATION_INSTALLING = 'installing';
+export const APPLICATION_INSTALLED = 'installed';
+export const APPLICATION_ERROR = 'errored';
+
+// These are only used client-side
+export const REQUEST_LOADING = 'request-loading';
+export const REQUEST_SUCCESS = 'request-success';
+export const REQUEST_FAILURE = 'request-failure';
+export const INGRESS = 'ingress';
diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/clusters/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
new file mode 100644
index 00000000000..13468578f4f
--- /dev/null
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -0,0 +1,25 @@
+import axios from '../../lib/utils/axios_utils';
+
+export default class ClusterService {
+ constructor(options = {}) {
+ this.options = options;
+ this.appInstallEndpointMap = {
+ helm: this.options.installHelmEndpoint,
+ ingress: this.options.installIngressEndpoint,
+ runner: this.options.installRunnerEndpoint,
+ prometheus: this.options.installPrometheusEndpoint,
+ };
+ }
+
+ fetchData() {
+ return axios.get(this.options.endpoint);
+ }
+
+ installApplication(appId) {
+ return axios.post(this.appInstallEndpointMap[appId]);
+ }
+
+ static updateCluster(endpoint, data) {
+ return axios.put(endpoint, data);
+ }
+}
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
new file mode 100644
index 00000000000..348bbec3b25
--- /dev/null
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -0,0 +1,89 @@
+import { s__ } from '../../locale';
+import { INGRESS } from '../constants';
+
+export default class ClusterStore {
+ constructor() {
+ this.state = {
+ helpPath: null,
+ ingressHelpPath: null,
+ status: null,
+ statusReason: null,
+ applications: {
+ helm: {
+ title: s__('ClusterIntegration|Helm Tiller'),
+ status: null,
+ statusReason: null,
+ requestStatus: null,
+ requestReason: null,
+ },
+ ingress: {
+ title: s__('ClusterIntegration|Ingress'),
+ status: null,
+ statusReason: null,
+ requestStatus: null,
+ requestReason: null,
+ externalIp: null,
+ },
+ runner: {
+ title: s__('ClusterIntegration|GitLab Runner'),
+ status: null,
+ statusReason: null,
+ requestStatus: null,
+ requestReason: null,
+ },
+ prometheus: {
+ title: s__('ClusterIntegration|Prometheus'),
+ status: null,
+ statusReason: null,
+ requestStatus: null,
+ requestReason: null,
+ },
+ },
+ };
+ }
+
+ setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath) {
+ this.state.helpPath = helpPath;
+ this.state.ingressHelpPath = ingressHelpPath;
+ this.state.ingressDnsHelpPath = ingressDnsHelpPath;
+ }
+
+ setManagePrometheusPath(managePrometheusPath) {
+ this.state.managePrometheusPath = managePrometheusPath;
+ }
+
+ updateStatus(status) {
+ this.state.status = status;
+ }
+
+ updateStatusReason(reason) {
+ this.state.statusReason = reason;
+ }
+
+ updateAppProperty(appId, prop, value) {
+ this.state.applications[appId][prop] = value;
+ }
+
+ updateStateFromServer(serverState = {}) {
+ this.state.status = serverState.status;
+ this.state.statusReason = serverState.status_reason;
+
+ serverState.applications.forEach((serverAppEntry) => {
+ const {
+ name: appId,
+ status,
+ status_reason: statusReason,
+ } = serverAppEntry;
+
+ this.state.applications[appId] = {
+ ...(this.state.applications[appId] || {}),
+ status,
+ statusReason,
+ };
+
+ if (appId === INGRESS) {
+ this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
index df0ba86198c..c74184949df 100644
--- a/app/assets/javascripts/comment_type_toggle.js
+++ b/app/assets/javascripts/comment_type_toggle.js
@@ -1,5 +1,8 @@
import DropLab from './droplab/drop_lab';
-import InputSetter from './droplab/plugins/input_setter';
+import ISetter from './droplab/plugins/input_setter';
+
+// Todo: Remove this when fixing issue in input_setter plugin
+const InputSetter = Object.assign({}, ISetter);
class CommentTypeToggle {
constructor(opts = {}) {
diff --git a/app/assets/javascripts/commit.js b/app/assets/javascripts/commit.js
deleted file mode 100644
index 5f637524e30..00000000000
--- a/app/assets/javascripts/commit.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife */
-/* global CommitFile */
-
-window.Commit = (function() {
- function Commit() {
- $('.files .diff-file').each(function() {
- return new CommitFile(this);
- });
- }
-
- return Commit;
-})();
diff --git a/app/assets/javascripts/commit/file.js b/app/assets/javascripts/commit/file.js
deleted file mode 100644
index ee087c978dd..00000000000
--- a/app/assets/javascripts/commit/file.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new */
-/* global ImageFile */
-
-(function() {
- this.CommitFile = (function() {
- function CommitFile(file) {
- if ($('.image', file).length) {
- new gl.ImageFile(file);
- }
- }
-
- return CommitFile;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 17d14dc1e79..7f3d04655a7 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,211 +1,210 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-use-before-define, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, quotes, one-var, one-var-declaration-per-line, no-unused-vars, no-return-assign, comma-dangle, quote-props, no-unused-expressions, no-sequences, object-shorthand, max-len */
-(function() {
- gl.ImageFile = (function() {
- var prepareFrames;
-
- // Width where images must fits in, for 2-up this gets divided by 2
- ImageFile.availWidth = 900;
-
- ImageFile.viewModes = ['two-up', 'swipe'];
-
- function ImageFile(file) {
- this.file = file;
- this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), (function(_this) {
- // Determine if old and new file has same dimensions, if not show 'two-up' view
- return function(deletedWidth, deletedHeight) {
- return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function(width, height) {
- if (width === deletedWidth && height === deletedHeight) {
- return _this.initViewModes();
- } else {
- return _this.initView('two-up');
+
+import $ from 'jquery';
+
+// Width where images must fits in, for 2-up this gets divided by 2
+const availWidth = 900;
+const viewModes = ['two-up', 'swipe'];
+
+export default class ImageFile {
+ constructor(file) {
+ this.file = file;
+ this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), (function(_this) {
+ return function(deletedWidth, deletedHeight) {
+ return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function(width, height) {
+ _this.initViewModes();
+
+ // Load two-up view after images are loaded
+ // so that we can display the correct width and height information
+ const $images = $('.two-up.view img', _this.file);
+
+ $images.waitForImages(function() {
+ _this.initView('two-up');
+ });
+ });
+ };
+ })(this));
+ }
+
+ initViewModes() {
+ const viewMode = viewModes[0];
+ $('.view-modes', this.file).removeClass('hide');
+ $('.view-modes-menu', this.file).on('click', 'li', (function(_this) {
+ return function(event) {
+ if (!$(event.currentTarget).hasClass('active')) {
+ return _this.activateViewMode(event.currentTarget.className);
+ }
+ };
+ })(this));
+ return this.activateViewMode(viewMode);
+ }
+
+ activateViewMode(viewMode) {
+ $('.view-modes-menu li', this.file).removeClass('active').filter("." + viewMode).addClass('active');
+ return $(".view:visible:not(." + viewMode + ")", this.file).fadeOut(200, (function(_this) {
+ return function() {
+ $(".view." + viewMode, _this.file).fadeIn(200);
+ return _this.initView(viewMode);
+ };
+ })(this));
+ }
+
+ initView(viewMode) {
+ return this.views[viewMode].call(this);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ initDraggable($el, padding, callback) {
+ var dragging = false;
+ var $body = $('body');
+ var $offsetEl = $el.parent();
+
+ $el.off('mousedown').on('mousedown', function() {
+ dragging = true;
+ $body.css('user-select', 'none');
+ });
+
+ $body.off('mouseup').off('mousemove').on('mouseup', function() {
+ dragging = false;
+ $body.css('user-select', '');
+ })
+ .on('mousemove', function(e) {
+ var left;
+ if (!dragging) return;
+
+ left = e.pageX - ($offsetEl.offset().left + padding);
+
+ callback(e, left);
+ });
+ }
+
+ prepareFrames(view) {
+ var maxHeight, maxWidth;
+ maxWidth = 0;
+ maxHeight = 0;
+ $('.frame', view).each((function(_this) {
+ return function(index, frame) {
+ var height, width;
+ width = $(frame).width();
+ height = $(frame).height();
+ maxWidth = width > maxWidth ? width : maxWidth;
+ return maxHeight = height > maxHeight ? height : maxHeight;
+ };
+ })(this)).css({
+ width: maxWidth,
+ height: maxHeight
+ });
+ return [maxWidth, maxHeight];
+ }
+ // eslint-disable-next-line
+ views = {
+ 'two-up': function() {
+ return $('.two-up.view .wrap', this.file).each((function(_this) {
+ return function(index, wrap) {
+ $('img', wrap).each(function() {
+ var currentWidth;
+ currentWidth = $(this).width();
+ if (currentWidth > availWidth / 2) {
+ return $(this).width(availWidth / 2);
}
});
+ return _this.requestImageInfo($('img', wrap), function(width, height) {
+ $('.image-info .meta-width', wrap).text(width + "px");
+ $('.image-info .meta-height', wrap).text(height + "px");
+ return $('.image-info', wrap).removeClass('hide');
+ });
};
})(this));
- }
+ },
+ 'swipe': function() {
+ var maxHeight, maxWidth;
+ maxWidth = 0;
+ maxHeight = 0;
+ return $('.swipe.view', this.file).each((function(_this) {
+ return function(index, view) {
+ var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref;
+ ref = _this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
+ $swipeFrame = $('.swipe-frame', view);
+ $swipeWrap = $('.swipe-wrap', view);
+ $swipeBar = $('.swipe-bar', view);
+
+ $swipeFrame.css({
+ width: maxWidth + 16,
+ height: maxHeight + 28
+ });
+ $swipeWrap.css({
+ width: maxWidth + 1,
+ height: maxHeight + 2
+ });
+ // Set swipeBar left position to match image frame
+ $swipeBar.css({
+ left: 1
+ });
- ImageFile.prototype.initViewModes = function() {
- var viewMode;
- viewMode = ImageFile.viewModes[0];
- $('.view-modes', this.file).removeClass('hide');
- $('.view-modes-menu', this.file).on('click', 'li', (function(_this) {
- return function(event) {
- if (!$(event.currentTarget).hasClass('active')) {
- return _this.activateViewMode(event.currentTarget.className);
- }
- };
- })(this));
- return this.activateViewMode(viewMode);
- };
-
- ImageFile.prototype.activateViewMode = function(viewMode) {
- $('.view-modes-menu li', this.file).removeClass('active').filter("." + viewMode).addClass('active');
- return $(".view:visible:not(." + viewMode + ")", this.file).fadeOut(200, (function(_this) {
- return function() {
- $(".view." + viewMode, _this.file).fadeIn(200);
- return _this.initView(viewMode);
+ wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
+
+ _this.initDraggable($swipeBar, wrapPadding, function(e, left) {
+ if (left > 0 && left < $swipeFrame.width() - (wrapPadding * 2)) {
+ $swipeWrap.width((maxWidth + 1) - left);
+ $swipeBar.css('left', left);
+ }
+ });
};
})(this));
- };
-
- ImageFile.prototype.initView = function(viewMode) {
- return this.views[viewMode].call(this);
- };
-
- ImageFile.prototype.initDraggable = function($el, padding, callback) {
- var dragging = false;
- var $body = $('body');
- var $offsetEl = $el.parent();
-
- $el.off('mousedown').on('mousedown', function() {
- dragging = true;
- $body.css('user-select', 'none');
- });
-
- $body.off('mouseup').off('mousemove').on('mouseup', function() {
- dragging = false;
- $body.css('user-select', '');
- })
- .on('mousemove', function(e) {
- var left;
- if (!dragging) return;
+ },
+ 'onion-skin': function() {
+ var dragTrackWidth, maxHeight, maxWidth;
+ maxWidth = 0;
+ maxHeight = 0;
+ dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width();
+ return $('.onion-skin.view', this.file).each((function(_this) {
+ return function(index, view) {
+ var $frame, $track, $dragger, $frameAdded, framePadding, ref, dragging = false;
+ ref = _this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
+ $frame = $('.onion-skin-frame', view);
+ $frameAdded = $('.frame.added', view);
+ $track = $('.drag-track', view);
+ $dragger = $('.dragger', $track);
+
+ $frame.css({
+ width: maxWidth + 16,
+ height: maxHeight + 28
+ });
+ $('.swipe-wrap', view).css({
+ width: maxWidth + 1,
+ height: maxHeight + 2
+ });
+ $dragger.css({
+ left: dragTrackWidth
+ });
- left = e.pageX - ($offsetEl.offset().left + padding);
+ $frameAdded.css('opacity', 1);
+ framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
- callback(e, left);
- });
- };
+ _this.initDraggable($dragger, framePadding, function(e, left) {
+ var opacity = left / dragTrackWidth;
- prepareFrames = function(view) {
- var maxHeight, maxWidth;
- maxWidth = 0;
- maxHeight = 0;
- $('.frame', view).each((function(_this) {
- return function(index, frame) {
- var height, width;
- width = $(frame).width();
- height = $(frame).height();
- maxWidth = width > maxWidth ? width : maxWidth;
- return maxHeight = height > maxHeight ? height : maxHeight;
+ if (opacity >= 0 && opacity <= 1) {
+ $dragger.css('left', left);
+ $frameAdded.css('opacity', opacity);
+ }
+ });
};
- })(this)).css({
- width: maxWidth,
- height: maxHeight
- });
- return [maxWidth, maxHeight];
- };
-
- ImageFile.prototype.views = {
- 'two-up': function() {
- return $('.two-up.view .wrap', this.file).each((function(_this) {
- return function(index, wrap) {
- $('img', wrap).each(function() {
- var currentWidth;
- currentWidth = $(this).width();
- if (currentWidth > ImageFile.availWidth / 2) {
- return $(this).width(ImageFile.availWidth / 2);
- }
- });
- return _this.requestImageInfo($('img', wrap), function(width, height) {
- $('.image-info .meta-width', wrap).text(width + "px");
- $('.image-info .meta-height', wrap).text(height + "px");
- return $('.image-info', wrap).removeClass('hide');
- });
- };
- })(this));
- },
- 'swipe': function() {
- var maxHeight, maxWidth;
- maxWidth = 0;
- maxHeight = 0;
- return $('.swipe.view', this.file).each((function(_this) {
- return function(index, view) {
- var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref;
- ref = prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
- $swipeFrame = $('.swipe-frame', view);
- $swipeWrap = $('.swipe-wrap', view);
- $swipeBar = $('.swipe-bar', view);
-
- $swipeFrame.css({
- width: maxWidth + 16,
- height: maxHeight + 28
- });
- $swipeWrap.css({
- width: maxWidth + 1,
- height: maxHeight + 2
- });
- $swipeBar.css({
- left: 0
- });
-
- wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
-
- _this.initDraggable($swipeBar, wrapPadding, function(e, left) {
- if (left > 0 && left < $swipeFrame.width() - (wrapPadding * 2)) {
- $swipeWrap.width((maxWidth + 1) - left);
- $swipeBar.css('left', left);
- }
- });
- };
- })(this));
- },
- 'onion-skin': function() {
- var dragTrackWidth, maxHeight, maxWidth;
- maxWidth = 0;
- maxHeight = 0;
- dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width();
- return $('.onion-skin.view', this.file).each((function(_this) {
- return function(index, view) {
- var $frame, $track, $dragger, $frameAdded, framePadding, ref, dragging = false;
- ref = prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
- $frame = $('.onion-skin-frame', view);
- $frameAdded = $('.frame.added', view);
- $track = $('.drag-track', view);
- $dragger = $('.dragger', $track);
-
- $frame.css({
- width: maxWidth + 16,
- height: maxHeight + 28
- });
- $('.swipe-wrap', view).css({
- width: maxWidth + 1,
- height: maxHeight + 2
- });
- $dragger.css({
- left: dragTrackWidth
- });
-
- framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
-
- _this.initDraggable($dragger, framePadding, function(e, left) {
- var opacity = left / dragTrackWidth;
-
- if (opacity >= 0 && opacity <= 1) {
- $dragger.css('left', left);
- $frameAdded.css('opacity', opacity);
- }
- });
+ })(this));
+ }
+ }
+
+ requestImageInfo(img, callback) {
+ const domImg = img.get(0);
+ if (domImg) {
+ if (domImg.complete) {
+ return callback.call(this, domImg.naturalWidth, domImg.naturalHeight);
+ } else {
+ return img.on('load', (function(_this) {
+ return function() {
+ return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight);
};
})(this));
}
- };
-
- ImageFile.prototype.requestImageInfo = function(img, callback) {
- var domImg;
- domImg = img.get(0);
- if (domImg) {
- if (domImg.complete) {
- return callback.call(this, domImg.naturalWidth, domImg.naturalHeight);
- } else {
- return img.on('load', (function(_this) {
- return function() {
- return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight);
- };
- })(this));
- }
- }
- };
-
- return ImageFile;
- })();
-}).call(window);
+ }
+ }
+}
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 2c38440a2af..3d89bf1316e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -15,16 +15,32 @@ const CommitPipelinesTable = Vue.extend(commitPipelinesTable);
window.gl = window.gl || {};
window.gl.CommitPipelinesTable = CommitPipelinesTable;
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
- const table = new CommitPipelinesTable({
- propsData: {
- endpoint: pipelineTableViewEl.dataset.endpoint,
- helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
- },
- }).$mount();
- pipelineTableViewEl.appendChild(table.$el);
+ if (pipelineTableViewEl) {
+ // Update MR and Commits tabs
+ pipelineTableViewEl.addEventListener('update-pipelines-count', (event) => {
+ if (event.detail.pipelines &&
+ event.detail.pipelines.count &&
+ event.detail.pipelines.count.all) {
+ const badge = document.querySelector('.js-pipelines-mr-count');
+
+ badge.textContent = event.detail.pipelines.count.all;
+ }
+ });
+
+ if (pipelineTableViewEl.dataset.disableInitialization === undefined) {
+ const table = new CommitPipelinesTable({
+ propsData: {
+ endpoint: pipelineTableViewEl.dataset.endpoint,
+ helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
+ emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
+ errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
+ autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
+ },
+ }).$mount();
+ pipelineTableViewEl.appendChild(table.$el);
+ }
}
-});
+};
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 3c77f14d533..466a5b5d635 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -4,6 +4,9 @@
import pipelinesMixin from '../../pipelines/mixins/pipelines';
export default {
+ mixins: [
+ pipelinesMixin,
+ ],
props: {
endpoint: {
type: String,
@@ -13,10 +16,20 @@
type: String,
required: true,
},
+ autoDevopsHelpPath: {
+ type: String,
+ required: true,
+ },
+ errorStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: false,
+ default: 'child',
+ },
},
- mixins: [
- pipelinesMixin,
- ],
data() {
const store = new PipelineStore();
@@ -28,34 +41,36 @@
},
computed: {
- /**
- * 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;
- },
-
shouldRenderTable() {
return !this.isLoading &&
this.state.pipelines.length > 0 &&
!this.hasError;
},
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
},
methods: {
successCallback(resp) {
- const response = resp.json();
+ return resp.json().then((response) => {
+ // depending of the endpoint the response can either bring a `pipelines` key or not.
+ const pipelines = response.pipelines || response;
+ this.setCommonData(pipelines);
- // depending of the endpoint the response can either bring a `pipelines` key or not.
- const pipelines = response.pipelines || response;
- this.setCommonData(pipelines);
+ const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
+ detail: {
+ pipelines: response,
+ },
+ });
+
+ // notifiy to update the count in tabs
+ if (this.$el.parentElement) {
+ this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
+ }
+ });
},
},
};
@@ -64,27 +79,29 @@
<div class="content-list pipelines">
<loading-icon
- label="Loading pipelines"
+ :label="s__('Pipelines|Loading Pipelines')"
size="3"
v-if="isLoading"
- />
-
- <empty-state
- v-if="shouldRenderEmptyState"
- :help-page-path="helpPagePath"
- />
+ class="prepend-top-20"
+ />
- <error-state
- v-if="shouldRenderErrorState"
- />
+ <svg-blank-state
+ v-else-if="shouldRenderErrorState"
+ :svg-path="errorStateSvgPath"
+ :message="s__(`Pipelines|There was an error fetching the pipelines.
+ Try again in a few moments or contact your support team.`)"
+ />
<div
class="table-holder"
- v-if="shouldRenderTable">
+ v-else-if="shouldRenderTable"
+ >
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
- />
+ :auto-devops-help-path="autoDevopsHelpPath"
+ :view-type="viewType"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js
new file mode 100644
index 00000000000..102b4ee8463
--- /dev/null
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -0,0 +1,74 @@
+/* global Flash */
+
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import { n__, s__ } from './locale';
+
+export function getHeaderText(childElementCount, mergeRequestCount) {
+ if (childElementCount === 0) {
+ return `${mergeRequestCount} ${n__('merge request', 'merge requests', mergeRequestCount)}`;
+ }
+ return ',';
+}
+
+export function createHeader(childElementCount, mergeRequestCount) {
+ const headerText = getHeaderText(childElementCount, mergeRequestCount);
+
+ return $('<span />', {
+ class: 'append-right-5',
+ text: headerText,
+ });
+}
+
+export function createLink(mergeRequest) {
+ return $('<a />', {
+ class: 'append-right-5',
+ href: mergeRequest.path,
+ text: `!${mergeRequest.iid}`,
+ });
+}
+
+export function createTitle(mergeRequest) {
+ return $('<span />', {
+ text: mergeRequest.title,
+ });
+}
+
+export function createItem(mergeRequest) {
+ const $item = $('<span />');
+ const $link = createLink(mergeRequest);
+ const $title = createTitle(mergeRequest);
+ $item.append($link);
+ $item.append($title);
+
+ return $item;
+}
+
+export function createContent(mergeRequests) {
+ const $content = $('<span />');
+
+ if (mergeRequests.length === 0) {
+ $content.text(s__('Commits|No related merge requests found'));
+ } else {
+ mergeRequests.forEach((mergeRequest) => {
+ const $header = createHeader($content.children().length, mergeRequests.length);
+ const $item = createItem(mergeRequest);
+ $content.append($header);
+ $content.append($item);
+ });
+ }
+
+ return $content;
+}
+
+export function fetchCommitMergeRequests() {
+ const $container = $('.merge-requests');
+
+ axios.get($container.data('projectCommitPath'))
+ .then((response) => {
+ const $content = createContent(response.data);
+
+ $container.html($content);
+ })
+ .catch(() => Flash(s__('Commits|An error occurred while fetching merge requests data.')));
+}
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index 2b0bf49cf92..7e2a3573f81 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -1,74 +1,65 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, consistent-return, no-return-assign, no-param-reassign, one-var, no-var, one-var-declaration-per-line, no-unused-vars, prefer-template, object-shorthand, comma-dangle, max-len, prefer-arrow-callback */
-/* global Pager */
+import $ from 'jquery';
+import { pluralize } from './lib/utils/text_utility';
+import { localTimeAgo } from './lib/utils/datetime_utility';
+import Pager from './pager';
+import axios from './lib/utils/axios_utils';
-window.CommitsList = (function() {
- var CommitsList = {};
-
- CommitsList.timer = null;
+export default class CommitsList {
+ constructor(limit = 0) {
+ this.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");
- e.stopPropagation();
- return false;
- }
- });
+ Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this));
- Pager.init(limit, false, false, this.processCommits);
-
- this.content = $("#commits-list");
- this.searchField = $("#commits-search");
+ this.content = $('#commits-list');
+ this.searchField = $('#commits-search');
this.lastSearch = this.searchField.val();
- return this.initSearch();
- };
+ this.initSearch();
+ }
- CommitsList.initSearch = function() {
+ initSearch() {
this.timer = null;
- return this.searchField.keyup((function(_this) {
- return function() {
- clearTimeout(_this.timer);
- return _this.timer = setTimeout(_this.filterResults, 500);
- };
- })(this));
- };
+ this.searchField.on('keyup', () => {
+ clearTimeout(this.timer);
+ this.timer = setTimeout(this.filterResults.bind(this), 500);
+ });
+ }
+
+ filterResults() {
+ const form = $('.commits-search-form');
+ const search = this.searchField.val();
+ if (search === this.lastSearch) return Promise.resolve();
+ const commitsUrl = `${form.attr('action')}?${form.serialize()}`;
+ this.content.fadeTo('fast', 0.5);
+ const params = form.serializeArray().reduce((acc, obj) => Object.assign(acc, {
+ [obj.name]: obj.value,
+ }), {});
+
+ return axios.get(form.attr('action'), {
+ params,
+ })
+ .then(({ data }) => {
+ this.lastSearch = search;
+ this.content.html(data.html);
+ this.content.fadeTo('fast', 1.0);
- CommitsList.filterResults = function() {
- var commitsUrl, form, search;
- form = $(".commits-search-form");
- search = CommitsList.searchField.val();
- if (search === CommitsList.lastSearch) return;
- commitsUrl = form.attr("action") + '?' + form.serialize();
- CommitsList.content.fadeTo('fast', 0.5);
- return $.ajax({
- type: "GET",
- url: form.attr("action"),
- data: form.serialize(),
- complete: function() {
- return CommitsList.content.fadeTo('fast', 1.0);
- },
- success: function(data) {
- CommitsList.lastSearch = search;
- CommitsList.content.html(data.html);
- return history.replaceState({
- page: commitsUrl
// Change url so if user reload a page - search results are saved
+ history.replaceState({
+ page: commitsUrl,
}, document.title, commitsUrl);
- },
- error: function() {
- CommitsList.lastSearch = null;
- },
- dataType: "json"
- });
- };
+ })
+ .catch(() => {
+ this.content.fadeTo('fast', 1.0);
+ this.lastSearch = null;
+ });
+ }
// Prepare loaded data.
- CommitsList.processCommits = (data) => {
+ processCommits(data) {
let processedData = data;
const $processedData = $(processedData);
- const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last();
+ const $commitsHeadersLast = this.$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');
@@ -81,17 +72,15 @@ window.CommitsList = (function() {
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}"]`);
+ 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)}`);
+ $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${pluralize('commit', commitsCount)}`);
}
- gl.utils.localTimeAgo($processedData.find('.js-timeago'));
+ localTimeAgo($processedData.find('.js-timeago'));
return processedData;
- };
-
- return CommitsList;
-})();
+ }
+}
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index 36bfe457be9..db96da4ccba 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -3,14 +3,16 @@ import $ from 'jquery';
// bootstrap jQuery plugins
import 'bootstrap-sass/assets/javascripts/bootstrap/affix';
import 'bootstrap-sass/assets/javascripts/bootstrap/alert';
+import 'bootstrap-sass/assets/javascripts/bootstrap/button';
import 'bootstrap-sass/assets/javascripts/bootstrap/dropdown';
import 'bootstrap-sass/assets/javascripts/bootstrap/modal';
import 'bootstrap-sass/assets/javascripts/bootstrap/tab';
import 'bootstrap-sass/assets/javascripts/bootstrap/transition';
import 'bootstrap-sass/assets/javascripts/bootstrap/tooltip';
+import 'bootstrap-sass/assets/javascripts/bootstrap/popover';
// custom jQuery functions
$.fn.extend({
- disable() { return $(this).attr('disabled', 'disabled').addClass('disabled'); },
- enable() { return $(this).removeAttr('disabled').removeClass('disabled'); },
+ disable() { return $(this).prop('disabled', true).addClass('disabled'); },
+ enable() { return $(this).prop('disabled', false).removeClass('disabled'); },
});
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index 7063f59d446..0d2fe2925d8 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,3 +1,6 @@
+import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
+import './vue';
+import '../lib/utils/axios_utils';
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index b53f6284afc..a7ed175f7a4 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -6,6 +6,5 @@ import 'vendor/jquery.endless-scroll';
import 'vendor/jquery.caret';
import 'vendor/jquery.atwho';
import 'vendor/jquery.scrollTo';
-import 'vendor/jquery.nicescroll';
-import 'vendor/jquery.waitforimages';
+import 'jquery.waitforimages';
import 'select2/select2';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index bc3e741f524..d62d3c23654 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,4 +1,5 @@
// ECMAScript polyfills
+import 'core-js/fn/array/fill';
import 'core-js/fn/array/find';
import 'core-js/fn/array/find-index';
import 'core-js/fn/array/from';
@@ -8,7 +9,12 @@ import 'core-js/fn/promise';
import 'core-js/fn/string/code-point-at';
import 'core-js/fn/string/from-code-point';
import 'core-js/fn/symbol';
+import 'core-js/es6/map';
+import 'core-js/es6/weak-map';
// Browser polyfills
+import 'classlist-polyfill';
import './polyfills/custom_event';
import './polyfills/element';
+import './polyfills/event';
+import './polyfills/nodelist';
diff --git a/app/assets/javascripts/commons/polyfills/custom_event.js b/app/assets/javascripts/commons/polyfills/custom_event.js
index aea61b82d03..db51ade61ae 100644
--- a/app/assets/javascripts/commons/polyfills/custom_event.js
+++ b/app/assets/javascripts/commons/polyfills/custom_event.js
@@ -1,7 +1,12 @@
if (typeof window.CustomEvent !== 'function') {
window.CustomEvent = function CustomEvent(event, params) {
const evt = document.createEvent('CustomEvent');
- const evtParams = params || { bubbles: false, cancelable: false, detail: undefined };
+ const evtParams = {
+ bubbles: false,
+ cancelable: false,
+ detail: undefined,
+ ...params,
+ };
evt.initCustomEvent(event, evtParams.bubbles, evtParams.cancelable, evtParams.detail);
return evt;
};
diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js
index 9a1f73bf2ac..b593bde6aa2 100644
--- a/app/assets/javascripts/commons/polyfills/element.js
+++ b/app/assets/javascripts/commons/polyfills/element.js
@@ -18,3 +18,22 @@ Element.prototype.matches = Element.prototype.matches ||
while (i >= 0 && elms.item(i) !== this) { i -= 1; }
return i > -1;
};
+
+// From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill
+((arr) => {
+ arr.forEach((item) => {
+ if (Object.prototype.hasOwnProperty.call(item, 'remove')) {
+ return;
+ }
+ Object.defineProperty(item, 'remove', {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: function remove() {
+ if (this.parentNode !== null) {
+ this.parentNode.removeChild(this);
+ }
+ },
+ });
+ });
+})([Element.prototype, CharacterData.prototype, DocumentType.prototype]);
diff --git a/app/assets/javascripts/commons/polyfills/event.js b/app/assets/javascripts/commons/polyfills/event.js
new file mode 100644
index 00000000000..ff5b9a1982f
--- /dev/null
+++ b/app/assets/javascripts/commons/polyfills/event.js
@@ -0,0 +1,18 @@
+/**
+ * Polyfill for IE11 support.
+ * new Event() is not supported by IE11.
+ * Although `initEvent` is deprecated for modern browsers it is the one supported by IE
+ */
+if (typeof window.Event !== 'function') {
+ window.Event = function Event(event, params) {
+ const evt = document.createEvent('Event');
+ const evtParams = {
+ bubbles: false,
+ cancelable: false,
+ ...params,
+ };
+ evt.initEvent(event, evtParams.bubbles, evtParams.cancelable);
+ return evt;
+ };
+ window.Event.prototype = Event;
+}
diff --git a/app/assets/javascripts/commons/polyfills/nodelist.js b/app/assets/javascripts/commons/polyfills/nodelist.js
new file mode 100644
index 00000000000..3772c94b900
--- /dev/null
+++ b/app/assets/javascripts/commons/polyfills/nodelist.js
@@ -0,0 +1,7 @@
+if (window.NodeList && !NodeList.prototype.forEach) {
+ NodeList.prototype.forEach = function forEach(callback, thisArg = window) {
+ for (let i = 0; i < this.length; i += 1) {
+ callback.call(thisArg, this[i], i, this);
+ }
+ };
+}
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
new file mode 100644
index 00000000000..798623b94fb
--- /dev/null
+++ b/app/assets/javascripts/commons/vue.js
@@ -0,0 +1,6 @@
+import Vue from 'vue';
+import '../vue_shared/vue_resource_interceptor';
+
+if (process.env.NODE_ENV !== 'production') {
+ Vue.config.productionTip = false;
+}
diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js
index 9e5dbd64a7e..303a5bf4a53 100644
--- a/app/assets/javascripts/compare.js
+++ b/app/assets/javascripts/compare.js
@@ -1,7 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */
-window.Compare = (function() {
- function Compare(opts) {
+import $ from 'jquery';
+import { localTimeAgo } from './lib/utils/datetime_utility';
+import axios from './lib/utils/axios_utils';
+
+export default class Compare {
+ constructor(opts) {
this.opts = opts;
this.source_loading = $(".js-source-loading");
this.target_loading = $(".js-target-loading");
@@ -11,7 +15,7 @@ window.Compare = (function() {
$dropdown = $(dropdown);
return $dropdown.glDropdown({
selectable: true,
- fieldName: $dropdown.data('field-name'),
+ fieldName: $dropdown.data('fieldName'),
filterable: true,
id: function(obj, $el) {
return $el.data('id');
@@ -34,57 +38,49 @@ window.Compare = (function() {
this.initialState();
}
- Compare.prototype.initialState = function() {
+ initialState() {
this.getSourceHtml();
- return this.getTargetHtml();
- };
+ this.getTargetHtml();
+ }
- Compare.prototype.getTargetProject = function() {
- return $.ajax({
- url: this.opts.targetProjectUrl,
- data: {
- target_project_id: $("input[name='merge_request[target_project_id]']").val()
- },
- beforeSend: function() {
- return $('.mr_target_commit').empty();
+ getTargetProject() {
+ $('.mr_target_commit').empty();
+
+ return axios.get(this.opts.targetProjectUrl, {
+ params: {
+ target_project_id: $("input[name='merge_request[target_project_id]']").val(),
},
- success: function(html) {
- return $('.js-target-branch-dropdown .dropdown-content').html(html);
- }
+ }).then(({ data }) => {
+ $('.js-target-branch-dropdown .dropdown-content').html(data);
});
- };
+ }
- Compare.prototype.getSourceHtml = function() {
- return this.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', {
+ getSourceHtml() {
+ return this.constructor.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', {
ref: $("input[name='merge_request[source_branch]']").val()
});
- };
+ }
- Compare.prototype.getTargetHtml = function() {
- return this.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', {
+ getTargetHtml() {
+ return this.constructor.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', {
target_project_id: $("input[name='merge_request[target_project_id]']").val(),
ref: $("input[name='merge_request[target_branch]']").val()
});
- };
+ }
- Compare.prototype.sendAjax = function(url, loading, target, data) {
- var $target;
- $target = $(target);
- return $.ajax({
- url: url,
- data: data,
- beforeSend: function() {
- loading.show();
- return $target.empty();
- },
- success: function(html) {
- loading.hide();
- $target.html(html);
- var className = '.' + $target[0].className.replace(' ', '.');
- gl.utils.localTimeAgo($('.js-timeago', className));
- }
- });
- };
+ static sendAjax(url, loading, target, params) {
+ const $target = $(target);
- return Compare;
-})();
+ loading.show();
+ $target.empty();
+
+ return axios.get(url, {
+ params,
+ }).then(({ data }) => {
+ loading.hide();
+ $target.html(data);
+ const className = '.' + $target[0].className.replace(' ', '.');
+ localTimeAgo($('.js-timeago', className));
+ });
+ }
+}
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index 72c0d98d47c..260c91cac24 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -1,68 +1,64 @@
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */
-window.CompareAutocomplete = (function() {
- function CompareAutocomplete() {
- this.initDropdown();
- }
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
- CompareAutocomplete.prototype.initDropdown = function() {
- return $('.js-compare-dropdown').each(function() {
- var $dropdown, selected;
- $dropdown = $(this);
- selected = $dropdown.data('selected');
- const $dropdownContainer = $dropdown.closest('.dropdown');
- const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer);
- const $filterInput = $('input[type="search"]', $dropdownContainer);
- $dropdown.glDropdown({
- data: function(term, callback) {
- return $.ajax({
- url: $dropdown.data('refs-url'),
- data: {
- ref: $dropdown.data('ref'),
- search: term,
- }
- }).done(function(refs) {
- return callback(refs);
- });
- },
- selectable: true,
- filterable: true,
- filterRemote: true,
- fieldName: $dropdown.data('field-name'),
- filterInput: 'input[type="search"]',
- renderRow: function(ref) {
- var link;
- if (ref.header != null) {
- return $('<li />').addClass('dropdown-header').text(ref.header);
- } else {
- link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref));
- return $('<li />').append(link);
- }
- },
- id: function(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel: function(obj, $el) {
- return $el.text().trim();
+export default function initCompareAutocomplete() {
+ $('.js-compare-dropdown').each(function() {
+ var $dropdown, selected;
+ $dropdown = $(this);
+ selected = $dropdown.data('selected');
+ const $dropdownContainer = $dropdown.closest('.dropdown');
+ const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer);
+ const $filterInput = $('input[type="search"]', $dropdownContainer);
+ $dropdown.glDropdown({
+ data: function(term, callback) {
+ axios.get($dropdown.data('refsUrl'), {
+ params: {
+ ref: $dropdown.data('ref'),
+ search: term,
+ },
+ }).then(({ data }) => {
+ callback(data);
+ }).catch(() => flash(__('Error fetching refs')));
+ },
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ fieldName: $dropdown.data('fieldName'),
+ filterInput: 'input[type="search"]',
+ renderRow: function(ref) {
+ var link;
+ if (ref.header != null) {
+ return $('<li />').addClass('dropdown-header').text(ref.header);
+ } else {
+ link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref));
+ return $('<li />').append(link);
}
- });
- $filterInput.on('keyup', (e) => {
- const keyCode = e.keyCode || e.which;
- if (keyCode !== 13) return;
- const text = $filterInput.val();
- $fieldInput.val(text);
- $('.dropdown-toggle-text', $dropdown).text(text);
- $dropdownContainer.removeClass('open');
- });
-
- $dropdownContainer.on('click', '.dropdown-content a', (e) => {
- $dropdown.prop('title', e.target.text.replace(/_+?/g, '-'));
- if ($dropdown.hasClass('has-tooltip')) {
- $dropdown.tooltip('fixTitle');
- }
- });
+ },
+ id: function(obj, $el) {
+ return $el.attr('data-ref');
+ },
+ toggleLabel: function(obj, $el) {
+ return $el.text().trim();
+ }
+ });
+ $filterInput.on('keyup', (e) => {
+ const keyCode = e.keyCode || e.which;
+ if (keyCode !== 13) return;
+ const text = $filterInput.val();
+ $fieldInput.val(text);
+ $('.dropdown-toggle-text', $dropdown).text(text);
+ $dropdownContainer.removeClass('open');
});
- };
- return CompareAutocomplete;
-})();
+ $dropdownContainer.on('click', '.dropdown-content a', (e) => {
+ $dropdown.prop('title', e.target.text.replace(/_+?/g, '-'));
+ if ($dropdown.hasClass('has-tooltip')) {
+ $dropdown.tooltip('fixTitle');
+ }
+ });
+ });
+}
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index b375b61202e..1638e09132b 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -1,30 +1,32 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, camelcase, one-var-declaration-per-line, no-else-return, max-len */
+import $ from 'jquery';
+import { rstrip } from './lib/utils/common_utils';
-window.ConfirmDangerModal = (function() {
- function ConfirmDangerModal(form, text) {
- var project_path, submit;
- this.form = form;
- $('.js-confirm-text').text(text || '');
- $('.js-confirm-danger-input').val('');
- $('#modal-confirm-danger').modal('show');
- project_path = $('.js-confirm-danger-match').text();
- submit = $('.js-confirm-danger-submit');
- submit.disable();
- $('.js-confirm-danger-input').off('input');
- $('.js-confirm-danger-input').on('input', function() {
- if (gl.utils.rstrip($(this).val()) === project_path) {
- return submit.enable();
- } else {
- return submit.disable();
- }
- });
- $('.js-confirm-danger-submit').off('click');
- $('.js-confirm-danger-submit').on('click', (function(_this) {
- return function() {
- return _this.form.submit();
- };
- })(this));
- }
+function openConfirmDangerModal($form, text) {
+ $('.js-confirm-text').text(text || '');
+ $('.js-confirm-danger-input').val('');
+ $('#modal-confirm-danger').modal('show');
- return ConfirmDangerModal;
-})();
+ const confirmTextMatch = $('.js-confirm-danger-match').text();
+ const $submit = $('.js-confirm-danger-submit');
+ $submit.disable();
+
+ $('.js-confirm-danger-input').off('input').on('input', function handleInput() {
+ const confirmText = rstrip($(this).val());
+ if (confirmText === confirmTextMatch) {
+ $submit.enable();
+ } else {
+ $submit.disable();
+ }
+ });
+ $('.js-confirm-danger-submit').off('click').on('click', () => $form.submit());
+}
+
+export default function initConfirmDangerModal() {
+ $(document).on('click', '.js-confirm-danger', (e) => {
+ e.preventDefault();
+ const $btn = $(e.target);
+ const $form = $btn.closest('form');
+ const text = $btn.data('confirmDangerMessage');
+ openConfirmDangerModal($form, text);
+ });
+}
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
new file mode 100644
index 00000000000..3a50e73ad85
--- /dev/null
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -0,0 +1,82 @@
+import $ from 'jquery';
+import Cookies from 'js-cookie';
+import _ from 'underscore';
+import bp from './breakpoints';
+
+export default class ContextualSidebar {
+ constructor() {
+ this.initDomElements();
+ this.render();
+ }
+
+ initDomElements() {
+ this.$page = $('.layout-page');
+ this.$sidebar = $('.nav-sidebar');
+ this.$innerScroll = $('.nav-sidebar-inner-scroll', this.$sidebar);
+ this.$overlay = $('.mobile-overlay');
+ this.$openSidebar = $('.toggle-mobile-nav');
+ this.$closeSidebar = $('.close-nav-button');
+ this.$sidebarToggle = $('.js-toggle-sidebar');
+ }
+
+ bindEvents() {
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.nav-sidebar') && (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md')) {
+ this.toggleCollapsedSidebar(true);
+ }
+ });
+ this.$openSidebar.on('click', () => this.toggleSidebarNav(true));
+ this.$closeSidebar.on('click', () => this.toggleSidebarNav(false));
+ this.$overlay.on('click', () => this.toggleSidebarNav(false));
+ this.$sidebarToggle.on('click', () => {
+ const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop');
+ this.toggleCollapsedSidebar(value);
+ });
+
+ $(window).on('resize', () => _.debounce(this.render(), 100));
+ }
+
+ static setCollapsedCookie(value) {
+ if (bp.getBreakpointSize() !== 'lg') {
+ return;
+ }
+ Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 });
+ }
+
+ toggleSidebarNav(show) {
+ this.$sidebar.toggleClass('sidebar-expanded-mobile', show);
+ this.$overlay.toggleClass('mobile-nav-open', show);
+ this.$sidebar.removeClass('sidebar-collapsed-desktop');
+ }
+
+ toggleCollapsedSidebar(collapsed) {
+ const breakpoint = bp.getBreakpointSize();
+
+ if (this.$sidebar.length) {
+ this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed);
+ this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed);
+ }
+ ContextualSidebar.setCollapsedCookie(collapsed);
+
+ this.toggleSidebarOverflow();
+ }
+
+ toggleSidebarOverflow() {
+ if (this.$innerScroll.prop('scrollHeight') > this.$innerScroll.prop('offsetHeight')) {
+ this.$innerScroll.css('overflow-y', 'scroll');
+ } else {
+ this.$innerScroll.css('overflow-y', '');
+ }
+ }
+
+ render() {
+ const breakpoint = bp.getBreakpointSize();
+
+ if (breakpoint === 'sm' || breakpoint === 'md') {
+ this.toggleCollapsedSidebar(true);
+ } else if (breakpoint === 'lg') {
+ const collapse = Cookies.get('sidebar_collapsed') === 'true';
+ this.toggleCollapsedSidebar(collapse);
+ }
+ }
+}
diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js
deleted file mode 100644
index ba9d9a3e1f7..00000000000
--- a/app/assets/javascripts/copy_as_gfm.js
+++ /dev/null
@@ -1,458 +0,0 @@
-/* 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 */
-
-import './lib/utils/common_utils';
-
-const gfmRules = {
- // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
- // GitLab Flavored Markdown (GFM) to HTML.
- // These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
- // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
- // from GFM should have a handler here, in reverse order.
- // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
- InlineDiffFilter: {
- 'span.idiff.addition'(el, text) {
- return `{+${text}+}`;
- },
- 'span.idiff.deletion'(el, text) {
- return `{-${text}-}`;
- },
- },
- TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el) {
- return `[${el.checked ? 'x' : ' '}]`;
- },
- },
- ReferenceFilter: {
- '.tooltip'(el) {
- return '';
- },
- 'a.gfm:not([data-link=true])'(el, text) {
- return el.dataset.original || text;
- },
- },
- AutolinkFilter: {
- 'a'(el, text) {
- // Fallback on the regular MarkdownFilter's `a` handler.
- if (text !== el.getAttribute('href')) return false;
-
- return text;
- },
- },
- TableOfContentsFilter: {
- 'ul.section-nav'(el) {
- return '[[_TOC_]]';
- },
- },
- EmojiFilter: {
- 'img.emoji'(el) {
- return el.getAttribute('alt');
- },
- 'gl-emoji'(el) {
- return `:${el.getAttribute('data-name')}:`;
- },
- },
- ImageLinkFilter: {
- 'a.no-attachment-icon'(el, text) {
- return text;
- },
- },
- VideoLinkFilter: {
- '.video-container'(el) {
- const videoEl = el.querySelector('video');
- if (!videoEl) return false;
-
- return CopyAsGFM.nodeToGFM(videoEl);
- },
- 'video'(el) {
- return `![${el.dataset.title}](${el.getAttribute('src')})`;
- },
- },
- MathFilter: {
- 'pre.code.math[data-math-style=display]'(el, text) {
- return `\`\`\`math\n${text.trim()}\n\`\`\``;
- },
- 'code.code.math[data-math-style=inline]'(el, text) {
- return `$\`${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) {
- const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
- if (!mathAnnotation) return false;
-
- return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
- },
- 'span.katex-html'(el) {
- // We don't want to include the content of this element in the copied text.
- return '';
- },
- 'annotation[encoding="application/x-tex"]'(el, text) {
- return text.trim();
- },
- },
- SanitizationFilter: {
- 'a[name]:not([href]):empty'(el) {
- return el.outerHTML;
- },
- 'dl'(el, text) {
- let lines = text.trim().split('\n');
- // Add two spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- lines = lines.map((l) => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- });
-
- return `<dl>\n${lines.join('\n')}\n</dl>`;
- },
- 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) {
- const tag = el.nodeName.toLowerCase();
- return `<${tag}>${text}</${tag}>`;
- },
- },
- SyntaxHighlightFilter: {
- 'pre.code.highlight'(el, t) {
- const text = t.trimRight();
-
- let lang = el.getAttribute('lang');
- if (!lang || lang === 'plaintext') {
- lang = '';
- }
-
- // Prefixes lines with 4 spaces if the code contains triple backticks
- if (lang === '' && text.match(/^```/gm)) {
- return text.split('\n').map((l) => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- }).join('\n');
- }
-
- return `\`\`\`${lang}\n${text}\n\`\`\``;
- },
- 'pre > code'(el, text) {
- // Don't wrap code blocks in ``
- return text;
- },
- },
- MarkdownFilter: {
- 'br'(el) {
- // Two spaces at the end of a line are turned into a BR
- return ' ';
- },
- 'code'(el, text) {
- let backtickCount = 1;
- const backtickMatch = text.match(/`+/);
- if (backtickMatch) {
- backtickCount = backtickMatch[0].length + 1;
- }
-
- const backticks = Array(backtickCount + 1).join('`');
- const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
-
- return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
- },
- 'blockquote'(el, text) {
- return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
- },
- 'img'(el) {
- return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
- },
- 'a.anchor'(el, text) {
- // Don't render a Markdown link for the anchor link inside a heading
- return text;
- },
- 'a'(el, text) {
- return `[${text}](${el.getAttribute('href')})`;
- },
- 'li'(el, text) {
- const lines = text.trim().split('\n');
- const firstLine = `- ${lines.shift()}`;
- // Add four spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- const nextLines = lines.map((s) => {
- if (s.trim().length === 0) return '';
-
- return ` ${s}`;
- });
-
- return `${firstLine}\n${nextLines.join('\n')}`;
- },
- 'ul'(el, text) {
- return text;
- },
- 'ol'(el, text) {
- // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
- return text.replace(/^- /mg, '1. ');
- },
- 'h1'(el, text) {
- return `# ${text.trim()}`;
- },
- 'h2'(el, text) {
- return `## ${text.trim()}`;
- },
- 'h3'(el, text) {
- return `### ${text.trim()}`;
- },
- 'h4'(el, text) {
- return `#### ${text.trim()}`;
- },
- 'h5'(el, text) {
- return `##### ${text.trim()}`;
- },
- 'h6'(el, text) {
- return `###### ${text.trim()}`;
- },
- 'strong'(el, text) {
- return `**${text}**`;
- },
- 'em'(el, text) {
- return `_${text}_`;
- },
- 'del'(el, text) {
- return `~~${text}~~`;
- },
- 'sup'(el, text) {
- return `^${text}`;
- },
- 'hr'(el) {
- return '-----';
- },
- 'table'(el) {
- const theadEl = el.querySelector('thead');
- const tbodyEl = el.querySelector('tbody');
- if (!theadEl || !tbodyEl) return false;
-
- const theadText = CopyAsGFM.nodeToGFM(theadEl);
- const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
-
- return [theadText, tbodyText].join('\n');
- },
- 'thead'(el, text) {
- const cells = _.map(el.querySelectorAll('th'), (cell) => {
- let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
-
- let before = '';
- let after = '';
- switch (cell.style.textAlign) {
- case 'center':
- before = ':';
- after = ':';
- chars -= 2;
- break;
- case 'right':
- after = ':';
- chars -= 1;
- break;
- default:
- break;
- }
-
- chars = Math.max(chars, 3);
-
- const middle = Array(chars + 1).join('-');
-
- return before + middle + after;
- });
-
- const separatorRow = `|${cells.join('|')}|`;
-
- return [text, separatorRow].join('\n');
- },
- '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(' | ')} |`;
- },
- },
-};
-
-class CopyAsGFM {
- constructor() {
- $(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);
- }
-
- static copyAsGFM(e, transformer) {
- const clipboardData = e.originalEvent.clipboardData;
- if (!clipboardData) return;
-
- const documentFragment = window.gl.utils.getSelectedFragment();
- if (!documentFragment) return;
-
- const el = transformer(documentFragment.cloneNode(true));
- if (!el) return;
-
- e.preventDefault();
- e.stopPropagation();
-
- clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
- }
-
- 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, (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) {
- 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 allGfmEl;
- }
- }
- }
-
- static transformCodeSelection(documentFragment) {
- const lineEls = documentFragment.querySelectorAll('.line');
-
- let codeEl;
- if (lineEls.length > 1) {
- codeEl = document.createElement('pre');
- codeEl.className = 'code highlight';
-
- const lang = lineEls[0].getAttribute('lang');
- if (lang) {
- codeEl.setAttribute('lang', lang);
- }
- } else {
- codeEl = document.createElement('code');
- }
-
- if (lineEls.length > 0) {
- for (let i = 0; i < lineEls.length; i += 1) {
- const lineEl = lineEls[i];
- codeEl.appendChild(lineEl);
- codeEl.appendChild(document.createTextNode('\n'));
- }
- } else {
- codeEl.appendChild(documentFragment);
- }
-
- return codeEl;
- }
-
- static nodeToGFM(node, respectWhitespaceParam = false) {
- if (node.nodeType === Node.COMMENT_NODE) {
- return '';
- }
-
- if (node.nodeType === Node.TEXT_NODE) {
- return node.textContent;
- }
-
- const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
-
- const text = this.innerGFM(node, respectWhitespace);
-
- if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
- return text;
- }
-
- for (const filter in gfmRules) {
- const rules = gfmRules[filter];
-
- for (const selector in rules) {
- const func = rules[selector];
-
- if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
-
- 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;
- }
- }
-
- return text;
- }
-
- static innerGFM(parentNode, respectWhitespace = false) {
- const nodes = parentNode.childNodes;
-
- const clonedParentNode = parentNode.cloneNode(true);
- const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
-
- for (let i = 0; i < nodes.length; i += 1) {
- const node = nodes[i];
- const clonedNode = clonedNodes[i];
-
- const text = this.nodeToGFM(node, respectWhitespace);
-
- // `clonedNode.replaceWith(text)` is not yet widely supported
- clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
- }
-
- let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
-
- if (!respectWhitespace) {
- nodeText = nodeText.trim();
- }
-
- return nodeText;
- }
-}
-
-window.gl = window.gl || {};
-window.gl.CopyAsGFM = CopyAsGFM;
-
-new CopyAsGFM();
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
deleted file mode 100644
index ab9a8e43dd1..00000000000
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, prefer-arrow-callback, max-len */
-
-import Clipboard from 'vendor/clipboard';
-
-var genericError, genericSuccess, showTooltip;
-
-genericSuccess = function(e) {
- showTooltip(e.trigger, 'Copied');
- // Clear the selection and blur the trigger so it loses its border
- e.clearSelection();
- return $(e.trigger).blur();
-};
-
-// Safari doesn't support `execCommand`, so instead we inform the user to
-// copy manually.
-//
-// See http://clipboardjs.com/#browser-support
-genericError = function(e) {
- var key;
- if (/Mac/i.test(navigator.userAgent)) {
- key = '&#8984;'; // Command
- } else {
- key = 'Ctrl';
- }
- return showTooltip(e.trigger, "Press " + key + "-C to copy");
-};
-
-showTooltip = function(target, title) {
- var $target = $(target);
- var originalTitle = $target.data('original-title');
-
- $target
- .attr('title', 'Copied')
- .tooltip('fixTitle')
- .tooltip('show')
- .attr('title', originalTitle)
- .tooltip('fixTitle');
-};
-
-$(function() {
- const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
- clipboard.on('success', genericSuccess);
- clipboard.on('error', genericError);
-
- // This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
- // The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text`
- // attribute that ClipboardJS reads from.
- // When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value
- // to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command,
- // this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the
- // `text/plain` and `text/x-gfm` copy data types to the intended values.
- $(document).on('copy', 'body > textarea[readonly]', function(e) {
- const clipboardData = e.originalEvent.clipboardData;
- if (!clipboardData) return;
-
- const text = e.target.value;
-
- let json;
- try {
- json = JSON.parse(text);
- } catch (ex) {
- return;
- }
-
- if (!json.text || !json.gfm) return;
-
- e.preventDefault();
-
- clipboardData.setData('text/plain', json.text);
- clipboardData.setData('text/x-gfm', json.gfm);
- });
-});
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
new file mode 100644
index 00000000000..42e9e568170
--- /dev/null
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -0,0 +1,119 @@
+import _ from 'underscore';
+
+export default class CreateItemDropdown {
+ /**
+ * @param {Object} options containing
+ * `$dropdown` target element
+ * `onSelect` event callback
+ * $dropdown must be an element created using `dropdown_tag()` rails helper
+ */
+ constructor(options) {
+ this.defaultToggleLabel = options.defaultToggleLabel;
+ this.fieldName = options.fieldName;
+ this.onSelect = options.onSelect || (() => {});
+ this.getDataOption = options.getData;
+ this.createNewItemFromValueOption = options.createNewItemFromValue;
+ this.$dropdown = options.$dropdown;
+ this.$dropdownContainer = this.$dropdown.parent();
+ this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
+ this.$createButton = this.$dropdownContainer.find('.js-dropdown-create-new-item');
+
+ this.buildDropdown();
+ this.bindEvents();
+
+ // Hide footer
+ this.toggleFooter(true);
+ }
+
+ buildDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.getData.bind(this),
+ filterable: true,
+ remote: false,
+ search: {
+ fields: ['text'],
+ },
+ selectable: true,
+ toggleLabel(selected) {
+ return (selected && 'id' in selected) ? _.escape(selected.title) : this.defaultToggleLabel;
+ },
+ fieldName: this.fieldName,
+ text(item) {
+ return _.escape(item.text);
+ },
+ id(item) {
+ return _.escape(item.id);
+ },
+ onFilter: this.toggleCreateNewButton.bind(this),
+ clicked: (options) => {
+ options.e.preventDefault();
+ this.onSelect();
+ },
+ });
+ }
+
+ clearDropdown() {
+ this.$dropdownContainer.find('.dropdown-content').html('');
+ this.$dropdownContainer.find('.dropdown-input-field').val('');
+ }
+
+ bindEvents() {
+ this.$createButton.on('click', this.onClickCreateWildcard.bind(this));
+ }
+
+ onClickCreateWildcard(e) {
+ e.preventDefault();
+
+ this.refreshData();
+ this.$dropdown.data('glDropdown').selectRowAtIndex();
+ }
+
+ refreshData() {
+ // Refresh the dropdown's data, which ends up calling `getData`
+ this.$dropdown.data('glDropdown').remote.execute();
+ }
+
+ getData(term, callback) {
+ this.getDataOption(term, (data = []) => {
+ // Ensure the selected item isn't already in the data to avoid duplicates
+ const alreadyHasSelectedItem = this.selectedItem && data.some(item =>
+ item.id === this.selectedItem.id,
+ );
+
+ let uniqueData = data;
+ if (!alreadyHasSelectedItem) {
+ uniqueData = data.concat(this.selectedItem || []);
+ }
+
+ callback(uniqueData);
+ });
+ }
+
+ createNewItemFromValue(newValue) {
+ if (this.createNewItemFromValueOption) {
+ return this.createNewItemFromValueOption(newValue);
+ }
+
+ return {
+ title: newValue,
+ id: newValue,
+ text: newValue,
+ };
+ }
+
+ toggleCreateNewButton(newValue) {
+ if (newValue) {
+ this.selectedItem = this.createNewItemFromValue(newValue);
+
+ this.$dropdownContainer
+ .find('.js-dropdown-create-new-item code')
+ .text(newValue);
+ }
+
+ this.toggleFooter(!newValue);
+ }
+
+ toggleFooter(toggleState) {
+ this.$dropdownFooter.toggleClass('hidden', toggleState);
+ }
+}
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 907b468e576..a999c21b2e9 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,8 +1,11 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */
+/* eslint-disable func-names, prefer-arrow-callback */
+
+import $ from 'jquery';
import Api from './api';
+import { humanize } from './lib/utils/text_utility';
-class CreateLabelDropdown {
- constructor ($el, namespacePath, projectPath) {
+export default class CreateLabelDropdown {
+ constructor($el, namespacePath, projectPath) {
this.$el = $el;
this.namespacePath = namespacePath;
this.projectPath = projectPath;
@@ -22,7 +25,7 @@ class CreateLabelDropdown {
this.addBinding();
}
- cleanBinding () {
+ cleanBinding() {
this.$colorSuggestions.off('click');
this.$newLabelField.off('keyup change');
this.$newColorField.off('keyup change');
@@ -31,7 +34,7 @@ class CreateLabelDropdown {
this.$newLabelCreateButton.off('click');
}
- addBinding () {
+ addBinding() {
const self = this;
this.$colorSuggestions.on('click', function (e) {
@@ -44,7 +47,7 @@ class CreateLabelDropdown {
this.$dropdownBack.on('click', this.resetForm.bind(this));
- this.$cancelButton.on('click', function(e) {
+ this.$cancelButton.on('click', function (e) {
e.preventDefault();
e.stopPropagation();
@@ -55,7 +58,7 @@ class CreateLabelDropdown {
this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
}
- addColorValue (e, $this) {
+ addColorValue(e, $this) {
e.preventDefault();
e.stopPropagation();
@@ -66,7 +69,7 @@ class CreateLabelDropdown {
.addClass('is-active');
}
- enableLabelCreateButton () {
+ enableLabelCreateButton() {
if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
this.$newLabelError.hide();
this.$newLabelCreateButton.enable();
@@ -75,7 +78,7 @@ class CreateLabelDropdown {
}
}
- resetForm () {
+ resetForm() {
this.$newLabelField
.val('')
.trigger('change');
@@ -90,13 +93,13 @@ class CreateLabelDropdown {
.removeClass('is-active');
}
- saveLabel (e) {
+ saveLabel(e) {
e.preventDefault();
e.stopPropagation();
Api.newLabel(this.namespacePath, this.projectPath, {
title: this.$newLabelField.val(),
- color: this.$newColorField.val()
+ color: this.$newColorField.val(),
}, (label) => {
this.$newLabelCreateButton.enable();
@@ -107,8 +110,8 @@ class CreateLabelDropdown {
errors = label.message;
} else {
errors = Object.keys(label.message).map(key =>
- `${gl.text.humanize(key)} ${label.message[key].join(', ')}`
- ).join("<br/>");
+ `${humanize(key)} ${label.message[key].join(', ')}`,
+ ).join('<br/>');
}
this.$newLabelError
@@ -122,6 +125,3 @@ class CreateLabelDropdown {
});
}
}
-
-window.gl = window.gl || {};
-gl.CreateLabelDropdown = CreateLabelDropdown;
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index ff2f2c81971..fb1fc9cd32e 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -1,7 +1,10 @@
/* eslint-disable no-new */
-/* global Flash */
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
+import { __, sprintf } from './locale';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
@@ -12,28 +15,49 @@ const CREATE_BRANCH = 'create-branch';
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
+ this.availableButton = this.wrapperEl.querySelector('.available');
+ this.branchInput = this.wrapperEl.querySelector('.js-branch-name');
+ this.branchMessage = this.wrapperEl.querySelector('.js-branch-message');
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
- this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
+ this.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
- this.availableButton = this.wrapperEl.querySelector('.available');
+ this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
+ this.refInput = this.wrapperEl.querySelector('.js-ref');
+ this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
- this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
+ this.branchCreated = false;
+ this.branchIsValid = true;
this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
+ this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false;
+ this.isCreatingBranch = false;
this.isCreatingMergeRequest = false;
+ this.isGettingRef = false;
this.mergeRequestCreated = false;
- this.isCreatingBranch = false;
- this.branchCreated = false;
+ this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
+ this.refIsValid = true;
+ this.refsPath = this.wrapperEl.dataset.refsPath;
+ this.suggestedRef = this.refInput.value;
- this.init();
- }
+ // These regexps are used to replace
+ // a backend generated new branch name and its source (ref)
+ // with user's inputs.
+ this.regexps = {
+ branch: {
+ createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'),
+ createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'),
+ },
+ ref: {
+ createBranchPath: new RegExp('(ref=)(.+?)$'),
+ createMrPath: new RegExp('(ref=)(.+?)$'),
+ },
+ };
- init() {
- this.checkAbilityToCreateBranch();
+ this.init();
}
available() {
@@ -41,153 +65,396 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.add('hide');
}
- unavailable() {
- this.availableButton.classList.add('hide');
- this.unavailableButton.classList.remove('hide');
+ bindEvents() {
+ this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
+ this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
+ this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
+ this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
+ }
+
+ checkAbilityToCreateBranch() {
+ this.setUnavailableButtonState();
+
+ axios.get(this.canCreatePath)
+ .then(({ data }) => {
+ this.setUnavailableButtonState(false);
+
+ if (data.can_create_branch) {
+ this.available();
+ this.enable();
+
+ if (!this.droplabInitialized) {
+ this.droplabInitialized = true;
+ this.initDroplab();
+ this.bindEvents();
+ }
+ } else if (data.has_related_branch) {
+ this.hide();
+ }
+ })
+ .catch(() => {
+ this.unavailable();
+ this.disable();
+ Flash('Failed to check if a new branch can be created.');
+ });
+ }
+
+ createBranch() {
+ this.isCreatingBranch = true;
+
+ return axios.post(this.createBranchPath)
+ .then(({ data }) => {
+ this.branchCreated = true;
+ window.location.href = data.url;
+ })
+ .catch(() => Flash('Failed to create a branch for this issue. Please try again.'));
+ }
+
+ createMergeRequest() {
+ this.isCreatingMergeRequest = true;
+
+ return axios.post(this.createMrPath)
+ .then(({ data }) => {
+ this.mergeRequestCreated = true;
+ window.location.href = data.url;
+ })
+ .catch(() => Flash('Failed to create Merge Request. Please try again.'));
+ }
+
+ disable() {
+ this.disableCreateAction();
+
+ this.dropdownToggle.classList.add('disabled');
+ this.dropdownToggle.setAttribute('disabled', 'disabled');
+ }
+
+ disableCreateAction() {
+ this.createMergeRequestButton.classList.add('disabled');
+ this.createMergeRequestButton.setAttribute('disabled', 'disabled');
+
+ this.createTargetButton.classList.add('disabled');
+ this.createTargetButton.setAttribute('disabled', 'disabled');
}
enable() {
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
+ this.createTargetButton.classList.remove('disabled');
+ this.createTargetButton.removeAttribute('disabled');
+
this.dropdownToggle.classList.remove('disabled');
this.dropdownToggle.removeAttribute('disabled');
}
- disable() {
- this.createMergeRequestButton.classList.add('disabled');
- this.createMergeRequestButton.setAttribute('disabled', 'disabled');
+ static findByValue(objects, ref, returnFirstMatch = false) {
+ if (!objects || !objects.length) return false;
+ if (objects.indexOf(ref) > -1) return ref;
+ if (returnFirstMatch) return objects.find(item => new RegExp(`^${ref}`).test(item));
- this.dropdownToggle.classList.add('disabled');
- this.dropdownToggle.setAttribute('disabled', 'disabled');
+ return false;
}
- hide() {
- this.wrapperEl.classList.add('hide');
+ getDroplabConfig() {
+ return {
+ addActiveClassToDropdownButton: true,
+ InputSetter: [
+ {
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ },
+ {
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-text',
+ },
+ {
+ input: this.createTargetButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ },
+ {
+ input: this.createTargetButton,
+ valueAttribute: 'data-text',
+ },
+ ],
+ hideOnClick: false,
+ };
}
- setUnavailableButtonState(isLoading = true) {
- if (isLoading) {
- this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin');
- this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
- this.unavailableButtonText.textContent = 'Checking branch availability…';
- } else {
- this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin');
- this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
- this.unavailableButtonText.textContent = 'New branch unavailable';
- }
+ static getInputSelectedText(input) {
+ const start = input.selectionStart;
+ const end = input.selectionEnd;
+
+ return input.value.substr(start, end - start);
}
- checkAbilityToCreateBranch() {
- return $.ajax({
- type: 'GET',
- dataType: 'json',
- url: this.canCreatePath,
- beforeSend: () => this.setUnavailableButtonState(),
- })
- .done((data) => {
- this.setUnavailableButtonState(false);
-
- if (data.can_create_branch) {
- this.available();
- this.enable();
-
- if (!this.droplabInitialized) {
- this.droplabInitialized = true;
- this.initDroplab();
- this.bindEvents();
+ getRef(ref, target = 'all') {
+ if (!ref) return false;
+
+ return axios.get(this.refsPath + ref)
+ .then(({ data }) => {
+ const branches = data[Object.keys(data)[0]];
+ const tags = data[Object.keys(data)[1]];
+ let result;
+
+ if (target === 'branch') {
+ result = CreateMergeRequestDropdown.findByValue(branches, ref);
+ } else {
+ result = CreateMergeRequestDropdown.findByValue(branches, ref, true) ||
+ CreateMergeRequestDropdown.findByValue(tags, ref, true);
+ this.suggestedRef = result;
}
- } else if (data.has_related_branch) {
- this.hide();
- }
- }).fail(() => {
- this.unavailable();
- this.disable();
- new Flash('Failed to check if a new branch can be created.');
- });
- }
- initDroplab() {
- this.droplab = new DropLab();
+ this.isGettingRef = false;
+
+ return this.updateInputState(target, ref, result);
+ })
+ .catch(() => {
+ this.unavailable();
+ this.disable();
+ new Flash('Failed to get ref.');
+
+ this.isGettingRef = false;
- this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter],
- this.getDroplabConfig());
+ return false;
+ });
}
- getDroplabConfig() {
+ getTargetData(target) {
return {
- InputSetter: [{
- input: this.createMergeRequestButton,
- valueAttribute: 'data-value',
- inputAttribute: 'data-action',
- }, {
- input: this.createMergeRequestButton,
- valueAttribute: 'data-text',
- }],
+ input: this[`${target}Input`],
+ message: this[`${target}Message`],
};
}
- bindEvents() {
- this.createMergeRequestButton
- .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ hide() {
+ this.wrapperEl.classList.add('hide');
+ }
+
+ init() {
+ this.checkAbilityToCreateBranch();
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+
+ this.droplab.init(
+ this.dropdownToggle,
+ this.dropdownList,
+ [InputSetter],
+ this.getDroplabConfig(),
+ );
+ }
+
+ inputsAreValid() {
+ return this.branchIsValid && this.refIsValid;
}
isBusy() {
return this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
- this.branchCreated;
+ this.branchCreated ||
+ this.isGettingRef;
}
- onClickCreateMergeRequestButton(e) {
+ onChangeInput(event) {
+ let target;
+ let value;
+
+ if (event.target === this.branchInput) {
+ target = 'branch';
+ value = this.branchInput.value;
+ } else if (event.target === this.refInput) {
+ target = 'ref';
+ value = event.target.value.slice(0, event.target.selectionStart) +
+ event.target.value.slice(event.target.selectionEnd);
+ } else {
+ return false;
+ }
+
+ if (this.isGettingRef) return false;
+
+ // `ENTER` key submits the data.
+ if (event.keyCode === 13 && this.inputsAreValid()) {
+ event.preventDefault();
+ return this.createMergeRequestButton.click();
+ }
+
+ // If the input is empty, use the original value generated by the backend.
+ if (!value) {
+ this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
+ this.createMrPath = this.wrapperEl.dataset.createMrPath;
+
+ if (target === 'branch') {
+ this.branchIsValid = true;
+ } else {
+ this.refIsValid = true;
+ }
+
+ this.enable();
+ this.showAvailableMessage(target);
+ return true;
+ }
+
+ this.showCheckingMessage(target);
+ this.refDebounce(value, target);
+
+ return true;
+ }
+
+ onClickCreateMergeRequestButton(event) {
let xhr = null;
- e.preventDefault();
+ event.preventDefault();
if (this.isBusy()) {
return;
}
- if (e.target.dataset.action === CREATE_MERGE_REQUEST) {
+ if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
- } else if (e.target.dataset.action === CREATE_BRANCH) {
+ } else if (event.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
- xhr.fail(() => {
+ xhr.catch(() => {
this.isCreatingMergeRequest = false;
this.isCreatingBranch = false;
- });
- xhr.always(() => this.enable());
+ this.enable();
+ });
this.disable();
}
- createMergeRequest() {
- return $.ajax({
- method: 'POST',
- dataType: 'json',
- url: this.createMrPath,
- beforeSend: () => (this.isCreatingMergeRequest = true),
- })
- .done((data) => {
- this.mergeRequestCreated = true;
- window.location.href = data.url;
- })
- .fail(() => new Flash('Failed to create Merge Request. Please try again.'));
+ onClickSetFocusOnBranchNameInput() {
+ this.branchInput.focus();
}
- createBranch() {
- return $.ajax({
- method: 'POST',
- dataType: 'json',
- url: this.createBranchPath,
- beforeSend: () => (this.isCreatingBranch = true),
- })
- .done((data) => {
- this.branchCreated = true;
- window.location.href = data.url;
- })
- .fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
+ // `TAB` autocompletes the source.
+ static processTab(event) {
+ if (event.keyCode !== 9 || this.isGettingRef) return;
+
+ const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
+
+ // if nothing selected, we don't need to autocomplete anything. Do the default TAB action.
+ // If a user manually selected text, don't autocomplete anything. Do the default TAB action.
+ if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
+
+ event.preventDefault();
+ window.getSelection().removeAllRanges();
+ }
+
+ removeMessage(target) {
+ const { input, message } = this.getTargetData(target);
+ const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline'];
+ const messageClasses = ['gl-field-hint', 'gl-field-error-message', 'gl-field-success-message'];
+
+ inputClasses.forEach(cssClass => input.classList.remove(cssClass));
+ messageClasses.forEach(cssClass => message.classList.remove(cssClass));
+ message.style.display = 'none';
+ }
+
+ setUnavailableButtonState(isLoading = true) {
+ if (isLoading) {
+ this.unavailableButtonArrow.classList.add('fa-spin');
+ this.unavailableButtonArrow.classList.add('fa-spinner');
+ this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = __('Checking branch availability...');
+ } else {
+ this.unavailableButtonArrow.classList.remove('fa-spin');
+ this.unavailableButtonArrow.classList.remove('fa-spinner');
+ this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = __('New branch unavailable');
+ }
+ }
+
+ showAvailableMessage(target) {
+ const { input, message } = this.getTargetData(target);
+ const text = target === 'branch' ? __('Branch name') : __('Source');
+
+ this.removeMessage(target);
+ input.classList.add('gl-field-success-outline');
+ message.classList.add('gl-field-success-message');
+ message.textContent = sprintf(__('%{text} is available'), { text });
+ message.style.display = 'inline-block';
+ }
+
+ showCheckingMessage(target) {
+ const { message } = this.getTargetData(target);
+ const text = target === 'branch' ? __('branch name') : __('source');
+
+ this.removeMessage(target);
+ message.classList.add('gl-field-hint');
+ message.textContent = sprintf(__('Checking %{text} availability…'), { text });
+ message.style.display = 'inline-block';
+ }
+
+ showNotAvailableMessage(target) {
+ const { input, message } = this.getTargetData(target);
+ const text = target === 'branch' ? __('Branch is already taken') : __('Source is not available');
+
+ this.removeMessage(target);
+ input.classList.add('gl-field-error-outline');
+ message.classList.add('gl-field-error-message');
+ message.textContent = text;
+ message.style.display = 'inline-block';
+ }
+
+ unavailable() {
+ this.availableButton.classList.add('hide');
+ this.unavailableButton.classList.remove('hide');
+ }
+
+ updateInputState(target, ref, result) {
+ // target - 'branch' or 'ref' - which the input field we are searching a ref for.
+ // ref - string - what a user typed.
+ // result - string - what has been found on backend.
+
+ const pathReplacement = `$1${ref}`;
+
+ // If a found branch equals exact the same text a user typed,
+ // that means a new branch cannot be created as it already exists.
+ if (ref === result) {
+ if (target === 'branch') {
+ this.branchIsValid = false;
+ this.showNotAvailableMessage('branch');
+ } else {
+ this.refIsValid = true;
+ this.refInput.dataset.value = ref;
+ this.showAvailableMessage('ref');
+ this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath,
+ pathReplacement);
+ this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath,
+ pathReplacement);
+ }
+ } else if (target === 'branch') {
+ this.branchIsValid = true;
+ this.showAvailableMessage('branch');
+ this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath,
+ pathReplacement);
+ this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath,
+ pathReplacement);
+ } else {
+ this.refIsValid = false;
+ this.refInput.dataset.value = ref;
+ this.disableCreateAction();
+ this.showNotAvailableMessage('ref');
+
+ // Show ref hint.
+ if (result) {
+ this.refInput.value = result;
+ this.refInput.setSelectionRange(ref.length, result.length);
+ }
+ }
+
+ if (this.inputsAreValid()) {
+ this.enable();
+ } else {
+ this.disableCreateAction();
+ }
}
}
diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue
new file mode 100644
index 00000000000..3204b8dd8e7
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/banner.vue
@@ -0,0 +1,61 @@
+<script>
+ import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg';
+
+ export default {
+ props: {
+ documentationLink: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ iconCycleAnalyticsSplash() {
+ return iconCycleAnalyticsSplash;
+ },
+ },
+ methods: {
+ dismissOverviewDialog() {
+ this.$emit('dismiss-overview-dialog');
+ },
+ },
+ };
+</script>
+<template>
+ <div class="landing content-block">
+ <button
+ class="js-ca-dismiss-button dismiss-button"
+ type="button"
+ :aria-label="__('Dismiss Cycle Analytics introduction box')"
+ @click="dismissOverviewDialog"
+ >
+ <i
+ class="fa fa-times"
+ aria-hidden="true">
+ </i>
+ </button>
+ <div
+ class="svg-container"
+ v-html="iconCycleAnalyticsSplash"
+ >
+ </div>
+ <div class="inner-content">
+ <h4>
+ {{ __('Introducing Cycle Analytics') }}
+ </h4>
+ <p>
+ {{ __(`Cycle Analytics gives an overview
+of how much time it takes to go from idea to production in your project.`) }}
+ </p>
+ <p>
+ <a
+ :href="documentationLink"
+ target="_blank"
+ rel="nofollow"
+ class="btn"
+ >
+ {{ __('Read more') }}
+ </a>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
deleted file mode 100644
index 8d3d34f836f..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export default {
- props: {
- count: {
- type: Number,
- required: true,
- },
- },
- template: `
- <span v-if="count === 50" class="events-info pull-right">
- <i class="fa fa-warning has-tooltip"
- aria-hidden="true"
- :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)"
- data-placement="top"></i>
- {{ n__('Showing %d event', 'Showing %d events', 50) }}
- </span>
- `,
-};
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
new file mode 100644
index 00000000000..32ae0cc1476
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
@@ -0,0 +1,35 @@
+<script>
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ },
+ };
+</script>
+<template>
+ <span
+ v-if="count === 50"
+ class="events-info pull-right"
+ >
+ <i
+ class="fa fa-warning"
+ v-tooltip
+ aria-hidden="true"
+ :title="n__(
+ 'Limited to showing %d event at most',
+ 'Limited to showing %d events at most',
+ 50
+ )"
+ data-placement="top"
+ >
+ </i>
+ {{ n__('Showing %d event', 'Showing %d events', 50) }}
+ </span>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
deleted file mode 100644
index 7c32a38fbe7..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/* 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 || {};
-
-global.cycleAnalytics.StageCodeComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- components: {
- userAvatarImage,
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="mergeRequest in items" class="stage-event-item">
- <div class="item-details">
- <!-- 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 }}
- </a>
- </h5>
- <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
- &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
- </span>
- </div>
- <div class="item-time">
- <total-time :time="mergeRequest.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
new file mode 100644
index 00000000000..a71dcf78103
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
@@ -0,0 +1,73 @@
+<script>
+ import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+ import limitWarning from './limit_warning_component.vue';
+ import totalTime from './total_time_component.vue';
+
+ export default {
+ components: {
+ userAvatarImage,
+ limitWarning,
+ totalTime,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ <limit-warning :count="items.length" />
+ </div>
+ <ul class="stage-event-list">
+ <li
+ v-for="(mergeRequest, i) in items"
+ :key="i"
+ class="stage-event-item"
+ >
+ <div class="item-details">
+ <!-- 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 }}
+ </a>
+ </h5>
+ <a
+ :href="mergeRequest.url"
+ class="issue-link">
+ !{{ mergeRequest.iid }}
+ </a>
+ &middot;
+ <span>
+ {{ s__('OpenedNDaysAgo|Opened') }}
+ <a
+ :href="mergeRequest.url"
+ class="issue-date">
+ {{ mergeRequest.createdAt }}
+ </a>
+ </span>
+ <span>
+ {{ s__('ByAuthor|by') }}
+ <a
+ :href="mergeRequest.author.webUrl"
+ class="issue-author-link">
+ {{ mergeRequest.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="mergeRequest.totalTime" />
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_component.vue
new file mode 100644
index 00000000000..907638d798a
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_component.vue
@@ -0,0 +1,76 @@
+<script>
+ import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+ import limitWarning from './limit_warning_component.vue';
+ import totalTime from './total_time_component.vue';
+
+ export default {
+ components: {
+ userAvatarImage,
+ limitWarning,
+ totalTime,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ <limit-warning :count="items.length" />
+ </div>
+ <ul class="stage-event-list">
+ <li
+ v-for="(issue, i) in items"
+ :key="i"
+ class="stage-event-item"
+ >
+ <div class="item-details">
+ <!-- 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 }}
+ </a>
+ </h5>
+ <a
+ :href="issue.url"
+ class="issue-link"
+ >#{{ issue.iid }}</a>
+ &middot;
+ <span>
+ {{ s__('OpenedNDaysAgo|Opened') }}
+ <a
+ :href="issue.url"
+ class="issue-date"
+ >{{ issue.createdAt }}</a>
+ </span>
+ <span>
+ {{ s__('ByAuthor|by') }}
+ <a
+ :href="issue.author.webUrl"
+ class="issue-author-link"
+ >
+ {{ issue.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="issue.totalTime" />
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
+
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
deleted file mode 100644
index 5f4a0ac8590..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/* 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 || {};
-
-global.cycleAnalytics.StageIssueComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- components: {
- userAvatarImage,
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="issue in items" class="stage-event-item">
- <div class="item-details">
- <!-- 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 }}
- </a>
- </h5>
- <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
- &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="issue.author.webUrl" class="issue-author-link">
- {{ issue.author.name }}
- </a>
- </span>
- </div>
- <div class="item-time">
- <total-time :time="issue.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
deleted file mode 100644
index 11fee5410d9..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* 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 = {});
-global.cycleAnalytics = global.cycleAnalytics || {};
-
-global.cycleAnalytics.StagePlanComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- components: {
- userAvatarImage,
- },
- data() {
- return { iconCommit };
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="commit in items" class="stage-event-item">
- <div class="item-details item-conmmit-component">
- <!-- 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 }}
- </a>
- </h5>
- <span>
- {{ s__('FirstPushedBy|First') }}
- <span class="commit-icon">${iconCommit}</span>
- <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a>
- {{ s__('FirstPushedBy|pushed by') }}
- <a :href="commit.author.webUrl" class="commit-author-link">
- {{ commit.author.name }}
- </a>
- </span>
- </div>
- <div class="item-time">
- <total-time :time="commit.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue
new file mode 100644
index 00000000000..cee294b4ac2
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue
@@ -0,0 +1,77 @@
+<script>
+ import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+ import iconCommit from '../svg/icon_commit.svg';
+ import limitWarning from './limit_warning_component.vue';
+ import totalTime from './total_time_component.vue';
+
+ export default {
+ components: {
+ userAvatarImage,
+ totalTime,
+ limitWarning,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ iconCommit() {
+ return iconCommit;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ <limit-warning :count="items.length" />
+ </div>
+ <ul class="stage-event-list">
+ <li
+ v-for="(commit, i) in items"
+ :key="i"
+ class="stage-event-item"
+ >
+ <div class="item-details item-conmmit-component">
+ <!-- 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 }}
+ </a>
+ </h5>
+ <span>
+ {{ s__('FirstPushedBy|First') }}
+ <span
+ class="commit-icon"
+ v-html="iconCommit"
+ >
+ </span>
+ <a
+ :href="commit.commitUrl"
+ class="commit-hash-link commit-sha"
+ >{{ commit.shortSha }}</a>
+ {{ s__('FirstPushedBy|pushed by') }}
+ <a
+ :href="commit.author.webUrl"
+ class="commit-author-link"
+ >
+ {{ commit.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="commit.totalTime" />
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
+
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
deleted file mode 100644
index b7ba9360f70..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/* 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 || {};
-
-global.cycleAnalytics.StageProductionComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- components: {
- userAvatarImage,
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="issue in items" class="stage-event-item">
- <div class="item-details">
- <!-- 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 }}
- </a>
- </h5>
- <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
- &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="issue.author.webUrl" class="issue-author-link">
- {{ issue.author.name }}
- </a>
- </span>
- </div>
- <div class="item-time">
- <total-time :time="issue.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
deleted file mode 100644
index f41a0d0e4ff..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/* 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 || {};
-
-global.cycleAnalytics.StageReviewComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- components: {
- userAvatarImage,
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="mergeRequest in items" class="stage-event-item">
- <div class="item-details">
- <!-- 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 }}
- </a>
- </h5>
- <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
- &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
- </span>
- <template v-if="mergeRequest.state === 'closed'">
- <span class="merge-request-state">
- <i class="fa fa-ban"></i>
- {{ mergeRequest.state.toUpperCase() }}
- </span>
- </template>
- <template v-else>
- <span class="merge-request-branch" v-if="mergeRequest.branch">
- <i class= "fa fa-code-fork"></i>
- <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a>
- </span>
- </template>
- </div>
- <div class="item-time">
- <total-time :time="mergeRequest.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
new file mode 100644
index 00000000000..34aa04083e6
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
@@ -0,0 +1,96 @@
+<script>
+ import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+ import limitWarning from './limit_warning_component.vue';
+ import totalTime from './total_time_component.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ userAvatarImage,
+ totalTime,
+ limitWarning,
+ icon,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ <limit-warning :count="items.length" />
+ </div>
+ <ul class="stage-event-list">
+ <li
+ v-for="(mergeRequest, i) in items"
+ :key="i"
+ class="stage-event-item"
+ >
+ <div class="item-details">
+ <!-- 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 }}
+ </a>
+ </h5>
+ <a
+ :href="mergeRequest.url"
+ class="issue-link"
+ >!{{ mergeRequest.iid }}</a>
+ &middot;
+ <span>
+ {{ s__('OpenedNDaysAgo|Opened') }}
+ <a
+ :href="mergeRequest.url"
+ class="issue-date"
+ >{{ mergeRequest.createdAt }}</a>
+ </span>
+ <span>
+ {{ s__('ByAuthor|by') }}
+ <a
+ :href="mergeRequest.author.webUrl"
+ class="issue-author-link"
+ >{{ mergeRequest.author.name }}</a>
+ </span>
+ <template v-if="mergeRequest.state === 'closed'">
+ <span class="merge-request-state">
+ <i
+ class="fa fa-ban"
+ aria-hidden="true"
+ >
+ </i>
+ {{ mergeRequest.state.toUpperCase() }}
+ </span>
+ </template>
+ <template v-else>
+ <span
+ class="merge-request-branch"
+ v-if="mergeRequest.branch"
+ >
+ <icon
+ name="fork"
+ :size="16"
+ />
+ <a :href="mergeRequest.branch.url">
+ {{ mergeRequest.branch.name }}
+ </a>
+ </span>
+ </template>
+ </div>
+ <div class="item-time">
+ <total-time :time="mergeRequest.totalTime" />
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
deleted file mode 100644
index d7c906c9d39..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* 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 = {});
-global.cycleAnalytics = global.cycleAnalytics || {};
-
-global.cycleAnalytics.StageStagingComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- data() {
- return { iconBranch };
- },
- components: {
- userAvatarImage,
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="build in items" class="stage-event-item item-build-component">
- <div class="item-details">
- <!-- 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="ref-name">{{ build.branch.name }}</a>
- <span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
- </h5>
- <span>
- <a :href="build.url" class="build-date">{{ build.date }}</a>
- {{ s__('ByAuthor|by') }}
- <a :href="build.author.webUrl" class="issue-author-link">
- {{ build.author.name }}
- </a>
- </span>
- </div>
- <div class="item-time">
- <total-time :time="build.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
new file mode 100644
index 00000000000..92f2a95a66a
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
@@ -0,0 +1,98 @@
+<script>
+ import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+ import iconBranch from '../svg/icon_branch.svg';
+ import limitWarning from './limit_warning_component.vue';
+ import totalTime from './total_time_component.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ userAvatarImage,
+ totalTime,
+ limitWarning,
+ icon,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ iconBranch() {
+ return iconBranch;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ <limit-warning :count="items.length" />
+ </div>
+ <ul class="stage-event-list">
+ <li
+ v-for="(build, i) in items"
+ class="stage-event-item item-build-component"
+ :key="i"
+ >
+ <div class="item-details">
+ <!-- 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>
+ <icon
+ name="fork"
+ :size="16"
+ />
+ <a
+ :href="build.branch.url"
+ class="ref-name"
+ >
+ {{ build.branch.name }}
+ </a>
+ <span
+ class="icon-branch"
+ v-html="iconBranch"
+ >
+ </span>
+ <a
+ :href="build.commitUrl"
+ class="commit-sha"
+ >
+ {{ build.shortSha }}
+ </a>
+ </h5>
+ <span>
+ <a
+ :href="build.url"
+ class="build-date"
+ >
+ {{ build.date }}
+ </a>
+ {{ s__('ByAuthor|by') }}
+ <a
+ :href="build.author.webUrl"
+ class="issue-author-link"
+ >
+ {{ build.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="build.totalTime" />
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
deleted file mode 100644
index 78cc97eea0b..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* eslint-disable no-param-reassign */
-import Vue from 'vue';
-import iconBuildStatus from '../svg/icon_build_status.svg';
-import iconBranch from '../svg/icon_branch.svg';
-
-const global = window.gl || (window.gl = {});
-global.cycleAnalytics = global.cycleAnalytics || {};
-
-global.cycleAnalytics.StageTestComponent = Vue.extend({
- props: {
- items: Array,
- stage: Object,
- },
- data() {
- return { iconBuildStatus, iconBranch };
- },
- template: `
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="build in items" class="stage-event-item item-build-component">
- <div class="item-details">
- <h5 class="item-title">
- <span class="icon-build-status">${iconBuildStatus}</span>
- <a :href="build.url" class="item-build-name">{{ build.name }}</a>
- &middot;
- <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
- <i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
- <span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
- </h5>
- <span>
- <a :href="build.url" class="issue-date">
- {{ build.date }}
- </a>
- </span>
- </div>
- <div class="item-time">
- <total-time :time="build.totalTime"></total-time>
- </div>
- </li>
- </ul>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
new file mode 100644
index 00000000000..b84bb6ed792
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
@@ -0,0 +1,101 @@
+<script>
+ import iconBuildStatus from '../svg/icon_build_status.svg';
+ import iconBranch from '../svg/icon_branch.svg';
+ import limitWarning from './limit_warning_component.vue';
+ import totalTime from './total_time_component.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ totalTime,
+ limitWarning,
+ icon,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ iconBuildStatus() {
+ return iconBuildStatus;
+ },
+ iconBranch() {
+ return iconBranch;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ <limit-warning :count="items.length" />
+ </div>
+ <ul class="stage-event-list">
+ <li
+ v-for="(build, i) in items"
+ :key="i"
+ class="stage-event-item item-build-component"
+ >
+ <div class="item-details">
+ <h5 class="item-title">
+ <span
+ class="icon-build-status"
+ v-html="iconBuildStatus"
+ >
+ </span>
+ <a
+ :href="build.url"
+ class="item-build-name"
+ >
+ {{ build.name }}
+ </a>
+ &middot;
+ <a
+ :href="build.url"
+ class="pipeline-id"
+ >
+ #{{ build.id }}
+ </a>
+ <icon
+ name="fork"
+ :size="16"
+ />
+ <a
+ :href="build.branch.url"
+ class="ref-name"
+ >
+ {{ build.branch.name }}
+ </a>
+ <span
+ class="icon-branch"
+ v-html="iconBranch"
+ >
+ </span>
+ <a
+ :href="build.commitUrl"
+ class="commit-sha">
+ {{ build.shortSha }}
+ </a>
+ </h5>
+ <span>
+ <a
+ :href="build.url"
+ class="issue-date">
+ {{ build.date }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="build.totalTime" />
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
deleted file mode 100644
index d5e6167b2a8..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* eslint-disable no-param-reassign */
-
-import Vue from 'vue';
-
-const global = window.gl || (window.gl = {});
-global.cycleAnalytics = global.cycleAnalytics || {};
-
-global.cycleAnalytics.TotalTimeComponent = Vue.extend({
- props: {
- time: Object,
- },
- template: `
- <span class="total-time">
- <template v-if="Object.keys(time).length">
- <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template>
- <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template>
- <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template>
- <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template>
- </template>
- <template v-else>
- --
- </template>
- </span>
- `,
-});
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
new file mode 100644
index 00000000000..7758bf0cb3f
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
@@ -0,0 +1,49 @@
+<script>
+ export default {
+ props: {
+ time: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ hasData() {
+ return Object.keys(this.time).length;
+ },
+ },
+ };
+</script>
+<template>
+ <span class="total-time">
+ <template v-if="hasData">
+ <template v-if="time.days">
+ {{ time.days }}
+ <span>
+ {{ n__('day', 'days', time.days) }}
+ </span>
+ </template>
+ <template v-if="time.hours">
+ {{ time.hours }}
+ <span>
+ {{ n__('Time|hr', 'Time|hrs', time.hours) }}
+ </span>
+ </template>
+ <template v-if="time.mins && !time.days">
+ {{ time.mins }}
+ <span>
+ {{ n__('Time|min', 'Time|mins', time.mins) }}
+ </span>
+ </template>
+ <template v-if="time.seconds && hasData === 1 || time.seconds === 0">
+ {{ time.seconds }}
+ <span>
+ {{ s__('Time|s') }}
+ </span>
+ </template>
+ </template>
+ <template v-else>
+ --
+ </template>
+ </span>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 44791a93936..87f8854f940 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -1,62 +1,65 @@
-/* global Flash */
-
+import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
+import Flash from '../flash';
import Translate from '../vue_shared/translate';
-import LimitWarningComponent from './components/limit_warning_component';
-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';
+import banner from './components/banner.vue';
+import stageCodeComponent from './components/stage_code_component.vue';
+import stagePlanComponent from './components/stage_plan_component.vue';
+import stageComponent from './components/stage_component.vue';
+import stageReviewComponent from './components/stage_review_component.vue';
+import stageStagingComponent from './components/stage_staging_component.vue';
+import stageTestComponent from './components/stage_test_component.vue';
+import CycleAnalyticsService from './cycle_analytics_service';
+import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate);
-$(() => {
+export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
- const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
- const cycleAnalyticsStore = gl.cycleAnalytics.CycleAnalyticsStore;
- const cycleAnalyticsService = new gl.cycleAnalytics.CycleAnalyticsService({
- requestPath: cycleAnalyticsEl.dataset.requestPath,
- });
- gl.cycleAnalyticsApp = new Vue({
+ new Vue({ // eslint-disable-line no-new
el: '#cycle-analytics',
name: 'CycleAnalytics',
- data: {
- state: cycleAnalyticsStore.state,
- isLoading: false,
- isLoadingStage: false,
- isEmptyStage: false,
- hasError: false,
- startDate: 30,
- isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ components: {
+ banner,
+ 'stage-issue-component': stageComponent,
+ 'stage-plan-component': stagePlanComponent,
+ 'stage-code-component': stageCodeComponent,
+ 'stage-test-component': stageTestComponent,
+ 'stage-review-component': stageReviewComponent,
+ 'stage-staging-component': stageStagingComponent,
+ 'stage-production-component': stageComponent,
+ },
+ data() {
+ const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
+ const cycleAnalyticsService = new CycleAnalyticsService({
+ requestPath: cycleAnalyticsEl.dataset.requestPath,
+ });
+
+ return {
+ store: CycleAnalyticsStore,
+ state: CycleAnalyticsStore.state,
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+ hasError: false,
+ startDate: 30,
+ isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ service: cycleAnalyticsService,
+ };
},
computed: {
currentStage() {
- return cycleAnalyticsStore.currentActiveStage();
+ return this.store.currentActiveStage();
},
},
- components: {
- 'stage-issue-component': gl.cycleAnalytics.StageIssueComponent,
- 'stage-plan-component': gl.cycleAnalytics.StagePlanComponent,
- 'stage-code-component': gl.cycleAnalytics.StageCodeComponent,
- 'stage-test-component': gl.cycleAnalytics.StageTestComponent,
- 'stage-review-component': gl.cycleAnalytics.StageReviewComponent,
- 'stage-staging-component': gl.cycleAnalytics.StageStagingComponent,
- 'stage-production-component': gl.cycleAnalytics.StageProductionComponent,
- },
created() {
this.fetchCycleAnalyticsData();
},
methods: {
handleError() {
- cycleAnalyticsStore.setErrorState(true);
+ this.store.setErrorState(true);
return new Flash('There was an error while fetching cycle analytics data.');
},
initDropdown() {
@@ -77,22 +80,22 @@ $(() => {
this.isLoading = true;
- cycleAnalyticsService
+ this.service
.fetchCycleAnalyticsData(fetchOptions)
- .done((response) => {
- cycleAnalyticsStore.setCycleAnalyticsData(response);
+ .then(resp => resp.json())
+ .then((response) => {
+ this.store.setCycleAnalyticsData(response);
this.selectDefaultStage();
this.initDropdown();
+ this.isLoading = false;
})
- .error(() => {
+ .catch(() => {
this.handleError();
- })
- .always(() => {
this.isLoading = false;
});
},
selectDefaultStage() {
- const stage = this.state.stages.first();
+ const stage = this.state.stages[0];
this.selectStage(stage);
},
selectStage(stage) {
@@ -100,27 +103,27 @@ $(() => {
if (this.currentStage === stage) return;
if (!stage.isUserAllowed) {
- cycleAnalyticsStore.setActiveStage(stage);
+ this.store.setActiveStage(stage);
return;
}
this.isLoadingStage = true;
- cycleAnalyticsStore.setStageEvents([], stage);
- cycleAnalyticsStore.setActiveStage(stage);
+ this.store.setStageEvents([], stage);
+ this.store.setActiveStage(stage);
- cycleAnalyticsService
+ this.service
.fetchStageData({
stage,
startDate: this.startDate,
})
- .done((response) => {
+ .then(resp => resp.json())
+ .then((response) => {
this.isEmptyStage = !response.events.length;
- cycleAnalyticsStore.setStageEvents(response.events, stage);
+ this.store.setStageEvents(response.events, stage);
+ this.isLoadingStage = false;
})
- .error(() => {
+ .catch(() => {
this.isEmptyStage = true;
- })
- .always(() => {
this.isLoadingStage = false;
});
},
@@ -130,8 +133,4 @@ $(() => {
},
},
});
-
- // Register global components
- Vue.component('limit-warning', LimitWarningComponent);
- Vue.component('total-time', gl.cycleAnalytics.TotalTimeComponent);
-});
+};
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
index 6504d7db2f2..f496c38208d 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
@@ -1,27 +1,16 @@
-/* eslint-disable no-param-reassign */
+import Vue from 'vue';
+import VueResource from 'vue-resource';
-const global = window.gl || (window.gl = {});
-global.cycleAnalytics = global.cycleAnalytics || {};
+Vue.use(VueResource);
-class CycleAnalyticsService {
+export default class CycleAnalyticsService {
constructor(options) {
this.requestPath = options.requestPath;
+ this.cycleAnalytics = Vue.resource(this.requestPath);
}
- fetchCycleAnalyticsData(options) {
- options = options || { startDate: 30 };
-
- return $.ajax({
- url: this.requestPath,
- method: 'GET',
- dataType: 'json',
- contentType: 'application/json',
- data: {
- cycle_analytics: {
- start_date: options.startDate,
- },
- },
- });
+ fetchCycleAnalyticsData(options = { startDate: 30 }) {
+ return this.cycleAnalytics.get({ cycle_analytics: { start_date: options.startDate } });
}
fetchStageData(options) {
@@ -30,12 +19,12 @@ class CycleAnalyticsService {
startDate,
} = options;
- return $.get(`${this.requestPath}/events/${stage.name}.json`, {
- cycle_analytics: {
- start_date: startDate,
+ return Vue.http.get(`${this.requestPath}/events/${stage.name}.json`, {
+ params: {
+ cycle_analytics: {
+ start_date: startDate,
+ },
},
});
}
}
-
-global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService;
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 991f8c1f6fd..a8cd8c20f8f 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -1,12 +1,9 @@
/* eslint-disable no-param-reassign */
import { __ } from '../locale';
-import '../lib/utils/text_utility';
+import { dasherize } from '../lib/utils/text_utility';
import DEFAULT_EVENT_OBJECTS from './default_event_objects';
-const global = window.gl || (window.gl = {});
-global.cycleAnalytics = global.cycleAnalytics || {};
-
const EMPTY_STAGE_TEXTS = {
issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'),
plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'),
@@ -17,7 +14,7 @@ const EMPTY_STAGE_TEXTS = {
production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'),
};
-global.cycleAnalytics.CycleAnalyticsStore = {
+export default {
state: {
summary: '',
stats: '',
@@ -39,7 +36,7 @@ global.cycleAnalytics.CycleAnalyticsStore = {
});
newData.stages.forEach((item) => {
- const stageSlug = gl.text.dasherize(item.name.toLowerCase());
+ const stageSlug = dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
index 3f993213dd0..b839b9f286f 100644
--- a/app/assets/javascripts/deploy_keys/components/action_btn.vue
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -3,10 +3,8 @@
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
- data() {
- return {
- isLoading: false,
- };
+ components: {
+ loadingIcon,
},
props: {
deployKey: {
@@ -23,21 +21,23 @@
default: 'btn-default',
},
},
-
- components: {
- loadingIcon,
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ computed: {
+ text() {
+ return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
+ },
},
-
methods: {
doAction() {
this.isLoading = true;
- eventHub.$emit(`${this.type}.key`, this.deployKey);
- },
- },
- computed: {
- text() {
- return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
+ eventHub.$emit(`${this.type}.key`, this.deployKey, () => {
+ this.isLoading = false;
+ });
},
},
};
@@ -50,6 +50,9 @@
:disabled="isLoading"
@click="doAction">
{{ text }}
- <loading-icon v-if="isLoading" />
+ <loading-icon
+ v-if="isLoading"
+ :inline="true"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index a663e30dfd0..5a782237b7d 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,5 +1,5 @@
<script>
- /* global Flash */
+ import Flash from '../../flash';
import eventHub from '../eventhub';
import DeployKeysService from '../service';
import DeployKeysStore from '../store';
@@ -7,11 +7,9 @@
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
- data() {
- return {
- isLoading: false,
- store: new DeployKeysStore(),
- };
+ components: {
+ keysPanel,
+ loadingIcon,
},
props: {
endpoint: {
@@ -19,6 +17,12 @@
required: true,
},
},
+ data() {
+ return {
+ isLoading: false,
+ store: new DeployKeysStore(),
+ };
+ },
computed: {
hasKeys() {
return Object.keys(this.keys).length;
@@ -27,9 +31,20 @@
return this.store.keys;
},
},
- components: {
- keysPanel,
- loadingIcon,
+ created() {
+ this.service = new DeployKeysService(this.endpoint);
+
+ eventHub.$on('enable.key', this.enableKey);
+ eventHub.$on('remove.key', this.disableKey);
+ eventHub.$on('disable.key', this.disableKey);
+ },
+ mounted() {
+ this.fetchKeys();
+ },
+ beforeDestroy() {
+ eventHub.$off('enable.key', this.enableKey);
+ eventHub.$off('remove.key', this.disableKey);
+ eventHub.$off('disable.key', this.disableKey);
},
methods: {
fetchKeys() {
@@ -47,30 +62,18 @@
.then(() => this.fetchKeys())
.catch(() => new Flash('Error enabling deploy key'));
},
- disableKey(deployKey) {
+ disableKey(deployKey, callback) {
// eslint-disable-next-line no-alert
if (confirm('You are going to remove this deploy key. Are you sure?')) {
this.service.disableKey(deployKey.id)
.then(() => this.fetchKeys())
+ .then(callback)
.catch(() => new Flash('Error removing deploy key'));
+ } else {
+ callback();
}
},
},
- created() {
- this.service = new DeployKeysService(this.endpoint);
-
- eventHub.$on('enable.key', this.enableKey);
- eventHub.$on('remove.key', this.disableKey);
- eventHub.$on('disable.key', this.disableKey);
- },
- mounted() {
- this.fetchKeys();
- },
- beforeDestroy() {
- eventHub.$off('enable.key', this.enableKey);
- eventHub.$off('remove.key', this.disableKey);
- eventHub.$off('disable.key', this.disableKey);
- },
};
</script>
@@ -84,6 +87,7 @@
<div v-else-if="hasKeys">
<keys-panel
title="Enabled deploy keys for this project"
+ class="qa-project-deploy-keys"
:keys="keys.enabled_keys"
:store="store"
:endpoint="endpoint"
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 904f7f64fa8..c6091efd62f 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -1,7 +1,15 @@
<script>
import actionBtn from './action_btn.vue';
+ import { getTimeago } from '../../lib/utils/datetime_utility';
+ import tooltip from '../../vue_shared/directives/tooltip';
export default {
+ components: {
+ actionBtn,
+ },
+ directives: {
+ tooltip,
+ },
props: {
deployKey: {
type: Object,
@@ -16,12 +24,9 @@
required: true,
},
},
- components: {
- actionBtn,
- },
computed: {
timeagoDate() {
- return gl.utils.getTimeago().format(this.deployKey.created_at);
+ return getTimeago().format(this.deployKey.created_at);
},
editDeployKeyPath() {
return `${this.endpoint}/${this.deployKey.id}/edit`;
@@ -31,6 +36,9 @@
isEnabled(id) {
return this.store.findEnabledKey(id) !== undefined;
},
+ tooltipTitle(project) {
+ return project.can_push ? 'Write access allowed' : 'Read access only';
+ },
},
};
</script>
@@ -45,26 +53,29 @@
</i>
</div>
<div class="deploy-key-content key-list-item-info">
- <strong class="title">
+ <strong class="title qa-key-title">
{{ deployKey.title }}
</strong>
- <div class="description">
+ <div class="description qa-key-fingerprint">
{{ deployKey.fingerprint }}
</div>
- <div
- v-if="deployKey.can_push"
- class="write-access-allowed"
- >
- Write access allowed
- </div>
</div>
<div class="deploy-key-content prepend-left-default deploy-key-projects">
<a
- v-for="project in deployKey.projects"
+ v-for="(deployKeysProject, i) in deployKey.deploy_keys_projects"
+ :key="i"
class="label deploy-project-label"
- :href="project.full_path"
+ :href="deployKeysProject.project.full_path"
+ :title="tooltipTitle(deployKeysProject)"
+ v-tooltip
>
- {{ project.full_name }}
+ {{ deployKeysProject.project.full_name }}
+ <i
+ v-if="!deployKeysProject.can_push"
+ aria-hidden="true"
+ class="fa fa-lock"
+ >
+ </i>
</a>
</div>
<div class="deploy-key-content">
@@ -73,7 +84,7 @@
</span>
<a
v-if="deployKey.can_edit"
- class="btn btn-small"
+ class="btn btn-sm"
:href="editDeployKeyPath"
>
Edit
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index 9e6fb244af6..822b0323156 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -2,6 +2,9 @@
import key from './key.vue';
export default {
+ components: {
+ key,
+ },
props: {
title: {
type: String,
@@ -25,9 +28,6 @@
required: true,
},
},
- components: {
- key,
- },
};
</script>
@@ -37,12 +37,14 @@
{{ title }}
({{ keys.length }})
</h5>
- <ul class="well-list"
+ <ul
+ class="well-list"
v-if="keys.length"
>
<li
v-for="deployKey in keys"
- :key="deployKey.id">
+ :key="deployKey.id"
+ >
<key
:deploy-key="deployKey"
:store="store"
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
index a5f232f950a..b727261648c 100644
--- a/app/assets/javascripts/deploy_keys/index.js
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -1,16 +1,16 @@
import Vue from 'vue';
import deployKeysApp from './components/app.vue';
-document.addEventListener('DOMContentLoaded', () => new Vue({
+export default () => new Vue({
el: document.getElementById('js-deploy-keys'),
+ components: {
+ deployKeysApp,
+ },
data() {
return {
endpoint: this.$options.el.dataset.endpoint,
};
},
- components: {
- deployKeysApp,
- },
render(createElement) {
return createElement('deploy-keys-app', {
props: {
@@ -18,4 +18,4 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
},
});
},
-}));
+});
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 725ec7b9c70..a044fc1ab42 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,26 +1,41 @@
-/* eslint-disable class-methods-use-this */
-
-import './lib/utils/url_utility';
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+import { getLocationHash } from './lib/utils/url_utility';
+import FilesCommentButton from './files_comment_button';
+import SingleFileDiff from './single_file_diff';
+import imageDiffHelper from './image_diff/helpers/index';
const UNFOLD_COUNT = 20;
let isBound = false;
-class Diff {
+export default class Diff {
constructor() {
const $diffFile = $('.files .diff-file');
- $diffFile.singleFileDiff();
- $diffFile.filesCommentButton();
- $diffFile.each((index, file) => new gl.ImageFile(file));
+ $diffFile.each((index, file) => {
+ if (!$.data(file, 'singleFileDiff')) {
+ $.data(file, 'singleFileDiff', new SingleFileDiff(file));
+ }
+ });
+
+ const tab = document.getElementById('diffs');
+ if (!tab || (tab && tab.dataset && tab.dataset.isLocked !== '')) FilesCommentButton.init($diffFile);
+
+ const firstFile = $('.files').first().get(0);
+ const canCreateNote = firstFile && firstFile.hasAttribute('data-can-create-note');
+ $diffFile.each((index, file) => imageDiffHelper.initImageDiff(file, canCreateNote));
if (!isBound) {
$(document)
.on('click', '.js-unfold', this.handleClickUnfold.bind(this))
- .on('click', '.diff-line-num a', this.handleClickLineNum.bind(this));
+ .on('click', '.diff-line-num a', this.handleClickLineNum.bind(this))
+ .on('mousedown', 'td.line_content.parallel', this.handleParallelLineDown.bind(this));
isBound = true;
}
- if (gl.utils.getLocationHash()) {
+ if (getLocationHash()) {
this.highlightSelectedLine();
}
@@ -54,15 +69,17 @@ class Diff {
}
const file = $target.parents('.diff-file');
- const link = file.data('blob-diff-path');
+ const link = file.data('blobDiffPath');
const view = file.data('view');
const params = { since, to, bottom, offset, unfold, view };
- $.get(link, params, response => $target.parent().replaceWith(response));
+ axios.get(link, { params })
+ .then(({ data }) => $target.parent().replaceWith(data))
+ .catch(() => flash(__('An error occurred while loading diff')));
}
openAnchoredDiff(cb) {
- const locationHash = gl.utils.getLocationHash();
+ const locationHash = getLocationHash();
const anchoredDiff = locationHash && locationHash.split('_')[0];
if (!anchoredDiff) return;
@@ -91,11 +108,23 @@ class Diff {
}
this.highlightSelectedLine();
}
+ // eslint-disable-next-line class-methods-use-this
+ handleParallelLineDown(e) {
+ const line = $(e.currentTarget);
+ const table = line.closest('table');
+
+ table.removeClass('left-side-selected right-side-selected');
+ const lineClass = ['left-side', 'right-side'].filter(name => line.hasClass(name))[0];
+ if (lineClass) {
+ table.addClass(`${lineClass}-selected`);
+ }
+ }
+ // eslint-disable-next-line class-methods-use-this
diffViewType() {
- return $('.inline-parallel-buttons a.active').data('view-type');
+ return $('.inline-parallel-buttons a.active').data('viewType');
}
-
+ // eslint-disable-next-line class-methods-use-this
lineNumbers(line) {
const children = line.find('.diff-line-num').toArray();
if (children.length !== 2) {
@@ -103,9 +132,9 @@ class Diff {
}
return children.map(elm => parseInt($(elm).data('linenumber'), 10) || 0);
}
-
+ // eslint-disable-next-line class-methods-use-this
highlightSelectedLine() {
- const hash = gl.utils.getLocationHash();
+ const hash = getLocationHash();
const $diffFiles = $('.diff-file');
$diffFiles.find('.hll').removeClass('hll');
@@ -116,6 +145,3 @@ class Diff {
}
}
}
-
-window.gl = window.gl || {};
-window.gl.Diff = Diff;
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index aed7cac4e62..d1260ff5373 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -1,6 +1,7 @@
/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, quotes, no-lonely-if, max-len */
/* global CommentsStore */
+import $ from 'jquery';
import Vue from 'vue';
const CommentAndResolveBtn = Vue.extend({
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 517bdb6be09..180a6bd67e7 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -1,8 +1,9 @@
/* global CommentsStore */
-/* global notes */
+import $ from 'jquery';
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
+import Notes from '../../notes';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const DiffNoteAvatars = Vue.extend({
@@ -21,11 +22,13 @@ const DiffNoteAvatars = Vue.extend({
},
template: `
<div class="diff-comment-avatar-holders"
+ :class="discussionClassName"
v-show="notesCount !== 0">
<div v-if="!isVisible">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image
v-for="note in notesSubset"
+ :key="note.id"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)"
:img-src="note.authorAvatar"
@@ -68,7 +71,8 @@ const DiffNoteAvatars = Vue.extend({
});
});
},
- destroyed() {
+ beforeDestroy() {
+ this.addNoCommentClass();
$(document).off('toggle.comments');
},
watch: {
@@ -85,6 +89,9 @@ const DiffNoteAvatars = Vue.extend({
},
},
computed: {
+ discussionClassName() {
+ return `js-diff-avatars-${this.discussionId}`;
+ },
notesSubset() {
let notes = [];
@@ -123,7 +130,7 @@ const DiffNoteAvatars = Vue.extend({
},
methods: {
clickedAvatar(e) {
- notes.onAddDiffNote(e);
+ Notes.instance.onAddDiffNote(e);
// Toggle the active state of the toggle all button
this.toggleDiscussionsToggleState();
@@ -139,9 +146,9 @@ const DiffNoteAvatars = Vue.extend({
const notesCount = this.notesCount;
$(this.$el).closest('.js-avatar-container')
- .toggleClass('js-no-comment-btn', notesCount > 0)
+ .toggleClass('no-comment-btn', notesCount > 0)
.nextUntil('.js-avatar-container')
- .toggleClass('js-no-comment-btn', notesCount > 0);
+ .toggleClass('no-comment-btn', notesCount > 0);
},
toggleDiscussionsToggleState() {
const $notesHolders = $(this.$el).closest('.code').find('.notes_holder');
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 37ddca29e71..8f9186dfb9a 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -2,8 +2,11 @@
/* global DiscussionMixins */
/* global CommentsStore */
+import $ from 'jquery';
import Vue from 'vue';
+import '../mixins/discussion';
+
const JumpToDiscussion = Vue.extend({
mixins: [DiscussionMixins],
props: {
@@ -94,7 +97,7 @@ const JumpToDiscussion = Vue.extend({
hasDiscussionsToJumpTo = false;
}
}
- } else if (activeTab !== 'notes') {
+ } else if (activeTab !== 'show') {
// If we are on the commits or builds tabs,
// there are no discussions to jump to.
hasDiscussionsToJumpTo = false;
@@ -103,12 +106,12 @@ const JumpToDiscussion = Vue.extend({
if (!hasDiscussionsToJumpTo) {
// If there are no discussions to jump to on the current page,
// switch to the notes tab and jump to the first disucssion there.
- window.mrTabs.activateTab('notes');
- activeTab = 'notes';
+ window.mrTabs.activateTab('show');
+ activeTab = 'show';
jumpToFirstDiscussion = true;
}
- if (activeTab === 'notes') {
+ if (activeTab === 'show') {
discussionsSelector = '.discussion[data-discussion-id]';
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
}
@@ -156,7 +159,7 @@ const JumpToDiscussion = Vue.extend({
let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
- if (activeTab === 'notes') {
+ if (activeTab === 'show') {
$target = $target.closest('.note-discussion');
// If the next discussion is closed, toggle it open.
@@ -169,7 +172,14 @@ const JumpToDiscussion = Vue.extend({
// When jumping between unresolved discussions on the diffs tab, we show them.
$target.closest(".content").show();
- $target = $target.closest("tr.notes_holder");
+ const $notesHolder = $target.closest("tr.notes_holder");
+
+ // Image diff discussions does not use notes_holder
+ // so we should keep original $target value in those cases
+ if ($notesHolder.length > 0) {
+ $target = $notesHolder;
+ }
+
$target.show();
// If we are on the diffs tab, we don't scroll to the discussion itself, but to
@@ -188,7 +198,7 @@ const JumpToDiscussion = Vue.extend({
}
$.scrollTo($target, {
- offset: 0
+ offset: -150
});
}
},
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index 9d51fb53eb2..df4c72ba0ed 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -1,9 +1,10 @@
-/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, no-new, max-len */
+/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */
/* global ResolveService */
-/* global Flash */
+import $ from 'jquery';
import Vue from 'vue';
+import Flash from '../../flash';
const ResolveBtn = Vue.extend({
props: {
@@ -64,8 +65,6 @@ const ResolveBtn = Vue.extend({
});
},
resolve: function () {
- const errorFlashMsg = 'An error occurred when trying to resolve a comment. Please try again.';
-
if (!this.canResolve) return;
let promise;
@@ -79,24 +78,21 @@ const ResolveBtn = Vue.extend({
.resolve(this.noteId);
}
- promise.then((response) => {
- this.loading = false;
+ promise
+ .then(resp => resp.json())
+ .then((data) => {
+ this.loading = false;
- if (response.status === 200) {
- const data = response.json();
const resolved_by = data ? data.resolved_by : null;
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
- } else {
- new Flash(errorFlashMsg);
- }
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
- this.updateTooltip();
- }).catch(() => {
- new Flash(errorFlashMsg);
- });
+ this.updateTooltip();
+ })
+ .catch(() => new Flash('An error occurred when trying to resolve a comment. Please try again.'));
}
},
mounted: function () {
diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js
index 96e5a440357..fe7cf8f5fc1 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_count.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_count.js
@@ -4,6 +4,8 @@
import Vue from 'vue';
+import '../mixins/discussion';
+
window.ResolveCount = Vue.extend({
mixins: [DiscussionMixins],
props: {
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index a2d33b0936e..e17daec6a92 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, comma-dangle, new-cap, no-new, max-len */
/* global ResolveCount */
+import $ from 'jquery';
import Vue from 'vue';
import './models/discussion';
import './models/note';
@@ -14,9 +15,11 @@ import './components/resolve_count';
import './components/resolve_discussion_btn';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
+import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
-$(() => {
- const projectPath = document.querySelector('.merge-request').dataset.projectPath;
+export default () => {
+ const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
+ const projectPath = projectPathHolder.dataset.projectPath;
const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
window.gl = window.gl || {};
@@ -32,6 +35,10 @@ $(() => {
const tmpApp = new tmp().$mount();
$(this).replaceWith(tmpApp.$el);
+ $(tmpApp.$el).one('remove.vue', () => {
+ tmpApp.$destroy();
+ tmpApp.$el.remove();
+ });
});
const $components = $(COMPONENT_SELECTOR).filter(function () {
@@ -42,6 +49,10 @@ $(() => {
$components.each(function () {
const $this = $(this);
const noteId = $this.attr(':note-id');
+ const discussionId = $this.attr(':discussion-id');
+
+ if ($this.is('comment-and-resolve-btn') && !discussionId) return;
+
const tmp = Vue.extend({
template: $this.get(0).outerHTML
});
@@ -58,12 +69,14 @@ $(() => {
gl.diffNotesCompileComponents();
- new Vue({
- el: '#resolve-count-app',
- components: {
- 'resolve-count': ResolveCount
- }
- });
+ if (!hasVueMRDiscussionsCookie()) {
+ new Vue({
+ el: '#resolve-count-app',
+ components: {
+ 'resolve-count': ResolveCount
+ },
+ });
+ }
$(window).trigger('resize.nav');
-});
+};
diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js
index dc43e4b2cc7..c97c559dd14 100644
--- a/app/assets/javascripts/diff_notes/models/discussion.js
+++ b/app/assets/javascripts/diff_notes/models/discussion.js
@@ -1,7 +1,9 @@
/* eslint-disable space-before-function-paren, camelcase, guard-for-in, no-restricted-syntax, no-unused-vars, max-len */
/* global NoteModel */
+import $ from 'jquery';
import Vue from 'vue';
+import { localTimeAgo } from '../../lib/utils/datetime_utility';
class DiscussionModel {
constructor (discussionId) {
@@ -71,7 +73,7 @@ class DiscussionModel {
$(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html);
}
- gl.utils.localTimeAgo($('.js-timeago', `${discussionSelector}`));
+ localTimeAgo($('.js-timeago', `${discussionSelector}`));
} else {
$discussionHeadline.remove();
}
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index 807ab11d292..d16f9297de1 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -1,16 +1,15 @@
-/* eslint-disable class-methods-use-this, one-var, camelcase, no-new, comma-dangle, no-param-reassign, max-len */
-/* global Flash */
/* global CommentsStore */
import Vue from 'vue';
+import Flash from '../../flash';
import '../../vue_shared/vue_resource_interceptor';
window.gl = window.gl || {};
class ResolveServiceClass {
constructor(root) {
- this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`);
- this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`);
+ this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
+ this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
}
resolve(noteId) {
@@ -32,27 +31,23 @@ class ResolveServiceClass {
promise = this.resolveAll(mergeRequestId, discussionId);
}
- promise.then((response) => {
- discussion.loading = false;
-
- if (response.status === 200) {
- const data = response.json();
- const resolved_by = data ? data.resolved_by : null;
+ promise
+ .then(resp => resp.json())
+ .then((data) => {
+ discussion.loading = false;
+ const resolvedBy = data ? data.resolved_by : null;
if (isResolved) {
discussion.unResolveAllNotes();
} else {
- discussion.resolveAllNotes(resolved_by);
+ discussion.resolveAllNotes(resolvedBy);
}
- gl.mrWidget.checkStatus();
+ if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
- } else {
- throw new Error('An error occurred when trying to resolve discussion.');
- }
- }).catch(() => {
- new Flash('An error occurred when trying to resolve a discussion. Please try again.');
- });
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
+ })
+ .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
}
resolveAll(mergeRequestId, discussionId) {
@@ -62,7 +57,7 @@ class ResolveServiceClass {
return this.discussionResource.save({
mergeRequestId,
- discussionId
+ discussionId,
}, {});
}
@@ -73,7 +68,7 @@ class ResolveServiceClass {
return this.discussionResource.delete({
mergeRequestId,
- discussionId
+ discussionId,
}, {});
}
}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 31a86090242..72f21f13860 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -1,537 +1,91 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */
-/* global UsernameValidator */
-/* global ActiveTabMemoizer */
-/* global ShortcutsNavigation */
-/* global IssuableIndex */
-/* global ShortcutsIssuable */
-/* global ZenMode */
-/* global Milestone */
-/* global IssuableForm */
-/* global LabelsSelect */
-/* global MilestoneSelect */
-/* global Commit */
-/* global NotificationsForm */
-/* global TreeView */
-/* global NotificationsDropdown */
-/* global GroupAvatar */
-/* global LineHighlighter */
-/* global ProjectFork */
-/* global BuildArtifacts */
-/* global GroupsSelect */
-/* global Search */
-/* global Admin */
-/* global NamespaceSelects */
-/* global Project */
-/* global ProjectAvatar */
-/* global CompareAutocomplete */
-/* global ProjectNew */
-/* global Star */
-/* global ProjectShow */
-/* global Labels */
-/* global Shortcuts */
-/* global Sidebar */
-/* 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';
-import BlobForkSuggestion from './blob/blob_fork_suggestion';
-import UserCallout from './user_callout';
-import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
-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';
+import $ from 'jquery';
+import Flash from './flash';
import GfmAutoComplete from './gfm_auto_complete';
-import ShortcutsBlob from './shortcuts_blob';
-import initSettingsPanels from './settings_panels';
-import initExperimentalFlags from './experimental_flags';
-
-(function() {
- var Dispatcher;
-
- $(function() {
- return new Dispatcher();
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
+import GlFieldErrors from './gl_field_errors';
+import Shortcuts from './shortcuts';
+import SearchAutocomplete from './search_autocomplete';
+
+function initSearch() {
+ // Only when search form is present
+ if ($('.search').length) {
+ return new SearchAutocomplete();
+ }
+}
+
+function initFieldErrors() {
+ $('.gl-show-field-errors').each((i, form) => {
+ new GlFieldErrors(form);
});
-
- Dispatcher = (function() {
- function Dispatcher() {
- this.initSearch();
- this.initFieldErrors();
- this.initPageScripts();
- }
-
- Dispatcher.prototype.initPageScripts = function() {
- var page, path, shortcut_handler, fileBlobPermalinkUrlElement, fileBlobPermalinkUrl;
- page = $('body').attr('data-page');
- if (!page) {
- return false;
- }
- path = page.split(':');
- shortcut_handler = null;
-
- $('.js-gfm-input').each((i, el) => {
- const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
- const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
- gfm.setup($(el), {
- emojis: true,
- members: enableGFM,
- issues: enableGFM,
- milestones: enableGFM,
- mergeRequests: enableGFM,
- labels: enableGFM,
- });
- });
-
- function initBlob() {
- new LineHighlighter();
-
- new BlobLinePermalinkUpdater(
- document.querySelector('#blob-content-holder'),
- '.diff-line-num[data-line-number]',
- document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
- );
-
- shortcut_handler = new ShortcutsNavigation();
- fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
- fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
- new ShortcutsBlob({
- skipResetBindings: true,
- fileBlobPermalinkUrl,
- });
-
- new BlobForkSuggestion({
- openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),
- forkButtons: document.querySelectorAll('.js-fork-suggestion-button'),
- cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'),
- suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
- actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
- })
- .init();
- }
-
- switch (page) {
- case 'profiles:preferences:show':
- initExperimentalFlags();
- break;
- case 'sessions:new':
- new UsernameValidator();
- new ActiveTabMemoizer();
- break;
- case 'projects:boards:show':
- case 'projects:boards:index':
- shortcut_handler = new ShortcutsNavigation();
- new UsersSelect();
- break;
- case 'projects:merge_requests:index':
- case 'projects:issues:index':
- if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
- const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
- filteredSearchManager.setup();
- }
- 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();
- shortcut_handler = new ShortcutsIssuable();
- new ZenMode();
- break;
- case 'projects:milestones:show':
- case 'groups:milestones:show':
- case 'dashboard:milestones:show':
- new Milestone();
- new Sidebar();
- break;
- case 'groups:issues':
- case 'groups:merge_requests':
- new UsersSelect();
- break;
- case 'dashboard:todos:index':
- new gl.Todos();
- break;
- case 'dashboard:projects:index':
- case 'dashboard:projects:starred':
- case 'explore:projects:index':
- case 'explore:projects:trending':
- case 'explore:projects:starred':
- case 'admin:projects:index':
- new ProjectsList();
- break;
- case 'explore:groups:index':
- new GroupsList();
-
- const landingElement = document.querySelector('.js-explore-groups-landing');
- if (!landingElement) break;
- const exploreGroupsLanding = new Landing(
- landingElement,
- landingElement.querySelector('.dismiss-button'),
- 'explore_groups_landing_dismissed',
- );
- exploreGroupsLanding.toggle();
- break;
- case 'projects:milestones:new':
- case 'projects:milestones:edit':
- case 'projects:milestones:update':
- case 'groups:milestones:new':
- case 'groups:milestones:edit':
- case 'groups:milestones:update':
- new ZenMode();
- new gl.DueDateSelectors();
- new gl.GLForm($('.milestone-form'), true);
- break;
- case 'projects:compare:show':
- new gl.Diff();
- break;
- case 'projects:branches:index':
- gl.AjaxLoadingSpinner.init();
- new DeleteModal();
- break;
- case 'projects:issues:new':
- case 'projects:issues:edit':
- shortcut_handler = new ShortcutsNavigation();
- new gl.GLForm($('.issue-form'), true);
- new IssuableForm($('.issue-form'));
- new LabelsSelect();
- new MilestoneSelect();
- new gl.IssuableTemplateSelectors();
- break;
- case 'projects:merge_requests:new':
- case 'projects:merge_requests:new_diffs':
- case 'projects:merge_requests:edit':
- new gl.Diff();
- shortcut_handler = new ShortcutsNavigation();
- new gl.GLForm($('.merge-request-form'), true);
- new IssuableForm($('.merge-request-form'));
- new LabelsSelect();
- new MilestoneSelect();
- new gl.IssuableTemplateSelectors();
- new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
- break;
- case 'projects:tags:new':
- new ZenMode();
- new gl.GLForm($('.tag-form'), true);
- 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':
- new gl.GLForm($('.snippet-form'), true);
- break;
- case 'snippets:new':
- case 'snippets:edit':
- case 'snippets:create':
- case 'snippets:update':
- new gl.GLForm($('.snippet-form'), false);
- break;
- case 'projects:releases:edit':
- new ZenMode();
- new gl.GLForm($('.release-form'), true);
- break;
- case 'projects:merge_requests:show':
- new gl.Diff();
- shortcut_handler = new ShortcutsIssuable(true);
- new ZenMode();
- break;
- case "projects:merge_requests:diffs":
- new gl.Diff();
- new ZenMode();
- break;
- 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();
- new ZenMode();
- shortcut_handler = new ShortcutsNavigation();
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- break;
- case 'projects:commit:pipelines':
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- break;
- case 'projects:commits:show':
- case 'projects:activity':
- shortcut_handler = new ShortcutsNavigation();
- break;
- case 'projects:show':
- shortcut_handler = new ShortcutsNavigation();
- new NotificationsForm();
- 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':
- case 'projects:pipelines:show':
- const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
- const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
-
- new Pipelines({
- initTabs: true,
- pipelineStatusUrl,
- tabsOptions: {
- action: controllerAction,
- defaultAction: 'pipelines',
- parentEl: '.pipelines-tabs',
- },
- });
- break;
- case 'groups:activity':
- new gl.Activities();
- break;
- case 'groups:show':
- shortcut_handler = new ShortcutsNavigation();
- new NotificationsForm();
- new NotificationsDropdown();
- new ProjectsList();
- break;
- case 'groups:group_members:index':
- new gl.MemberExpirationDate();
- new gl.Members();
- new UsersSelect();
- break;
- case 'projects:members:show':
- new gl.MemberExpirationDate('.js-access-expiration-date-groups');
- new GroupsSelect();
- new gl.MemberExpirationDate();
- new gl.Members();
- new UsersSelect();
- break;
- case 'groups:new':
- case 'admin:groups:new':
- case 'groups:create':
- case 'admin:groups:create':
- BindInOut.initAll();
- new Group();
- new GroupAvatar();
- break;
- case 'groups:edit':
- case 'admin:groups:edit':
- new GroupAvatar();
- break;
- case 'projects:tree:show':
- shortcut_handler = new ShortcutsNavigation();
- new TreeView();
- new BlobViewer();
- break;
- case 'projects:find_file:show':
- shortcut_handler = true;
- break;
- case 'projects:blob:show':
- new BlobViewer();
- initBlob();
- break;
- case 'projects:blame:show':
- initBlob();
- break;
- case 'groups:labels:new':
- case 'groups:labels:edit':
- case 'projects:labels:new':
- case 'projects:labels:edit':
- new Labels();
- break;
- case 'projects:labels:index':
- if ($('.prioritized-labels').length) {
- new gl.LabelManager();
- }
- break;
- case 'projects:network:show':
- // Ensure we don't create a particular shortcut handler here. This is
- // already created, where the network graph is created.
- shortcut_handler = true;
- break;
- case 'projects:forks:new':
- new ProjectFork();
- break;
- case 'projects:artifacts:browse':
- new ShortcutsNavigation();
- new BuildArtifacts();
- break;
- case 'projects:artifacts:file':
- new ShortcutsNavigation();
- new BlobViewer();
- break;
- case 'help:index':
- gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
- break;
- case 'search:show':
- new Search();
- break;
- case 'projects:repository:show':
- // Initialize Protected Branch Settings
- new gl.ProtectedBranchCreate();
- new gl.ProtectedBranchEditList();
- // Initialize Protected Tag Settings
- new ProtectedTagCreate();
- new ProtectedTagEditList();
- // Initialize expandable settings panels
- initSettingsPanels();
- break;
- case 'projects:ci_cd:show':
- new gl.ProjectVariables();
- break;
- case 'ci:lints:create':
- case 'ci:lints:show':
- new gl.CILintEditor();
- break;
- 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':
- case 'omniauth_callbacks':
- if (!gon.u2f) break;
- gl.u2fAuthenticate = new gl.U2FAuthenticate(
- $('#js-authenticate-u2f'),
- '#js-login-u2f-form',
- gon.u2f,
- document.querySelector('#js-login-2fa-device'),
- document.querySelector('.js-2fa-form'),
- );
- gl.u2fAuthenticate.start();
- case 'admin':
- new Admin();
- switch (path[1]) {
- case 'cohorts':
- new gl.UsagePing();
- break;
- case 'groups':
- new UsersSelect();
- break;
- case 'projects':
- new NamespaceSelects();
- break;
- case 'labels':
- switch (path[2]) {
- case 'new':
- case 'edit':
- new Labels();
- }
- case 'abuse_reports':
- new gl.AbuseReports();
- break;
- }
- break;
- case 'dashboard':
- case 'root':
- new UserCallout();
- break;
- case 'groups':
- new GroupName();
- break;
- case 'profiles':
- new NotificationsForm();
- new NotificationsDropdown();
- break;
- case 'projects':
- new Project();
- new ProjectAvatar();
- new GroupName();
- switch (path[1]) {
- case 'compare':
- new CompareAutocomplete();
- break;
- case 'edit':
- shortcut_handler = new ShortcutsNavigation();
- new ProjectNew();
- break;
- case 'new':
- new ProjectNew();
- break;
- case 'show':
- new Star();
- new ProjectNew();
- new ProjectShow();
- new NotificationsDropdown();
- break;
- case 'wikis':
- new gl.Wikis();
- shortcut_handler = new ShortcutsWiki();
- new ZenMode();
- new gl.GLForm($('.wiki-form'), true);
- break;
- case 'snippets':
- shortcut_handler = new ShortcutsNavigation();
- if (path[2] === 'show') {
- new ZenMode();
- new LineHighlighter();
- new BlobViewer();
- }
- break;
- case 'labels':
- case 'graphs':
- case 'compare':
- case 'pipelines':
- case 'forks':
- case 'milestones':
- case 'project_members':
- case 'deploy_keys':
- case 'builds':
- case 'hooks':
- case 'services':
- case 'protected_branches':
- shortcut_handler = new ShortcutsNavigation();
- }
- }
- // If we haven't installed a custom shortcut handler, install the default one
- if (!shortcut_handler) {
- new Shortcuts();
- }
- };
-
- Dispatcher.prototype.initSearch = function() {
- // Only when search form is present
- if ($('.search').length) {
- return new gl.SearchAutocomplete();
- }
- };
-
- Dispatcher.prototype.initFieldErrors = function() {
- $('.gl-show-field-errors').each((i, form) => {
- new gl.GlFieldErrors(form);
- });
- };
-
- return Dispatcher;
- })();
-}).call(window);
+}
+
+function initPageShortcuts(page) {
+ const pagesWithCustomShortcuts = [
+ 'projects:activity',
+ 'projects:artifacts:browse',
+ 'projects:artifacts:file',
+ 'projects:blame:show',
+ 'projects:blob:show',
+ 'projects:commit:show',
+ 'projects:commits:show',
+ 'projects:find_file:show',
+ 'projects:issues:edit',
+ 'projects:issues:index',
+ 'projects:issues:new',
+ 'projects:issues:show',
+ 'projects:merge_requests:creations:diffs',
+ 'projects:merge_requests:creations:new',
+ 'projects:merge_requests:edit',
+ 'projects:merge_requests:index',
+ 'projects:merge_requests:show',
+ 'projects:network:show',
+ 'projects:show',
+ 'projects:tree:show',
+ 'groups:show',
+ ];
+
+ if (pagesWithCustomShortcuts.indexOf(page) === -1) {
+ new Shortcuts();
+ }
+}
+
+function initGFMInput() {
+ $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
+ const gfm = new GfmAutoComplete(
+ gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
+ );
+ const enableGFM = convertPermissionToBoolean(
+ el.dataset.supportsAutocomplete,
+ );
+ gfm.setup($(el), {
+ emojis: true,
+ members: enableGFM,
+ issues: enableGFM,
+ milestones: enableGFM,
+ mergeRequests: enableGFM,
+ labels: enableGFM,
+ });
+ });
+}
+
+function initPerformanceBar() {
+ if (document.querySelector('#js-peek')) {
+ import('./performance_bar')
+ .then(m => new m.default({ container: '#js-peek' })) // eslint-disable-line new-cap
+ .catch(() => Flash('Error loading performance bar module'));
+ }
+}
+
+export default () => {
+ initSearch();
+ initFieldErrors();
+
+ const page = $('body').attr('data-page');
+ if (page) {
+ initPageShortcuts(page);
+ initGFMInput();
+ initPerformanceBar();
+ }
+};
diff --git a/app/assets/javascripts/docs/docs_bundle.js b/app/assets/javascripts/docs/docs_bundle.js
new file mode 100644
index 00000000000..897439f56b0
--- /dev/null
+++ b/app/assets/javascripts/docs/docs_bundle.js
@@ -0,0 +1,10 @@
+import Mousetrap from 'mousetrap';
+
+function addMousetrapClick(el, key) {
+ el.addEventListener('click', () => Mousetrap.trigger(key));
+}
+
+export default () => {
+ addMousetrapClick(document.querySelector('.js-trigger-shortcut'), '?');
+ addMousetrapClick(document.querySelector('.js-trigger-search-bar'), 's');
+};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index 70cd337fb8a..3cc316c3f3e 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -2,13 +2,17 @@ import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
class DropDown {
- constructor(list) {
+ constructor(list, config = { }) {
this.currentIndex = 0;
this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = [];
-
this.eventWrapper = {};
+ this.hideOnClick = config.hideOnClick !== false;
+
+ if (config.addActiveClassToDropdownButton) {
+ this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
+ }
this.getItems();
this.initTemplateString();
@@ -34,15 +38,17 @@ class DropDown {
clickEvent(e) {
if (e.target.tagName === 'UL') return;
- if (e.target.classList.contains(IGNORE_CLASS)) return;
+ if (e.target.closest(`.${IGNORE_CLASS}`)) return;
- const selected = utils.closest(e.target, 'LI');
+ const selected = e.target.closest('li');
if (!selected) return;
this.addSelectedClass(selected);
e.preventDefault();
- this.hide();
+ if (this.hideOnClick) {
+ this.hide();
+ }
const listEvent = new CustomEvent('click.dl', {
detail: {
@@ -67,7 +73,20 @@ class DropDown {
addEvents() {
this.eventWrapper.clickEvent = this.clickEvent.bind(this);
+ this.eventWrapper.closeDropdown = this.closeDropdown.bind(this);
+
this.list.addEventListener('click', this.eventWrapper.clickEvent);
+ this.list.addEventListener('keyup', this.eventWrapper.closeDropdown);
+ }
+
+ closeDropdown(event) {
+ // `ESC` key closes the dropdown.
+ if (event.keyCode === 27) {
+ event.preventDefault();
+ return this.toggle();
+ }
+
+ return true;
}
setData(data) {
@@ -85,6 +104,13 @@ class DropDown {
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join('');
+
+ const listEvent = new CustomEvent('render.dl', {
+ detail: {
+ list: this,
+ },
+ });
+ this.list.dispatchEvent(listEvent);
}
renderChildren(data) {
@@ -103,6 +129,8 @@ class DropDown {
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
+
+ if (this.dropdownToggle) this.dropdownToggle.classList.add('active');
}
hide() {
@@ -110,6 +138,8 @@ class DropDown {
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
+
+ if (this.dropdownToggle) this.dropdownToggle.classList.remove('active');
}
toggle() {
@@ -121,6 +151,7 @@ class DropDown {
destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
+ this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown);
}
static setImagesSrc(template) {
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
index cf78165b0d8..8a8dcde9f88 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/droplab/hook.js
@@ -3,7 +3,7 @@ import DropDown from './drop_down';
class Hook {
constructor(trigger, list, plugins, config) {
this.trigger = trigger;
- this.list = new DropDown(list);
+ this.list = new DropDown(list, config);
this.type = 'Hook';
this.event = 'click';
this.plugins = plugins || [];
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js
index c0da5866139..267b53fa4f2 100644
--- a/app/assets/javascripts/droplab/plugins/ajax.js
+++ b/app/assets/javascripts/droplab/plugins/ajax.js
@@ -11,6 +11,16 @@ const Ajax = {
if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data);
},
+ preprocessing: function preprocessing(config, data) {
+ let results = data;
+
+ if (config.preprocessing && !data.preprocessed) {
+ results = config.preprocessing(data);
+ AjaxCache.override(config.endpoint, results);
+ }
+
+ return results;
+ },
init: function init(hook) {
var self = this;
self.destroyed = false;
@@ -31,7 +41,8 @@ const Ajax = {
dynamicList.outerHTML = loadingTemplate.outerHTML;
}
- AjaxCache.retrieve(config.endpoint)
+ return AjaxCache.retrieve(config.endpoint)
+ .then(self.preprocessing.bind(null, config))
.then((data) => self._loadData(data, config, self))
.catch(config.onError);
},
diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/droplab/plugins/filter.js
index d6a1aadd49c..404d707cf7a 100644
--- a/app/assets/javascripts/droplab/plugins/filter.js
+++ b/app/assets/javascripts/droplab/plugins/filter.js
@@ -79,8 +79,6 @@ const Filter = {
this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
-
- this.debounceKeydown({ detail: { hook: this.hook } });
},
destroy: function destroy() {
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js
index 4da7344604e..bfe056a0fcc 100644
--- a/app/assets/javascripts/droplab/utils.js
+++ b/app/assets/javascripts/droplab/utils.js
@@ -30,7 +30,7 @@ const utils = {
},
isDropDownParts(target) {
- if (!target || target.tagName === 'HTML') return false;
+ if (!target || !target.hasAttribute || target.tagName === 'HTML') return false;
return target.hasAttribute(DATA_TRIGGER) || target.hasAttribute(DATA_DROPDOWN);
},
};
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 73675d300be..5528ad9f38d 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,302 +1,277 @@
-/* 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 */
-
+import $ from 'jquery';
+import Dropzone from 'dropzone';
+import _ from 'underscore';
import './preview_markdown';
+import csrf from './lib/utils/csrf';
+import axios from './lib/utils/axios_utils';
+
+Dropzone.autoDiscover = false;
+
+export default function dropzoneInput(form) {
+ const divHover = '<div class="div-dropzone-hover"></div>';
+ const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
+ const $attachButton = form.find('.button-attach-file');
+ const $attachingFileMessage = form.find('.attaching-file-message');
+ const $cancelButton = form.find('.button-cancel-uploading-files');
+ const $retryLink = form.find('.retry-uploading-link');
+ const $uploadProgress = form.find('.uploading-progress');
+ const $uploadingErrorContainer = form.find('.uploading-error-container');
+ const $uploadingErrorMessage = form.find('.uploading-error-message');
+ const $uploadingProgressContainer = form.find('.uploading-progress-container');
+ const uploadsPath = window.uploads_path || null;
+ const maxFileSize = gon.max_file_size || 10;
+ const formTextarea = form.find('.js-gfm-input');
+ let handlePaste;
+ let pasteText;
+ let addFileToForm;
+ let updateAttachingMessage;
+ let isImage;
+ let getFilename;
+ let uploadFile;
+
+ formTextarea.wrap('<div class="div-dropzone"></div>');
+ formTextarea.on('paste', event => handlePaste(event));
+
+ // Add dropzone area to the form.
+ const $mdArea = formTextarea.closest('.md-area');
+ form.setupMarkdownPreview();
+ const $formDropzone = form.find('.div-dropzone');
+ $formDropzone.parent().addClass('div-dropzone-wrapper');
+ $formDropzone.append(divHover);
+ $formDropzone.find('.div-dropzone-hover').append(iconPaperclip);
+
+ if (!uploadsPath) {
+ $formDropzone.addClass('js-invalid-dropzone');
+ return;
+ }
-window.DropzoneInput = (function() {
- function DropzoneInput(form) {
- 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;
- 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));
-
- // 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 = $formDropzone.dropzone({
- url: uploadsPath,
- dictDefaultMessage: '',
- clickable: true,
- paramName: 'file',
- maxFilesize: maxFileSize,
- uploadMultiple: false,
- headers: {
- 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
- },
- previewContainer: false,
- processing: function() {
- return $('.div-dropzone-alert').alert('close');
- },
- dragover: function() {
- $mdArea.addClass('is-dropzone-hover');
- form.find('.div-dropzone-hover').css('opacity', 0.7);
- },
- dragleave: function() {
- $mdArea.removeClass('is-dropzone-hover');
- form.find('.div-dropzone-hover').css('opacity', 0);
- },
- drop: function() {
- $mdArea.removeClass('is-dropzone-hover');
- 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(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) {
- 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');
- },
- removedfile: function() {
- $attachButton.removeClass('hide');
- $cancelButton.addClass('hide');
- $uploadingProgressContainer.addClass('hide');
- $uploadingErrorContainer.addClass('hide');
- },
- queuecomplete: function() {
- $('.dz-preview').remove();
- $('.markdown-area').trigger('input');
-
- $uploadingProgressContainer.addClass('hide');
- $cancelButton.addClass('hide');
- }
- });
-
- 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;
+ const dropzone = $formDropzone.dropzone({
+ url: uploadsPath,
+ dictDefaultMessage: '',
+ clickable: true,
+ paramName: 'file',
+ maxFilesize: maxFileSize,
+ uploadMultiple: false,
+ headers: csrf.headers,
+ previewContainer: false,
+ processing: () => $('.div-dropzone-alert').alert('close'),
+ dragover: () => {
+ $mdArea.addClass('is-dropzone-hover');
+ form.find('.div-dropzone-hover').css('opacity', 0.7);
+ },
+ dragleave: () => {
+ $mdArea.removeClass('is-dropzone-hover');
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ },
+ drop: () => {
+ $mdArea.removeClass('is-dropzone-hover');
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ formTextarea.focus();
+ },
+ success(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: (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;
- if (file.status === Dropzone.ERROR) {
- file.status = undefined;
- file.accepted = undefined;
- }
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ $attachButton.addClass('hide');
+ $cancelButton.addClass('hide');
+ },
+ totaluploadprogress(totalUploadProgress) {
+ updateAttachingMessage(this.files, $attachingFileMessage);
+ $uploadProgress.text(`${Math.round(totalUploadProgress)}%`);
+ },
+ sending: () => {
+ // 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');
+ },
+ removedfile: () => {
+ $attachButton.removeClass('hide');
+ $cancelButton.addClass('hide');
+ $uploadingProgressContainer.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
+ },
+ queuecomplete: () => {
+ $('.dz-preview').remove();
+ $('.markdown-area').trigger('input');
+
+ $uploadingProgressContainer.addClass('hide');
+ $cancelButton.addClass('hide');
+ },
+ });
+
+ const child = $(dropzone[0]).children('textarea');
+
+ // removeAllFiles(true) stops uploading files (if any)
+ // and remove them from dropzone files queue.
+ $cancelButton.on('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ Dropzone.forElement($formDropzone.get(0)).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('.js-main-target-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) => {
+ const file = failedFile;
+
+ if (file.status === Dropzone.ERROR) {
+ file.status = undefined;
+ file.accepted = undefined;
+ }
- return dropzoneInstance.addFile(file);
- });
+ return dropzoneInstance.addFile(file);
});
-
- handlePaste = function(event) {
- var filename, image, pasteEvent, text;
- pasteEvent = event.originalEvent;
- if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) {
- image = isImage(pasteEvent);
- if (image) {
- event.preventDefault();
- filename = getFilename(pasteEvent) || 'image.png';
- text = `{{${filename}}}`;
- pasteText(text);
- return uploadFile(image.getAsFile(), filename);
- }
+ });
+ // eslint-disable-next-line consistent-return
+ handlePaste = (event) => {
+ const pasteEvent = event.originalEvent;
+ if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) {
+ const image = isImage(pasteEvent);
+ if (image) {
+ event.preventDefault();
+ const filename = getFilename(pasteEvent) || 'image.png';
+ const 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) {
- return item;
- }
- i += 1;
+ }
+ };
+
+ isImage = (data) => {
+ let i = 0;
+ while (i < data.clipboardData.items.length) {
+ const item = data.clipboardData.items[i];
+ if (item.type.indexOf('image') !== -1) {
+ return item;
}
- return false;
- };
-
- pasteText = function(text, shouldPad) {
- var afterSelection, beforeSelection, caretEnd, caretStart, textEnd;
- var formattedText = text;
- if (shouldPad) formattedText += "\n\n";
- const textarea = child.get(0);
- caretStart = textarea.selectionStart;
- caretEnd = textarea.selectionEnd;
- textEnd = $(child).val().length;
- beforeSelection = $(child).val().substring(0, caretStart);
- afterSelection = $(child).val().substring(caretEnd, textEnd);
- $(child).val(beforeSelection + formattedText + afterSelection);
- textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
- textarea.style.height = `${textarea.scrollHeight}px`;
- 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');
- } else if (e.clipboardData && e.clipboardData.getData) {
- 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);
- return $.ajax({
- url: uploadsPath,
- type: 'POST',
- data: formData,
- dataType: 'json',
- processData: false,
- contentType: false,
- headers: {
- 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
- },
- beforeSend: function() {
- showSpinner();
- return closeAlertMessage();
- },
- success: function(e, textStatus, response) {
- return insertToTextArea(filename, response.responseJSON.link.markdown);
- },
- error: function(response) {
- return showError(response.responseJSON.message);
- },
- complete: function() {
- return closeSpinner();
- }
- });
- };
-
- 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);
+ i += 1;
+ }
+ return false;
+ };
+
+ pasteText = (text, shouldPad) => {
+ let formattedText = text;
+ if (shouldPad) {
+ formattedText += '\n\n';
+ }
+ const textarea = child.get(0);
+ const caretStart = textarea.selectionStart;
+ const caretEnd = textarea.selectionEnd;
+ const textEnd = $(child).val().length;
+ const beforeSelection = $(child).val().substring(0, caretStart);
+ const afterSelection = $(child).val().substring(caretEnd, textEnd);
+ $(child).val(beforeSelection + formattedText + afterSelection);
+ textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ formTextarea.get(0).dispatchEvent(new Event('input'));
+ return formTextarea.trigger('input');
+ };
+
+ addFileToForm = (path) => {
+ $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`);
+ };
+
+ getFilename = (e) => {
+ let value;
+ if (window.clipboardData && window.clipboardData.getData) {
+ value = window.clipboardData.getData('Text');
+ } else if (e.clipboardData && e.clipboardData.getData) {
+ value = e.clipboardData.getData('text/plain');
+ }
+ value = value.split('\r');
+ return value[0];
+ };
+
+ const showSpinner = () => $uploadingProgressContainer.removeClass('hide');
+
+ const closeSpinner = () => $uploadingProgressContainer.addClass('hide');
+
+ const showError = (message) => {
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ };
+
+ const closeAlertMessage = () => form.find('.div-dropzone-alert').alert('close');
+
+ const insertToTextArea = (filename, url) => {
+ const $child = $(child);
+ $child.val((index, val) => val.replace(`{{${filename}}}`, url));
+
+ $child.trigger('change');
+ };
+
+ uploadFile = (item, filename) => {
+ const formData = new FormData();
+ formData.append('file', item, filename);
+
+ showSpinner();
+ closeAlertMessage();
+
+ axios.post(uploadsPath, formData)
+ .then(({ data }) => {
+ const md = data.link.markdown;
+
+ insertToTextArea(filename, md);
+ closeSpinner();
+ })
+ .catch((e) => {
+ showError(e.response.data.message);
+ closeSpinner();
});
- };
-
- appendToTextArea = function(url) {
- return $(child).val(function(index, val) {
- return val + url + "\n";
- });
- };
-
- showSpinner = function(e) {
- return $uploadingProgressContainer.removeClass('hide');
- };
-
- closeSpinner = function() {
- return $uploadingProgressContainer.addClass('hide');
- };
-
- showError = function(message) {
- $uploadingErrorContainer.removeClass('hide');
- $uploadingErrorMessage.html(message);
- };
-
- closeAlertMessage = function() {
- return form.find('.div-dropzone-alert').alert('close');
- };
-
- form.find('.markdown-selector').click(function(e) {
- e.preventDefault();
- $(this).closest('.gfm-form').find('.div-dropzone').click();
- formTextarea.focus();
- });
- }
-
- return DropzoneInput;
-})();
+ };
+
+ updateAttachingMessage = (files, messageContainer) => {
+ let attachingMessage;
+ const filesCount = files.filter(file => 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);
+ };
+
+ form.find('.markdown-selector').click(function onMarkdownClick(e) {
+ e.preventDefault();
+ $(this).closest('.gfm-form').find('.div-dropzone').click();
+ formTextarea.focus();
+ });
+}
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index a8fc5b41fb4..842a4255f08 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -1,6 +1,9 @@
-/* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */
/* global dateFormat */
-/* global Pikaday */
+
+import $ from 'jquery';
+import Pikaday from 'pikaday';
+import axios from './lib/utils/axios_utils';
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
class DueDateSelect {
constructor({ $dropdown, $loading } = {}) {
@@ -15,9 +18,9 @@ class DueDateSelect {
this.$value = $block.find('.value');
this.$valueContent = $block.find('.value-content');
this.$sidebarValue = $('.js-due-date-sidebar-value', $block);
- this.fieldName = $dropdown.data('field-name'),
- this.abilityName = $dropdown.data('ability-name'),
- this.issueUpdateURL = $dropdown.data('issue-update');
+ this.fieldName = $dropdown.data('fieldName');
+ this.abilityName = $dropdown.data('abilityName');
+ this.issueUpdateURL = $dropdown.data('issueUpdate');
this.rawSelectedDate = null;
this.displayedDate = null;
@@ -37,21 +40,20 @@ class DueDateSelect {
hidden: () => {
this.$selectbox.hide();
this.$value.css('display', '');
- }
+ },
});
}
initDatePicker() {
const $dueDateInput = $(`input[name='${this.fieldName}']`);
-
const calendar = new Pikaday({
field: $dueDateInput.get(0),
theme: 'gitlab-theme',
format: 'yyyy-mm-dd',
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
onSelect: (dateText) => {
- const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd');
-
- $dueDateInput.val(formattedDate);
+ $dueDateInput.val(calendar.toString(dateText));
if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val();
@@ -59,10 +61,10 @@ class DueDateSelect {
} else {
this.saveDueDate(true);
}
- }
+ },
});
- calendar.setDate(new Date($dueDateInput.val()));
+ calendar.setDate(parsePikadayDate($dueDateInput.val()));
this.$datePicker.append(calendar.el);
this.$datePicker.data('pikaday', calendar);
}
@@ -78,8 +80,8 @@ class DueDateSelect {
gl.issueBoards.BoardsStore.detail.issue.dueDate = '';
this.updateIssueBoardIssue();
} else {
- $("input[name='" + this.fieldName + "']").val('');
- return this.saveDueDate(false);
+ $(`input[name='${this.fieldName}']`).val('');
+ this.saveDueDate(false);
}
});
}
@@ -110,7 +112,7 @@ class DueDateSelect {
this.datePayload = datePayload;
}
- updateIssueBoardIssue () {
+ updateIssueBoardIssue() {
this.$loading.fadeIn();
this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide();
@@ -125,59 +127,55 @@ class DueDateSelect {
}
submitSelectedDate(isDropdown) {
- return $.ajax({
- type: 'PUT',
- url: this.issueUpdateURL,
- data: this.datePayload,
- dataType: 'json',
- beforeSend: () => {
- const selectedDateValue = this.datePayload[this.abilityName].due_date;
- const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value';
+ const selectedDateValue = this.datePayload[this.abilityName].due_date;
+ const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value';
- this.$loading.removeClass('hidden').fadeIn();
+ this.$loading.removeClass('hidden').fadeIn();
- if (isDropdown) {
- this.$dropdown.trigger('loading.gl.dropdown');
- this.$selectbox.hide();
- }
+ if (isDropdown) {
+ this.$dropdown.trigger('loading.gl.dropdown');
+ this.$selectbox.hide();
+ }
- this.$value.css('display', '');
- this.$valueContent.html(`<span class='${displayedDateStyle}'>${this.displayedDate}</span>`);
- this.$sidebarValue.html(this.displayedDate);
+ this.$value.css('display', '');
+ this.$valueContent.html(`<span class='${displayedDateStyle}'>${this.displayedDate}</span>`);
+ this.$sidebarValue.html(this.displayedDate);
- return selectedDateValue.length ?
- $('.js-remove-due-date-holder').removeClass('hidden') :
- $('.js-remove-due-date-holder').addClass('hidden');
- }
- }).done((data) => {
- if (isDropdown) {
- this.$dropdown.trigger('loaded.gl.dropdown');
- this.$dropdown.dropdown('toggle');
- }
- return this.$loading.fadeOut();
- });
+ $('.js-remove-due-date-holder').toggleClass('hidden', selectedDateValue.length);
+
+ return axios.put(this.issueUpdateURL, this.datePayload)
+ .then(() => {
+ if (isDropdown) {
+ this.$dropdown.trigger('loaded.gl.dropdown');
+ this.$dropdown.dropdown('toggle');
+ }
+ return this.$loading.fadeOut();
+ });
}
}
-class DueDateSelectors {
+export default class DueDateSelectors {
constructor() {
this.initMilestoneDatePicker();
this.initIssuableSelect();
}
-
+ // eslint-disable-next-line class-methods-use-this
initMilestoneDatePicker() {
- $('.datepicker').each(function() {
+ $('.datepicker').each(function initPikadayMilestone() {
const $datePicker = $(this);
const calendar = new Pikaday({
field: $datePicker.get(0),
theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd',
container: $datePicker.parent().get(0),
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
onSelect(dateText) {
- $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
- }
+ $datePicker.val(calendar.toString(dateText));
+ },
});
- calendar.setDate(new Date($datePicker.val()));
+
+ calendar.setDate(parsePikadayDate($datePicker.val()));
$datePicker.data('pikaday', calendar);
});
@@ -188,19 +186,17 @@ class DueDateSelectors {
calendar.setDate(null);
});
}
-
+ // eslint-disable-next-line class-methods-use-this
initIssuableSelect() {
const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide();
$('.js-due-date-select').each((i, dropdown) => {
const $dropdown = $(dropdown);
+ // eslint-disable-next-line no-new
new DueDateSelect({
$dropdown,
- $loading
+ $loading,
});
});
}
}
-
-window.gl = window.gl || {};
-window.gl.DueDateSelectors = DueDateSelectors;
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
new file mode 100644
index 00000000000..dc7672560ea
--- /dev/null
+++ b/app/assets/javascripts/emoji/index.js
@@ -0,0 +1,100 @@
+import _ from 'underscore';
+import emojiMap from 'emojis/digests.json';
+import emojiAliases from 'emojis/aliases.json';
+
+export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+
+export function normalizeEmojiName(name) {
+ return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name;
+}
+
+export function isEmojiNameValid(name) {
+ return validEmojiNames.indexOf(name) >= 0;
+}
+
+export function filterEmojiNames(filter) {
+ const match = filter.toLowerCase();
+ return validEmojiNames.filter(name => name.indexOf(match) >= 0);
+}
+
+export function filterEmojiNamesByAlias(filter) {
+ return _.uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name)));
+}
+
+let emojiCategoryMap;
+export function getEmojiCategoryMap() {
+ if (!emojiCategoryMap) {
+ emojiCategoryMap = {
+ activity: [],
+ people: [],
+ nature: [],
+ food: [],
+ travel: [],
+ objects: [],
+ symbols: [],
+ flags: [],
+ };
+ Object.keys(emojiMap).forEach((name) => {
+ const emoji = emojiMap[name];
+ if (emojiCategoryMap[emoji.category]) {
+ emojiCategoryMap[emoji.category].push(name);
+ }
+ });
+ }
+ return emojiCategoryMap;
+}
+
+export function getEmojiInfo(query) {
+ let name = normalizeEmojiName(query);
+ let emojiInfo = emojiMap[name];
+
+ // Fallback to question mark for unknown emojis
+ if (!emojiInfo) {
+ name = 'grey_question';
+ emojiInfo = emojiMap[name];
+ }
+
+ return { ...emojiInfo, name };
+}
+
+export function emojiFallbackImageSrc(inputName) {
+ const { name, digest } = getEmojiInfo(inputName);
+ return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`;
+}
+
+export function emojiImageTag(name, src) {
+ return `<img class="emoji" title=":${name}:" alt=":${name}:" src="${src}" width="20" height="20" align="absmiddle" />`;
+}
+
+export function glEmojiTag(inputName, options) {
+ const opts = { sprite: false, forceFallback: false, ...options };
+ const { name, ...emojiInfo } = getEmojiInfo(inputName);
+
+ const fallbackImageSrc = emojiFallbackImageSrc(name);
+ const fallbackSpriteClass = `emoji-${name}`;
+
+ const classList = [];
+ if (opts.forceFallback && opts.sprite) {
+ classList.push('emoji-icon');
+ classList.push(fallbackSpriteClass);
+ }
+ const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
+ const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : '';
+ let contents = emojiInfo.moji;
+ if (opts.forceFallback && !opts.sprite) {
+ contents = emojiImageTag(name, fallbackImageSrc);
+ }
+
+ return `
+ <gl-emoji
+ ${classAttribute}
+ data-name="${name}"
+ data-fallback-src="${fallbackImageSrc}"
+ ${fallbackSpriteAttribute}
+ data-unicode-version="${emojiInfo.unicodeVersion}"
+ title="${emojiInfo.description}"
+ >
+ ${contents}
+ </gl-emoji>
+ `;
+}
diff --git a/app/assets/javascripts/emoji/support/index.js b/app/assets/javascripts/emoji/support/index.js
new file mode 100644
index 00000000000..1f7852dd487
--- /dev/null
+++ b/app/assets/javascripts/emoji/support/index.js
@@ -0,0 +1,10 @@
+import isEmojiUnicodeSupported from './is_emoji_unicode_supported';
+import getUnicodeSupportMap from './unicode_support_map';
+
+// cache browser support map between calls
+let browserUnicodeSupportMap;
+
+export default function isEmojiUnicodeSupportedByBrowser(emojiUnicode, unicodeVersion) {
+ browserUnicodeSupportMap = browserUnicodeSupportMap || getUnicodeSupportMap();
+ return isEmojiUnicodeSupported(browserUnicodeSupportMap, emojiUnicode, unicodeVersion);
+}
diff --git a/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js b/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
new file mode 100644
index 00000000000..e9defb62cf8
--- /dev/null
+++ b/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
@@ -0,0 +1,134 @@
+// On Windows, flags render as two-letter country codes, see http://emojipedia.org/flags/
+const flagACodePoint = 127462; // parseInt('1F1E6', 16)
+const flagZCodePoint = 127487; // parseInt('1F1FF', 16)
+function isFlagEmoji(emojiUnicode) {
+ const cp = emojiUnicode.codePointAt(0);
+ // Length 4 because flags are made of 2 characters which are surrogate pairs
+ return emojiUnicode.length === 4 && cp >= flagACodePoint && cp <= flagZCodePoint;
+}
+
+// Tested on mac OS 10.12.6 and Windows 10 FCU, it renders as two separate characters
+const baseFlagCodePoint = 127987; // parseInt('1F3F3', 16)
+const rainbowCodePoint = 127752; // parseInt('1F308', 16)
+function isRainbowFlagEmoji(emojiUnicode) {
+ const characters = Array.from(emojiUnicode);
+ // Length 4 because flags are made of 2 characters which are surrogate pairs
+ return emojiUnicode.length === 4 &&
+ characters[0].codePointAt(0) === baseFlagCodePoint &&
+ characters[1].codePointAt(0) === rainbowCodePoint;
+}
+
+// Chrome <57 renders keycaps oddly
+// See https://bugs.chromium.org/p/chromium/issues/detail?id=632294
+// Same issue on Windows also fixed in Chrome 57, http://i.imgur.com/rQF7woO.png
+function isKeycapEmoji(emojiUnicode) {
+ return emojiUnicode.length === 3 && emojiUnicode[2] === '\u20E3';
+}
+
+// Check for a skin tone variation emoji which aren't always supported
+const tone1 = 127995;// parseInt('1F3FB', 16)
+const tone5 = 127999;// parseInt('1F3FF', 16)
+function isSkinToneComboEmoji(emojiUnicode) {
+ return emojiUnicode.length > 2 && Array.from(emojiUnicode).some((char) => {
+ const cp = char.codePointAt(0);
+ return cp >= tone1 && cp <= tone5;
+ });
+}
+
+// macOS supports most skin tone emoji's but
+// doesn't support the skin tone versions of horse racing
+const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
+function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
+ const firstCharacter = Array.from(emojiUnicode)[0];
+ return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint &&
+ isSkinToneComboEmoji(emojiUnicode);
+}
+
+// Check for `family_*`, `kiss_*`, `couple_*`
+// For ex. Windows 8.1 Firefox 51.0.1, doesn't support these
+const zwj = 8205; // parseInt('200D', 16)
+const personStartCodePoint = 128102; // parseInt('1F466', 16)
+const personEndCodePoint = 128105; // parseInt('1F469', 16)
+function isPersonZwjEmoji(emojiUnicode) {
+ let hasPersonEmoji = false;
+ let hasZwj = false;
+ Array.from(emojiUnicode).forEach((character) => {
+ const cp = character.codePointAt(0);
+ if (cp === zwj) {
+ hasZwj = true;
+ } else if (cp >= personStartCodePoint && cp <= personEndCodePoint) {
+ hasPersonEmoji = true;
+ }
+ });
+
+ return hasPersonEmoji && hasZwj;
+}
+
+// Helper so we don't have to run `isFlagEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
+ const isFlagResult = isFlagEmoji(emojiUnicode);
+ const isRainbowFlagResult = isRainbowFlagEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.flag && isFlagResult) ||
+ (unicodeSupportMap.rainbowFlag && isRainbowFlagResult) ||
+ (!isFlagResult && !isRainbowFlagResult)
+ );
+}
+
+// Helper so we don't have to run `isSkinToneComboEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) {
+ const isSkinToneResult = isSkinToneComboEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.skinToneModifier && isSkinToneResult) ||
+ !isSkinToneResult
+ );
+}
+
+// Helper func so we don't have to run `isHorceRacingSkinToneComboEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) {
+ const isHorseRacingSkinToneResult = isHorceRacingSkinToneComboEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.horseRacing && isHorseRacingSkinToneResult) ||
+ !isHorseRacingSkinToneResult
+ );
+}
+
+// Helper so we don't have to run `isPersonZwjEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode) {
+ const isPersonZwjResult = isPersonZwjEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.personZwj && isPersonZwjResult) ||
+ !isPersonZwjResult
+ );
+}
+
+// Takes in a support map and determines whether
+// the given unicode emoji is supported on the platform.
+//
+// Combines all the edge case tests into a one-stop shop method
+function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVersion) {
+ const isOlderThanChrome57 = unicodeSupportMap.meta && unicodeSupportMap.meta.isChrome &&
+ unicodeSupportMap.meta.chromeVersion < 57;
+
+ // For comments about each scenario, see the comments above each individual respective function
+ return unicodeSupportMap[unicodeVersion] &&
+ !(isOlderThanChrome57 && isKeycapEmoji(emojiUnicode)) &&
+ checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) &&
+ checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) &&
+ checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) &&
+ checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode);
+}
+
+export {
+ isEmojiUnicodeSupported as default,
+ isFlagEmoji,
+ isRainbowFlagEmoji,
+ isKeycapEmoji,
+ isSkinToneComboEmoji,
+ isHorceRacingSkinToneComboEmoji,
+ isPersonZwjEmoji,
+};
diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js
new file mode 100644
index 00000000000..c18d07dad43
--- /dev/null
+++ b/app/assets/javascripts/emoji/support/unicode_support_map.js
@@ -0,0 +1,178 @@
+import AccessorUtilities from '../../lib/utils/accessor';
+
+const GL_EMOJI_VERSION = '0.2.0';
+
+const unicodeSupportTestMap = {
+ // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
+ // occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
+ // woman, biking (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
+ // sexZwj: '\u{1F6B4}\u{200D}\u{2640}',
+ // family_mwgb
+ // Windows 8.1, Firefox 51.0.1 does not support `family_`, `kiss_`, `couple_`
+ personZwj: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
+ // horse_racing_tone5
+ // Special case that is not supported on macOS 10.12 even though `skinToneModifier` succeeds
+ horseRacing: '\u{1F3C7}\u{1F3FF}',
+ // US flag, http://emojipedia.org/flags/
+ flag: '\u{1F1FA}\u{1F1F8}',
+ rainbowFlag: '\u{1F3F3}\u{1F308}',
+ // http://emojipedia.org/modifiers/
+ skinToneModifier: [
+ // spy_tone5
+ '\u{1F575}\u{1F3FF}',
+ // person_with_ball_tone5
+ '\u{26F9}\u{1F3FF}',
+ // angel_tone5
+ '\u{1F47C}\u{1F3FF}',
+ ],
+ // rofl, http://emojipedia.org/unicode-9.0/
+ '9.0': '\u{1F923}',
+ // metal, http://emojipedia.org/unicode-8.0/
+ '8.0': '\u{1F918}',
+ // spy, http://emojipedia.org/unicode-7.0/
+ '7.0': '\u{1F575}',
+ // expressionless, http://emojipedia.org/unicode-6.1/
+ 6.1: '\u{1F611}',
+ // japanese_goblin, http://emojipedia.org/unicode-6.0/
+ '6.0': '\u{1F47A}',
+ // sailboat, http://emojipedia.org/unicode-5.2/
+ 5.2: '\u{26F5}',
+ // mahjong, http://emojipedia.org/unicode-5.1/
+ 5.1: '\u{1F004}',
+ // gear, http://emojipedia.org/unicode-4.1/
+ 4.1: '\u{2699}',
+ // zap, http://emojipedia.org/unicode-4.0/
+ '4.0': '\u{26A1}',
+ // recycle, http://emojipedia.org/unicode-3.2/
+ 3.2: '\u{267B}',
+ // information_source, http://emojipedia.org/unicode-3.0/
+ '3.0': '\u{2139}',
+ // heart, http://emojipedia.org/unicode-1.1/
+ 1.1: '\u{2764}',
+};
+
+function checkPixelInImageDataArray(pixelOffset, imageDataArray) {
+ // `4 *` because RGBA
+ const indexOffset = 4 * pixelOffset;
+ const hasColor = imageDataArray[indexOffset + 0] ||
+ imageDataArray[indexOffset + 1] ||
+ imageDataArray[indexOffset + 2];
+ const isVisible = imageDataArray[indexOffset + 3];
+ // Check for some sort of color other than black
+ if (hasColor && isVisible) {
+ return true;
+ }
+ return false;
+}
+
+const chromeMatches = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
+const isChrome = chromeMatches && chromeMatches.length > 0;
+const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatches[1], 10);
+
+// We use 16px because mobile Safari (iOS 9.3) doesn't properly scale emojis :/
+// See 32px, https://i.imgur.com/htY6Zym.png
+// See 16px, https://i.imgur.com/FPPsIF8.png
+const fontSize = 16;
+function generateUnicodeSupportMap(testMap) {
+ const testMapKeys = Object.keys(testMap);
+ const numTestEntries = testMapKeys
+ .reduce((list, testKey) => list.concat(testMap[testKey]), []).length;
+
+ const canvas = document.createElement('canvas');
+ (window.gl || window).testEmojiUnicodeSupportMapCanvas = canvas;
+ const ctx = canvas.getContext('2d');
+ canvas.width = (2 * fontSize);
+ canvas.height = (numTestEntries * fontSize);
+ ctx.fillStyle = '#000000';
+ ctx.textBaseline = 'middle';
+ ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`;
+ // Write each emoji to the canvas vertically
+ let writeIndex = 0;
+ testMapKeys.forEach((testKey) => {
+ const testEntry = testMap[testKey];
+ [].concat(testEntry).forEach((emojiUnicode) => {
+ ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2));
+ writeIndex += 1;
+ });
+ });
+
+ // Read from the canvas
+ const resultMap = {};
+ let readIndex = 0;
+ testMapKeys.forEach((testKey) => {
+ const testEntry = testMap[testKey];
+ // This needs to be a `reduce` instead of `every` because we need to
+ // keep the `readIndex` in sync from the writes by running all entries
+ const isTestSatisfied = [].concat(testEntry).reduce((isSatisfied) => {
+ // Sample along the vertical-middle for a couple of characters
+ const imageData = ctx.getImageData(
+ 0,
+ (readIndex * fontSize) + (fontSize / 2),
+ 2 * fontSize,
+ 1,
+ ).data;
+
+ let isValidEmoji = false;
+ for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) {
+ const isLookingAtFirstChar = currentPixel < fontSize;
+ const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2));
+ // Check for the emoji somewhere along the row
+ if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) {
+ isValidEmoji = true;
+
+ // Check to see that nothing is rendered next to the first character
+ // to ensure that the ZWJ sequence rendered as one piece
+ } else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) {
+ isValidEmoji = false;
+ break;
+ }
+ }
+
+ readIndex += 1;
+ return isSatisfied && isValidEmoji;
+ }, true);
+
+ resultMap[testKey] = isTestSatisfied;
+ });
+
+ resultMap.meta = {
+ isChrome,
+ chromeVersion,
+ };
+
+ return resultMap;
+}
+
+export default function getUnicodeSupportMap() {
+ const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
+ let glEmojiVersionFromCache;
+ let userAgentFromCache;
+ if (isLocalStorageAvailable) {
+ glEmojiVersionFromCache = window.localStorage.getItem('gl-emoji-version');
+ userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+ }
+
+ let unicodeSupportMap;
+ try {
+ unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
+ } catch (err) {
+ // swallow
+ }
+
+ if (
+ !unicodeSupportMap ||
+ glEmojiVersionFromCache !== GL_EMOJI_VERSION ||
+ userAgentFromCache !== navigator.userAgent
+ ) {
+ unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap);
+
+ if (isLocalStorageAvailable) {
+ window.localStorage.setItem('gl-emoji-version', GL_EMOJI_VERSION);
+ window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
+ window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
+ }
+ }
+
+ return unicodeSupportMap;
+}
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
new file mode 100644
index 00000000000..dbee81fa320
--- /dev/null
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -0,0 +1,70 @@
+<script>
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tablePagination from '../../vue_shared/components/table_pagination.vue';
+ import environmentTable from '../components/environments_table.vue';
+
+ export default {
+ components: {
+ environmentTable,
+ loadingIcon,
+ tablePagination,
+ },
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ environments: {
+ type: Array,
+ required: true,
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ canCreateDeployment: {
+ type: Boolean,
+ required: true,
+ },
+ canReadEnvironment: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ onChangePage(page) {
+ this.$emit('onChangePage', page);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="environments-container">
+
+ <loading-icon
+ label="Loading environments"
+ v-if="isLoading"
+ size="3"
+ />
+
+ <slot name="emptyState"></slot>
+
+ <div
+ class="table-holder"
+ v-if="!isLoading && environments.length > 0">
+
+ <environment-table
+ :environments="environments"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
+
+ <table-pagination
+ v-if="pagination && pagination.totalPages > 1"
+ :change="onChangePage"
+ :page-info="pagination"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
new file mode 100644
index 00000000000..00e63c3467a
--- /dev/null
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -0,0 +1,44 @@
+<script>
+ export default {
+ name: 'EnvironmentsEmptyState',
+ props: {
+ newPath: {
+ type: String,
+ required: true,
+ },
+ canCreateEnvironment: {
+ type: Boolean,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+<template>
+ <div class="blank-state-row">
+ <div class="blank-state-center">
+ <h2 class="blank-state-title js-blank-state-title">
+ {{ s__("Environments|You don't have any environments right now.") }}
+ </h2>
+ <p class="blank-state-text">
+ {{ s__(`Environments|Environments are places where
+code gets deployed, such as staging or production.`) }}
+ <br />
+ <a :href="helpPath">
+ {{ s__("Environments|Read more about environments") }}
+ </a>
+ </p>
+
+ <a
+ v-if="canCreateEnvironment"
+ :href="newPath"
+ class="btn btn-create js-new-environment-button"
+ >
+ {{ s__("Environments|New environment") }}
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
deleted file mode 100644
index 8120ef182d4..00000000000
--- a/app/assets/javascripts/environments/components/environment.vue
+++ /dev/null
@@ -1,279 +0,0 @@
-<script>
-/* global Flash */
-import Visibility from 'visibilityjs';
-import EnvironmentsService from '../services/environments_service';
-import environmentTable from './environments_table.vue';
-import EnvironmentsStore from '../stores/environments_store';
-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: {
- environmentTable,
- tablePagination,
- loadingIcon,
- },
-
- mixins: [
- environmentsMixin,
- ],
-
- data() {
- const environmentsData = document.querySelector('#environments-list-view').dataset;
- const store = new EnvironmentsStore();
-
- return {
- store,
- state: store.state,
- visibility: 'available',
- isLoading: false,
- isLoadingFolderContent: false,
- cssContainerClass: environmentsData.cssClass,
- endpoint: environmentsData.environmentsDataEndpoint,
- canCreateDeployment: environmentsData.canCreateDeployment,
- canReadEnvironment: environmentsData.canReadEnvironment,
- canCreateEnvironment: environmentsData.canCreateEnvironment,
- projectEnvironmentsPath: environmentsData.projectEnvironmentsPath,
- projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
- newEnvironmentPath: environmentsData.newEnvironmentPath,
- helpPagePath: environmentsData.helpPagePath,
- isMakingRequest: false,
-
- // Pagination Properties,
- paginationInformation: {},
- pageNumber: 1,
- };
- },
-
- computed: {
- scope() {
- return gl.utils.getParameterByName('scope');
- },
-
- canReadEnvironmentParsed() {
- return gl.utils.convertPermissionToBoolean(this.canReadEnvironment);
- },
-
- canCreateDeploymentParsed() {
- return gl.utils.convertPermissionToBoolean(this.canCreateDeployment);
- },
-
- canCreateEnvironmentParsed() {
- return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment);
- },
- },
-
- /**
- * Fetches all the environments and stores them.
- * 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);
-
- 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('toggleFolder', this.toggleFolder);
- eventHub.$on('postAction', this.postAction);
- },
-
- beforeDestroy() {
- eventHub.$off('toggleFolder');
- eventHub.$off('postAction');
- },
-
- methods: {
- toggleFolder(folder, folderUrl) {
- this.store.toggleFolder(folder);
-
- if (!folder.isOpen) {
- this.fetchChildEnvironments(folder, folderUrl);
- }
- },
-
- /**
- * Will change the page number and update the URL.
- *
- * @param {Number} pageNumber desired page to go to.
- * @return {String}
- */
- changePage(pageNumber) {
- const param = gl.utils.setParamInURL('page', pageNumber);
-
- 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);
- },
-
- fetchChildEnvironments(folder, folderUrl) {
- this.isLoadingFolderContent = true;
-
- this.service.getFolderContent(folderUrl)
- .then(resp => resp.json())
- .then((response) => {
- this.store.setfolderContent(folder, response.environments);
- this.isLoadingFolderContent = false;
- })
- .catch(() => {
- this.isLoadingFolderContent = 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.'));
- }
- },
-
- 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.');
- },
- },
-};
-</script>
-<template>
- <div :class="cssContainerClass">
- <div class="top-area">
- <ul
- v-if="!isLoading"
- class="nav-links">
- <li :class="{ active: scope === null || scope === 'available' }">
- <a :href="projectEnvironmentsPath">
- Available
- <span class="badge js-available-environments-count">
- {{state.availableCounter}}
- </span>
- </a>
- </li>
- <li :class="{ active : scope === 'stopped' }">
- <a :href="projectStoppedEnvironmentsPath">
- Stopped
- <span class="badge js-stopped-environments-count">
- {{state.stoppedCounter}}
- </span>
- </a>
- </li>
- </ul>
- <div
- v-if="canCreateEnvironmentParsed && !isLoading"
- class="nav-controls">
- <a
- :href="newEnvironmentPath"
- class="btn btn-create">
- New environment
- </a>
- </div>
- </div>
-
- <div class="environments-container">
- <loading-icon
- label="Loading environments"
- size="3"
- v-if="isLoading"
- />
-
- <div
- class="blank-state blank-state-no-icon"
- v-if="!isLoading && state.environments.length === 0">
- <h2 class="blank-state-title js-blank-state-title">
- You don't have any environments right now.
- </h2>
- <p class="blank-state-text">
- Environments are places where code gets deployed, such as staging or production.
- <br />
- <a :href="helpPagePath">
- Read more about environments
- </a>
- </p>
-
- <a
- v-if="canCreateEnvironmentParsed"
- :href="newEnvironmentPath"
- class="btn btn-create js-new-environment-button">
- New environment
- </a>
- </div>
-
- <div
- class="table-holder"
- v-if="!isLoading && state.environments.length > 0">
-
- <environment-table
- :environments="state.environments"
- :can-create-deployment="canCreateDeploymentParsed"
- :can-read-environment="canReadEnvironmentParsed"
- :is-loading-folder-content="isLoadingFolderContent" />
- </div>
-
- <table-pagination
- v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
- :change="changePage"
- :pageInfo="state.paginationInformation" />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index e7495677e7c..16bd2f5feb3 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,55 +1,54 @@
<script>
-import playIconSvg from 'icons/_icon_play.svg';
-import eventHub from '../event_hub';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+ import playIconSvg from 'icons/_icon_play.svg';
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- actions: {
- type: Array,
- required: false,
- default: () => [],
+ export default {
+ directives: {
+ tooltip,
},
- },
- directives: {
- tooltip,
- },
-
- components: {
- loadingIcon,
- },
+ components: {
+ loadingIcon,
+ },
+ props: {
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
- data() {
- return {
- playIconSvg,
- isLoading: false,
- };
- },
+ data() {
+ return {
+ playIconSvg,
+ isLoading: false,
+ };
+ },
- computed: {
- title() {
- return 'Deploy to...';
+ computed: {
+ title() {
+ return 'Deploy to...';
+ },
},
- },
- methods: {
- onClickAction(endpoint) {
- this.isLoading = true;
+ methods: {
+ onClickAction(endpoint) {
+ this.isLoading = true;
- eventHub.$emit('postAction', endpoint);
- },
+ eventHub.$emit('postAction', endpoint);
+ },
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
+ isActionDisabled(action) {
+ if (action.playable === undefined) {
+ return false;
+ }
- return !action.playable;
+ return !action.playable;
+ },
},
- },
-};
+ };
</script>
<template>
<div
@@ -63,27 +62,33 @@ export default {
data-toggle="dropdown"
:title="title"
:aria-label="title"
- :disabled="isLoading">
+ :disabled="isLoading"
+ >
<span>
<span v-html="playIconSvg"></span>
<i
class="fa fa-caret-down"
- aria-hidden="true"/>
+ aria-hidden="true"
+ >
+ </i>
<loading-icon v-if="isLoading" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="action in actions">
+ <li
+ v-for="(action, i) in actions"
+ :key="i">
<button
type="button"
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
:class="{ disabled: isActionDisabled(action) }"
- :disabled="isActionDisabled(action)">
+ :disabled="isActionDisabled(action)"
+ >
<span v-html="playIconSvg"></span>
<span>
- {{action.name}}
+ {{ action.name }}
</span>
</button>
</li>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 6b749814ea4..c9a68cface6 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,27 +1,27 @@
<script>
-import tooltip from '../../vue_shared/directives/tooltip';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { s__ } from '../../locale';
-/**
- * Renders the external url link in environments table.
- */
-export default {
- props: {
- externalUrl: {
- type: String,
- required: true,
+ /**
+ * Renders the external url link in environments table.
+ */
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ externalUrl: {
+ type: String,
+ required: true,
+ },
},
- },
-
- directives: {
- tooltip,
- },
- computed: {
- title() {
- return 'Open';
+ computed: {
+ title() {
+ return s__('Environments|Open');
+ },
},
- },
-};
+ };
</script>
<template>
<a
@@ -32,9 +32,12 @@ export default {
rel="noopener noreferrer nofollow"
:title="title"
:aria-label="title"
- :href="externalUrl">
+ :href="externalUrl"
+ >
<i
class="fa fa-external-link"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index b25113e0fc6..79326ca3487 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,449 +1,458 @@
<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';
-import StopComponent from './environment_stop.vue';
-import RollbackComponent from './environment_rollback.vue';
-import TerminalButtonComponent from './environment_terminal_button.vue';
-import MonitoringButtonComponent from './environment_monitoring.vue';
-import CommitComponent from '../../vue_shared/components/commit.vue';
-import eventHub from '../event_hub';
-
-/**
- * Envrionment Item Component
- *
- * Renders a table row for each environment.
- */
-const timeagoInstance = new Timeago();
-
-export default {
- components: {
- userAvatarLink,
- 'commit-component': CommitComponent,
- 'actions-component': ActionsComponent,
- 'external-url-component': ExternalUrlComponent,
- 'stop-component': StopComponent,
- 'rollback-component': RollbackComponent,
- 'terminal-button-component': TerminalButtonComponent,
- 'monitoring-button-component': MonitoringButtonComponent,
- },
-
- props: {
- model: {
- type: Object,
- required: true,
- default: () => ({}),
- },
-
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
- },
-
- canReadEnvironment: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- /**
- * Verifies if `last_deployment` key exists in the current Envrionment.
- * This key is required to render most of the html - this method works has
- * an helper.
- *
- * @returns {Boolean}
- */
- hasLastDeploymentKey() {
- if (this.model &&
- this.model.last_deployment &&
- !_.isEmpty(this.model.last_deployment)) {
- return true;
- }
- return false;
- },
-
- /**
- * Verifies is the given environment has manual actions.
- * Used to verify if we should render them or nor.
- *
- * @returns {Boolean|Undefined}
- */
- hasManualActions() {
- return this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.manual_actions &&
- this.model.last_deployment.manual_actions.length > 0;
- },
-
- /**
- * Returns the value of the `stop_action?` key provided in the response.
- *
- * @returns {Boolean}
- */
- hasStopAction() {
- return this.model && this.model['stop_action?'];
- },
-
- /**
- * Verifies if the `deployable` key is present in `last_deployment` key.
- * Used to verify whether we should or not render the rollback partial.
- *
- * @returns {Boolean|Undefined}
- */
- canRetry() {
- return this.model &&
- this.hasLastDeploymentKey &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable;
- },
-
- /**
- * Verifies if the date to be shown is present.
- *
- * @returns {Boolean|Undefined}
- */
- canShowDate() {
- return this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable !== undefined;
- },
-
- /**
- * Human readable date.
- *
- * @returns {String}
- */
- createdDate() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.created_at) {
- return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
- }
- return '';
- },
-
- /**
- * Returns the manual actions with the name parsed.
- *
- * @returns {Array.<Object>|Undefined}
- */
- manualActions() {
- if (this.hasManualActions) {
- return this.model.last_deployment.manual_actions.map((action) => {
- const parsedAction = {
- name: gl.text.humanize(action.name),
- play_path: action.play_path,
- playable: action.playable,
- };
- return parsedAction;
- });
- }
- return [];
- },
-
- /**
- * Builds the string used in the user image alt attribute.
- *
- * @returns {String}
- */
- userImageAltDescription() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.user &&
- this.model.last_deployment.user.username) {
- return `${this.model.last_deployment.user.username}'s avatar'`;
- }
- return '';
- },
-
- /**
- * If provided, returns the commit tag.
- *
- * @returns {String|Undefined}
- */
- commitTag() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.tag) {
- return this.model.last_deployment.tag;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit ref.
- *
- * @returns {Object|Undefined}
- */
- commitRef() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.ref) {
- return this.model.last_deployment.ref;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit url.
- *
- * @returns {String|Undefined}
- */
- commitUrl() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.commit_path) {
- return this.model.last_deployment.commit.commit_path;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit short sha.
- *
- * @returns {String|Undefined}
- */
- commitShortSha() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.short_id) {
- return this.model.last_deployment.commit.short_id;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit title.
- *
- * @returns {String|Undefined}
- */
- commitTitle() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.title) {
- return this.model.last_deployment.commit.title;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit tag.
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.author) {
- return this.model.last_deployment.commit.author;
- }
-
- return undefined;
- },
-
- /**
- * Verifies if the `retry_path` key is present and returns its value.
- *
- * @returns {String|Undefined}
- */
- retryUrl() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.retry_path) {
- return this.model.last_deployment.deployable.retry_path;
- }
- return undefined;
- },
-
- /**
- * Verifies if the `last?` key is present and returns its value.
- *
- * @returns {Boolean|Undefined}
- */
- isLastDeployment() {
- return this.model && this.model.last_deployment &&
- this.model.last_deployment['last?'];
- },
-
- /**
- * Builds the name of the builds needed to display both the name and the id.
- *
- * @returns {String}
- */
- buildName() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable) {
- return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`;
- }
- return '';
- },
-
- /**
- * Builds the needed string to show the internal id.
- *
- * @returns {String}
- */
- deploymentInternalId() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.iid) {
- return `#${this.model.last_deployment.iid}`;
- }
- return '';
- },
-
- /**
- * Verifies if the user object is present under last_deployment object.
- *
- * @returns {Boolean}
- */
- deploymentHasUser() {
- return this.model &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.user);
- },
-
- /**
- * Returns the user object nested with the last_deployment object.
- * Used to render the template.
- *
- * @returns {Object}
- */
- deploymentUser() {
- if (this.model &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.user)) {
- return this.model.last_deployment.user;
- }
- return {};
- },
-
- /**
- * Verifies if the build name column should be rendered by verifing
- * if all the information needed is present
- * and if the environment is not a folder.
- *
- * @returns {Boolean}
- */
- shouldRenderBuildName() {
- return !this.model.isFolder &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.deployable);
- },
-
- /**
- * Verifies the presence of all the keys needed to render the buil_path.
- *
- * @return {String}
- */
- buildPath() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.build_path) {
- return this.model.last_deployment.deployable.build_path;
- }
-
- return '';
- },
-
- /**
- * Verifies the presence of all the keys needed to render the external_url.
- *
- * @return {String}
- */
- externalURL() {
- if (this.model && this.model.external_url) {
- return this.model.external_url;
- }
-
- return '';
- },
-
- /**
- * Verifies if deplyment internal ID should be rendered by verifing
- * if all the information needed is present
- * and if the environment is not a folder.
- *
- * @returns {Boolean}
- */
- shouldRenderDeploymentID() {
- return !this.model.isFolder &&
- !_.isEmpty(this.model.last_deployment) &&
- this.model.last_deployment.iid !== undefined;
- },
-
- environmentPath() {
- if (this.model && this.model.environment_path) {
- return this.model.environment_path;
- }
-
- return '';
- },
-
- monitoringUrl() {
- if (this.model && this.model.metrics_path) {
- return this.model.metrics_path;
- }
-
- return '';
- },
-
- displayEnvironmentActions() {
- return this.hasManualActions ||
- this.externalURL ||
- this.monitoringUrl ||
- this.hasStopAction ||
- this.canRetry;
- },
-
- /**
- * Constructs folder URL based on the current location and the folder id.
- *
- * @return {String}
- */
- folderUrl() {
- return `${window.location.pathname}/folders/${this.model.folderName}`;
- },
- },
-
- methods: {
- onClickFolder() {
- eventHub.$emit('toggleFolder', this.model, this.folderUrl);
- },
- },
-};
+ import Timeago from 'timeago.js';
+ import _ from 'underscore';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+ import { humanize } from '~/lib/utils/text_utility';
+ import ActionsComponent from './environment_actions.vue';
+ import ExternalUrlComponent from './environment_external_url.vue';
+ import StopComponent from './environment_stop.vue';
+ import RollbackComponent from './environment_rollback.vue';
+ import TerminalButtonComponent from './environment_terminal_button.vue';
+ import MonitoringButtonComponent from './environment_monitoring.vue';
+ import CommitComponent from '../../vue_shared/components/commit.vue';
+ import eventHub from '../event_hub';
+
+ /**
+ * Envrionment Item Component
+ *
+ * Renders a table row for each environment.
+ */
+ const timeagoInstance = new Timeago();
+
+ export default {
+ components: {
+ UserAvatarLink,
+ CommitComponent,
+ ActionsComponent,
+ ExternalUrlComponent,
+ StopComponent,
+ RollbackComponent,
+ TerminalButtonComponent,
+ MonitoringButtonComponent,
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ props: {
+ model: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+
+ canCreateDeployment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ canReadEnvironment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ /**
+ * Verifies if `last_deployment` key exists in the current Envrionment.
+ * This key is required to render most of the html - this method works has
+ * an helper.
+ *
+ * @returns {Boolean}
+ */
+ hasLastDeploymentKey() {
+ if (this.model &&
+ this.model.last_deployment &&
+ !_.isEmpty(this.model.last_deployment)) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Verifies is the given environment has manual actions.
+ * Used to verify if we should render them or nor.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ hasManualActions() {
+ return this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.manual_actions &&
+ this.model.last_deployment.manual_actions.length > 0;
+ },
+
+ /**
+ * Returns the value of the `stop_action?` key provided in the response.
+ *
+ * @returns {Boolean}
+ */
+ hasStopAction() {
+ return this.model && this.model['stop_action?'];
+ },
+
+ /**
+ * Verifies if the `deployable` key is present in `last_deployment` key.
+ * Used to verify whether we should or not render the rollback partial.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canRetry() {
+ return this.model &&
+ this.hasLastDeploymentKey &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable;
+ },
+
+ /**
+ * Verifies if the date to be shown is present.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canShowDate() {
+ return this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable !== undefined;
+ },
+
+ /**
+ * Human readable date.
+ *
+ * @returns {String}
+ */
+ createdDate() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.created_at) {
+ return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
+ }
+ return '';
+ },
+
+ /**
+ * Returns the manual actions with the name parsed.
+ *
+ * @returns {Array.<Object>|Undefined}
+ */
+ manualActions() {
+ if (this.hasManualActions) {
+ return this.model.last_deployment.manual_actions.map((action) => {
+ const parsedAction = {
+ name: humanize(action.name),
+ play_path: action.play_path,
+ playable: action.playable,
+ };
+ return parsedAction;
+ });
+ }
+ return [];
+ },
+
+ /**
+ * Builds the string used in the user image alt attribute.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.user &&
+ this.model.last_deployment.user.username) {
+ return `${this.model.last_deployment.user.username}'s avatar'`;
+ }
+ return '';
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.tag) {
+ return this.model.last_deployment.tag;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit ref.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.ref) {
+ return this.model.last_deployment.ref;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit url.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.commit_path) {
+ return this.model.last_deployment.commit.commit_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit short sha.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.short_id) {
+ return this.model.last_deployment.commit.short_id;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit title.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.title) {
+ return this.model.last_deployment.commit.title;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.author) {
+ return this.model.last_deployment.commit.author;
+ }
+
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `retry_path` key is present and returns its value.
+ *
+ * @returns {String|Undefined}
+ */
+ retryUrl() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.retry_path) {
+ return this.model.last_deployment.deployable.retry_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `last?` key is present and returns its value.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ isLastDeployment() {
+ return this.model && this.model.last_deployment &&
+ this.model.last_deployment['last?'];
+ },
+
+ /**
+ * Builds the name of the builds needed to display both the name and the id.
+ *
+ * @returns {String}
+ */
+ buildName() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable) {
+ const deployable = this.model.last_deployment.deployable;
+ return `${deployable.name} #${deployable.id}`;
+ }
+ return '';
+ },
+
+ /**
+ * Builds the needed string to show the internal id.
+ *
+ * @returns {String}
+ */
+ deploymentInternalId() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.iid) {
+ return `#${this.model.last_deployment.iid}`;
+ }
+ return '';
+ },
+
+ /**
+ * Verifies if the user object is present under last_deployment object.
+ *
+ * @returns {Boolean}
+ */
+ deploymentHasUser() {
+ return this.model &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user);
+ },
+
+ /**
+ * Returns the user object nested with the last_deployment object.
+ * Used to render the template.
+ *
+ * @returns {Object}
+ */
+ deploymentUser() {
+ if (this.model &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)) {
+ return this.model.last_deployment.user;
+ }
+ return {};
+ },
+
+ /**
+ * Verifies if the build name column should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderBuildName() {
+ return !this.model.isFolder &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.deployable);
+ },
+
+ /**
+ * Verifies the presence of all the keys needed to render the buil_path.
+ *
+ * @return {String}
+ */
+ buildPath() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.build_path) {
+ return this.model.last_deployment.deployable.build_path;
+ }
+
+ return '';
+ },
+
+ /**
+ * Verifies the presence of all the keys needed to render the external_url.
+ *
+ * @return {String}
+ */
+ externalURL() {
+ if (this.model && this.model.external_url) {
+ return this.model.external_url;
+ }
+
+ return '';
+ },
+
+ /**
+ * Verifies if deplyment internal ID should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderDeploymentID() {
+ return !this.model.isFolder &&
+ !_.isEmpty(this.model.last_deployment) &&
+ this.model.last_deployment.iid !== undefined;
+ },
+
+ environmentPath() {
+ if (this.model && this.model.environment_path) {
+ return this.model.environment_path;
+ }
+
+ return '';
+ },
+
+ monitoringUrl() {
+ if (this.model && this.model.metrics_path) {
+ return this.model.metrics_path;
+ }
+
+ return '';
+ },
+
+ displayEnvironmentActions() {
+ return this.hasManualActions ||
+ this.externalURL ||
+ this.monitoringUrl ||
+ this.hasStopAction ||
+ this.canRetry;
+ },
+ },
+
+ methods: {
+ onClickFolder() {
+ eventHub.$emit('toggleFolder', this.model);
+ },
+ },
+ };
</script>
<template>
<div
- :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }"
+ class="gl-responsive-table-row"
+ :class="{
+ 'js-child-row environment-child-row': model.isChildren,
+ 'folder-row': model.isFolder,
+ }"
role="row">
- <div class="table-section section-10" role="gridcell">
+ <div
+ class="table-section section-10"
+ role="gridcell"
+ >
<div
v-if="!model.isFolder"
class="table-mobile-header"
- role="rowheader">
- Environment
+ role="rowheader"
+ >
+ {{ s__("Environments|Environment") }}
</div>
<a
v-if="!model.isFolder"
class="environment-name flex-truncate-parent table-mobile-content"
:href="environmentPath">
- <span class="flex-truncate-child">{{model.name}}</span>
+ <span
+ class="flex-truncate-child"
+ v-tooltip
+ :title="model.name"
+ >{{ model.name }}</span>
</a>
<span
v-else
@@ -455,32 +464,40 @@ export default {
<i
v-show="model.isOpen"
class="fa fa-caret-down"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
<i
v-show="!model.isOpen"
class="fa fa-caret-right"
- aria-hidden="true"/>
+ aria-hidden="true"
+ >
+ </i>
</span>
<span class="folder-icon">
<i
class="fa fa-folder"
- aria-hidden="true" />
+ aria-hidden="true">
+ </i>
</span>
<span>
- {{model.folderName}}
+ {{ model.folderName }}
</span>
<span class="badge">
- {{model.size}}
+ {{ model.size }}
</span>
</span>
</div>
- <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell">
+ <div
+ class="table-section section-10 deployment-column hidden-xs hidden-sm"
+ role="gridcell"
+ >
<span v-if="shouldRenderDeploymentID">
- {{deploymentInternalId}}
+ {{ deploymentInternalId }}
</span>
<span v-if="!model.isFolder && deploymentHasUser">
@@ -495,24 +512,32 @@ export default {
</span>
</div>
- <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell">
+ <div
+ class="table-section section-15 hidden-xs hidden-sm"
+ role="gridcell"
+ >
<a
v-if="shouldRenderBuildName"
- class="build-link"
- :href="buildPath">
- {{buildName}}
+ class="build-link flex-truncate-parent"
+ :href="buildPath"
+ >
+ <span class="flex-truncate-child">{{ buildName }}</span>
</a>
</div>
- <div class="table-section section-25" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-section section-25"
+ role="gridcell"
+ >
<div
- v-if="!model.isFolder"
role="rowheader"
- class="table-mobile-header">
- Commit
+ class="table-mobile-header"
+ >
+ {{ s__("Environments|Commit") }}
</div>
<div
- v-if="!model.isFolder && hasLastDeploymentKey"
+ v-if="hasLastDeploymentKey"
class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
@@ -523,23 +548,26 @@ export default {
:author="commitAuthor"/>
</div>
<div
- v-if="!model.isFolder && !hasLastDeploymentKey"
+ v-if="!hasLastDeploymentKey"
class="commit-title table-mobile-content">
- No deployments yet
+ {{ s__("Environments|No deployments yet") }}
</div>
</div>
- <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-section section-10"
+ role="gridcell"
+ >
<div
- v-if="!model.isFolder"
role="rowheader"
class="table-mobile-header">
- Updated
+ {{ s__("Environments|Updated") }}
</div>
<span
- v-if="!model.isFolder && canShowDate"
+ v-if="canShowDate"
class="environment-created-date-timeago table-mobile-content">
- {{createdDate}}
+ {{ createdDate }}
</span>
</div>
@@ -555,33 +583,33 @@ export default {
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
- />
+ />
<external-url-component
v-if="externalURL && canReadEnvironment"
:external-url="externalURL"
- />
+ />
<monitoring-button-component
v-if="monitoringUrl && canReadEnvironment"
:monitoring-url="monitoringUrl"
- />
+ />
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
- />
+ />
<stop-component
v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path"
- />
+ />
<rollback-component
v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
- />
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 1655561cdd3..081537cf218 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,27 +1,27 @@
<script>
-/**
- * Renders the Monitoring (Metrics) link in environments table.
- */
-import tooltip from '../../vue_shared/directives/tooltip';
+ /**
+ * Renders the Monitoring (Metrics) link in environments table.
+ */
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- monitoringUrl: {
- type: String,
- required: true,
+ export default {
+ directives: {
+ tooltip,
},
- },
- directives: {
- tooltip,
- },
+ props: {
+ monitoringUrl: {
+ type: String,
+ required: true,
+ },
+ },
- computed: {
- title() {
- return 'Monitoring';
+ computed: {
+ title() {
+ return 'Monitoring';
+ },
},
- },
-};
+ };
</script>
<template>
<a
@@ -31,9 +31,12 @@ export default {
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
:title="title"
- :aria-label="title">
+ :aria-label="title"
+ >
<i
class="fa fa-area-chart"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 49dba38edfb..605a88e997e 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -1,57 +1,58 @@
<script>
-/**
- * Renders Rollback or Re deploy button in environments table depending
- * of the provided property `isLastDeployment`.
- *
- * 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: {
- retryUrl: {
- type: String,
- default: '',
+ /**
+ * Renders Rollback or Re deploy button in environments table depending
+ * of the provided property `isLastDeployment`.
+ *
+ * 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 {
+ components: {
+ loadingIcon,
},
- isLastDeployment: {
- type: Boolean,
- default: true,
- },
- },
+ props: {
+ retryUrl: {
+ type: String,
+ default: '',
+ },
- components: {
- loadingIcon,
- },
+ isLastDeployment: {
+ type: Boolean,
+ default: true,
+ },
+ },
- data() {
- return {
- isLoading: false,
- };
- },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
- methods: {
- onClick() {
- this.isLoading = true;
+ methods: {
+ onClick() {
+ this.isLoading = true;
- eventHub.$emit('postAction', this.retryUrl);
+ eventHub.$emit('postAction', this.retryUrl);
+ },
},
- },
-};
+ };
</script>
<template>
<button
type="button"
class="btn hidden-xs hidden-sm"
@click="onClick"
- :disabled="isLoading">
+ :disabled="isLoading"
+ >
<span v-if="isLastDeployment">
- Re-deploy
+ {{ s__("Environments|Re-deploy") }}
</span>
<span v-else>
- Rollback
+ {{ s__("Environments|Rollback") }}
</span>
<loading-icon v-if="isLoading" />
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index 85f11d2071b..dda7429a726 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -1,53 +1,55 @@
<script>
-/**
- * Renders the stop "button" that allows stop an environment.
- * Used in environments table.
- */
-import eventHub from '../event_hub';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+ /**
+ * Renders the stop "button" that allows stop an environment.
+ * Used in environments table.
+ */
-export default {
- props: {
- stopUrl: {
- type: String,
- default: '',
+ import $ from 'jquery';
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ components: {
+ loadingIcon,
},
- },
- directives: {
- tooltip,
- },
+ directives: {
+ tooltip,
+ },
- data() {
- return {
- isLoading: false,
- };
- },
+ props: {
+ stopUrl: {
+ type: String,
+ default: '',
+ },
+ },
- components: {
- loadingIcon,
- },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
- computed: {
- title() {
- return 'Stop';
+ computed: {
+ title() {
+ return 'Stop';
+ },
},
- },
- methods: {
- onClick() {
- // eslint-disable-next-line no-alert
- if (confirm('Are you sure you want to stop this environment?')) {
- this.isLoading = true;
+ methods: {
+ onClick() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Are you sure you want to stop this environment?')) {
+ this.isLoading = true;
- $(this.$el).tooltip('destroy');
+ $(this.$el).tooltip('destroy');
- eventHub.$emit('postAction', this.stopUrl);
- }
+ eventHub.$emit('postAction', this.stopUrl);
+ }
+ },
},
- },
-};
+ };
</script>
<template>
<button
@@ -58,10 +60,13 @@ export default {
@click="onClick"
:disabled="isLoading"
:title="title"
- :aria-label="title">
+ :aria-label="title"
+ >
<i
class="fa fa-stop stop-env-icon"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
<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 2037bf618e3..407d5333c0e 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -1,36 +1,36 @@
<script>
-/**
- * Renders a terminal button to open a web terminal.
- * Used in environments table.
- */
-import terminalIconSvg from 'icons/_icon_terminal.svg';
-import tooltip from '../../vue_shared/directives/tooltip';
+ /**
+ * Renders a terminal button to open a web terminal.
+ * Used in environments table.
+ */
+ import terminalIconSvg from 'icons/_icon_terminal.svg';
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- terminalPath: {
- type: String,
- required: false,
- default: '',
+ export default {
+ directives: {
+ tooltip,
},
- },
- directives: {
- tooltip,
- },
+ props: {
+ terminalPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
- data() {
- return {
- terminalIconSvg,
- };
- },
+ data() {
+ return {
+ terminalIconSvg,
+ };
+ },
- computed: {
- title() {
- return 'Terminal';
+ computed: {
+ title() {
+ return 'Terminal';
+ },
},
- },
-};
+ };
</script>
<template>
<a
@@ -40,6 +40,7 @@ export default {
:title="title"
:aria-label="title"
:href="terminalPath"
- v-html="terminalIconSvg">
+ v-html="terminalIconSvg"
+ >
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
new file mode 100644
index 00000000000..c0be72f7401
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -0,0 +1,131 @@
+<script>
+ import Flash from '../../flash';
+ import { s__ } from '../../locale';
+ import emptyState from './empty_state.vue';
+ import eventHub from '../event_hub';
+ import environmentsMixin from '../mixins/environments_mixin';
+ import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
+
+ export default {
+ components: {
+ emptyState,
+ },
+
+ mixins: [
+ CIPaginationMixin,
+ environmentsMixin,
+ ],
+
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ canCreateEnvironment: {
+ type: Boolean,
+ required: true,
+ },
+ canCreateDeployment: {
+ type: Boolean,
+ required: true,
+ },
+ canReadEnvironment: {
+ type: Boolean,
+ required: true,
+ },
+ cssContainerClass: {
+ type: String,
+ required: true,
+ },
+ newEnvironmentPath: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+
+ created() {
+ eventHub.$on('toggleFolder', this.toggleFolder);
+ },
+
+ beforeDestroy() {
+ eventHub.$off('toggleFolder');
+ },
+
+ methods: {
+ toggleFolder(folder) {
+ this.store.toggleFolder(folder);
+
+ if (!folder.isOpen) {
+ this.fetchChildEnvironments(folder, true);
+ }
+ },
+
+ fetchChildEnvironments(folder, showLoader = false) {
+ this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
+
+ this.service.getFolderContent(folder.folder_path)
+ .then(resp => resp.json())
+ .then(response => this.store.setfolderContent(folder, response.environments))
+ .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
+ .catch(() => {
+ Flash(s__('Environments|An error occurred while fetching the environments.'));
+ this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
+ });
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+
+ // We need to verify if any folder is open to also update it
+ const openFolders = this.store.getOpenFolders();
+ if (openFolders.length) {
+ openFolders.forEach(folder => this.fetchChildEnvironments(folder));
+ }
+ },
+ },
+ };
+</script>
+<template>
+ <div :class="cssContainerClass">
+ <div class="top-area">
+ <tabs
+ :tabs="tabs"
+ @onChangeTab="onChangeTab"
+ scope="environments"
+ />
+
+ <div
+ v-if="canCreateEnvironment && !isLoading"
+ class="nav-controls"
+ >
+ <a
+ :href="newEnvironmentPath"
+ class="btn btn-create"
+ >
+ {{ s__("Environments|New environment") }}
+ </a>
+ </div>
+ </div>
+
+ <container
+ :is-loading="isLoading"
+ :environments="state.environments"
+ :pagination="state.paginationInformation"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ @onChangePage="onChangePage"
+ >
+ <empty-state
+ slot="emptyState"
+ v-if="!isLoading && state.environments.length === 0"
+ :new-path="newEnvironmentPath"
+ :help-path="helpPagePath"
+ :can-create-environment="canCreateEnvironment"
+ />
+ </container>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index b1fd9db650b..22863e926d4 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -2,12 +2,12 @@
/**
* Render environments table.
*/
-import EnvironmentTableRowComponent from './environment_item.vue';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import environmentItem from './environment_item.vue';
export default {
components: {
- 'environment-item': EnvironmentTableRowComponent,
+ environmentItem,
loadingIcon,
},
@@ -29,70 +29,97 @@ export default {
required: false,
default: false,
},
-
- isLoadingFolderContent: {
- type: Boolean,
- required: false,
- default: false,
- },
},
-
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
+ shouldRenderFolderContent(env) {
+ return env.isFolder &&
+ env.isOpen &&
+ env.children &&
+ env.children.length > 0;
+ },
},
};
</script>
<template>
- <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
+ 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"
+ >
+ {{ s__("Environments|Environment") }}
</div>
- <div class="table-section section-10 environments-deploy" role="columnheader">
- Deployment
+ <div
+ class="table-section section-10 environments-deploy"
+ role="columnheader"
+ >
+ {{ s__("Environments|Deployment") }}
</div>
- <div class="table-section section-15 environments-build" role="columnheader">
- Job
+ <div
+ class="table-section section-15 environments-build"
+ role="columnheader"
+ >
+ {{ s__("Environments|Job") }}
</div>
- <div class="table-section section-25 environments-commit" role="columnheader">
- Commit
+ <div
+ class="table-section section-25 environments-commit"
+ role="columnheader"
+ >
+ {{ s__("Environments|Commit") }}
</div>
- <div class="table-section section-10 environments-date" role="columnheader">
- Updated
+ <div
+ class="table-section section-10 environments-date"
+ role="columnheader"
+ >
+ {{ s__("Environments|Updated") }}
</div>
</div>
<template
- v-for="model in environments"
- v-bind:model="model">
+ v-for="(model, i) in environments"
+ :model="model">
<div
is="environment-item"
:model="model"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- />
+ :key="i"
+ />
- <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
- <div v-if="isLoadingFolderContent">
+ <template
+ v-if="shouldRenderFolderContent(model)"
+ >
+ <div
+ v-if="model.isLoadingFolderContent"
+ :key="`loading-item-${i}`">
<loading-icon size="2" />
</div>
<template v-else>
<div
is="environment-item"
- v-for="children in model.children"
+ v-for="(children, index) in model.children"
:model="children"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- />
+ :key="`env-item-${i}-${index}`"
+ />
- <div>
+ <div :key="`sub-div-${i}`">
<div class="text-center prepend-top-10">
<a
:href="folderUrl(model)"
- class="btn btn-default">
- Show all
+ class="btn btn-default"
+ >
+ {{ s__("Environments|Show all") }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/environments/environments_bundle.js b/app/assets/javascripts/environments/environments_bundle.js
deleted file mode 100644
index c0662125f28..00000000000
--- a/app/assets/javascripts/environments/environments_bundle.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Vue from 'vue';
-import EnvironmentsComponent from './components/environment.vue';
-
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#environments-list-view',
- components: {
- 'environments-table-app': EnvironmentsComponent,
- },
- render: createElement => createElement('environments-table-app'),
-}));
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 9add8c3d721..de0fbdb2e91 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,10 +1,35 @@
import Vue from 'vue';
-import EnvironmentsFolderComponent from './environments_folder_view.vue';
+import environmentsFolderApp from './environments_folder_view.vue';
+import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
+import Translate from '../../vue_shared/translate';
-document.addEventListener('DOMContentLoaded', () => new Vue({
+Vue.use(Translate);
+
+export default () => new Vue({
el: '#environments-folder-list-view',
components: {
- 'environments-folder-app': EnvironmentsFolderComponent,
+ environmentsFolderApp,
+ },
+ data() {
+ const environmentsData = document.querySelector(this.$options.el).dataset;
+
+ return {
+ endpoint: environmentsData.endpoint,
+ folderName: environmentsData.folderName,
+ cssContainerClass: environmentsData.cssClass,
+ canCreateDeployment: convertPermissionToBoolean(environmentsData.canCreateDeployment),
+ canReadEnvironment: convertPermissionToBoolean(environmentsData.canReadEnvironment),
+ };
+ },
+ render(createElement) {
+ return createElement('environments-folder-app', {
+ props: {
+ endpoint: this.endpoint,
+ folderName: this.folderName,
+ cssContainerClass: this.cssContainerClass,
+ canCreateDeployment: this.canCreateDeployment,
+ canReadEnvironment: this.canReadEnvironment,
+ },
+ });
},
- render: createElement => createElement('environments-folder-app'),
-}));
+});
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 925503a01c4..5ef5e347387 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,226 +1,66 @@
<script>
-/* global Flash */
-import Visibility from 'visibilityjs';
-import EnvironmentsService from '../services/environments_service';
-import environmentTable from '../components/environments_table.vue';
-import EnvironmentsStore from '../stores/environments_store';
-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';
-
-export default {
- components: {
- environmentTable,
- tablePagination,
- loadingIcon,
- },
-
- mixins: [
- environmentsMixin,
- ],
-
- data() {
- const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
- const store = new EnvironmentsStore();
- const pathname = window.location.pathname;
- const endpoint = `${pathname}.json`;
- const folderName = pathname.substr(pathname.lastIndexOf('/') + 1);
-
- return {
- store,
- folderName,
- endpoint,
- state: store.state,
- visibility: 'available',
- isLoading: false,
- cssContainerClass: environmentsData.cssClass,
- canCreateDeployment: environmentsData.canCreateDeployment,
- canReadEnvironment: environmentsData.canReadEnvironment,
- // Pagination Properties,
- paginationInformation: {},
- pageNumber: 1,
- };
- },
-
- computed: {
- scope() {
- return gl.utils.getParameterByName('scope');
- },
-
- canReadEnvironmentParsed() {
- return gl.utils.convertPermissionToBoolean(this.canReadEnvironment);
- },
-
- canCreateDeploymentParsed() {
- return gl.utils.convertPermissionToBoolean(this.canCreateDeployment);
- },
-
- /**
- * URL to link in the stopped tab.
- *
- * @return {String}
- */
- stoppedPath() {
- return `${window.location.pathname}?scope=stopped`;
- },
-
- /**
- * URL to link in the available tab.
- *
- * @return {String}
- */
- availablePath() {
- return window.location.pathname;
- },
- },
-
- /**
- * Fetches all the environments and stores them.
- * 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);
-
- const poll = new Poll({
- resource: this.service,
- method: 'get',
- data: { scope, page },
- successCallback: this.successCallback,
- errorCallback: this.errorCallback,
- notificationCallback: (isMakingRequest) => {
- this.isMakingRequest = isMakingRequest;
+ import environmentsMixin from '../mixins/environments_mixin';
+ import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
+
+ export default {
+ mixins: [
+ environmentsMixin,
+ CIPaginationMixin,
+ ],
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ folderName: {
+ type: String,
+ required: true,
+ },
+ cssContainerClass: {
+ type: String,
+ required: true,
+ },
+ canCreateDeployment: {
+ type: Boolean,
+ required: true,
+ },
+ canReadEnvironment: {
+ type: Boolean,
+ required: true,
},
- });
-
- 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: {
- /**
- * Will change the page number and update the URL.
- *
- * @param {Number} pageNumber desired page to go to.
- */
- changePage(pageNumber) {
- const param = gl.utils.setParamInURL('page', pageNumber);
-
- 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.'));
- }
+ methods: {
+ successCallback(resp) {
+ this.saveData(resp);
+ },
},
- },
-};
+ };
</script>
<template>
<div :class="cssContainerClass">
<div
class="top-area"
- v-if="!isLoading">
+ v-if="!isLoading"
+ >
<h4 class="js-folder-name environments-folder-name">
- Environments / <b>{{folderName}}</b>
+ {{ s__("Environments|Environments") }} / <b>{{ folderName }}</b>
</h4>
- <ul class="nav-links">
- <li :class="{ active: scope === null || scope === 'available' }">
- <a
- :href="availablePath"
- class="js-available-environments-folder-tab">
- Available
- <span class="badge js-available-environments-count">
- {{state.availableCounter}}
- </span>
- </a>
- </li>
- <li :class="{ active : scope === 'stopped' }">
- <a
- :href="stoppedPath"
- class="js-stopped-environments-folder-tab">
- Stopped
- <span class="badge js-stopped-environments-count">
- {{state.stoppedCounter}}
- </span>
- </a>
- </li>
- </ul>
+ <tabs
+ :tabs="tabs"
+ @onChangeTab="onChangeTab"
+ scope="environments"
+ />
</div>
- <div class="environments-container">
-
- <loading-icon
- label="Loading environments"
- v-if="isLoading"
- size="3"
- />
-
- <div
- class="table-holder"
- v-if="!isLoading && state.environments.length > 0">
-
- <environment-table
- :environments="state.environments"
- :can-create-deployment="canCreateDeploymentParsed"
- :can-read-environment="canReadEnvironmentParsed"
- />
-
- <table-pagination
- v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
- :change="changePage"
- :pageInfo="state.paginationInformation"/>
- </div>
- </div>
+ <container
+ :is-loading="isLoading"
+ :environments="state.environments"
+ :pagination="state.paginationInformation"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ @onChangePage="onChangePage"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
new file mode 100644
index 00000000000..afc4aba6554
--- /dev/null
+++ b/app/assets/javascripts/environments/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import environmentsComponent from './components/environments_app.vue';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import Translate from '../vue_shared/translate';
+
+Vue.use(Translate);
+
+export default () => new Vue({
+ el: '#environments-list-view',
+ components: {
+ environmentsComponent,
+ },
+ data() {
+ const environmentsData = document.querySelector(this.$options.el).dataset;
+
+ return {
+ endpoint: environmentsData.environmentsDataEndpoint,
+ newEnvironmentPath: environmentsData.newEnvironmentPath,
+ helpPagePath: environmentsData.helpPagePath,
+ cssContainerClass: environmentsData.cssClass,
+ canCreateEnvironment: convertPermissionToBoolean(environmentsData.canCreateEnvironment),
+ canCreateDeployment: convertPermissionToBoolean(environmentsData.canCreateDeployment),
+ canReadEnvironment: convertPermissionToBoolean(environmentsData.canReadEnvironment),
+ };
+ },
+ render(createElement) {
+ return createElement('environments-component', {
+ props: {
+ endpoint: this.endpoint,
+ newEnvironmentPath: this.newEnvironmentPath,
+ helpPagePath: this.helpPagePath,
+ cssContainerClass: this.cssContainerClass,
+ canCreateEnvironment: this.canCreateEnvironment,
+ canCreateDeployment: this.canCreateDeployment,
+ canReadEnvironment: this.canReadEnvironment,
+ },
+ });
+ },
+});
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 25b24fbd6dc..34d18d55120 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -1,17 +1,174 @@
+/**
+ * Common code between environmets app and folder view
+ */
+import _ from 'underscore';
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import {
+ getParameterByName,
+ parseQueryStringIntoObject,
+} from '../../lib/utils/common_utils';
+import { s__ } from '../../locale';
+import Flash from '../../flash';
+import eventHub from '../event_hub';
+
+import EnvironmentsStore from '../stores/environments_store';
+import EnvironmentsService from '../services/environments_service';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
+import environmentTable from '../components/environments_table.vue';
+import tabs from '../../vue_shared/components/navigation_tabs.vue';
+import container from '../components/container.vue';
+
export default {
+
+ components: {
+ environmentTable,
+ container,
+ loadingIcon,
+ tabs,
+ tablePagination,
+ },
+
+ data() {
+ const store = new EnvironmentsStore();
+
+ return {
+ store,
+ state: store.state,
+ isLoading: false,
+ isMakingRequest: false,
+ scope: getParameterByName('scope') || 'available',
+ page: getParameterByName('page') || '1',
+ requestData: {},
+ };
+ },
+
methods: {
saveData(resp) {
- const response = {
- headers: resp.headers,
- body: resp.json(),
- };
+ const headers = resp.headers;
+ return resp.json().then((response) => {
+ this.isLoading = false;
+ if (_.isEqual(parseQueryStringIntoObject(resp.url.split('?')[1]), this.requestData)) {
+ this.store.storeAvailableCount(response.available_count);
+ this.store.storeStoppedCount(response.stopped_count);
+ this.store.storeEnvironments(response.environments);
+ this.store.setPagination(headers);
+ }
+ });
+ },
+
+ /**
+ * Handles URL and query parameter changes.
+ * When the user uses the pagination or the tabs,
+ * - update URL
+ * - Make API request to the server with new parameters
+ * - Update the polling function
+ * - Update the internal state
+ */
+ updateContent(parameters) {
+ this.updateInternalState(parameters);
+ // fetch new data
+ return this.service.get(this.requestData)
+ .then(response => this.successCallback(response))
+ .then(() => {
+ // restart polling
+ this.poll.restart({ data: this.requestData });
+ })
+ .catch(() => {
+ this.errorCallback();
+
+ // restart polling
+ this.poll.restart();
+ });
+ },
+
+ errorCallback() {
this.isLoading = false;
+ Flash(s__('Environments|An error occurred while fetching the environments.'));
+ },
+
+ postAction(endpoint) {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
- 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);
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => {
+ this.isLoading = false;
+ Flash(s__('Environments|An error occurred while making the request.'));
+ });
+ }
},
+
+ fetchEnvironments() {
+ this.isLoading = true;
+
+ return this.service.get(this.requestData)
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+
+ },
+
+ computed: {
+ tabs() {
+ return [
+ {
+ name: s__('Available'),
+ scope: 'available',
+ count: this.state.availableCounter,
+ isActive: this.scope === 'available',
+ },
+ {
+ name: s__('Stopped'),
+ scope: 'stopped',
+ count: this.state.stoppedCounter,
+ isActive: this.scope === 'stopped',
+ },
+ ];
+ },
+ },
+
+ /**
+ * Fetches all the environments and stores them.
+ * Toggles loading property.
+ */
+ created() {
+ this.service = new EnvironmentsService(this.endpoint);
+ this.requestData = { page: this.page, scope: this.scope };
+
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: this.requestData,
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ this.fetchEnvironments();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ eventHub.$on('postAction', this.postAction);
+ },
+
+ beforeDestroyed() {
+ eventHub.$off('postAction');
},
};
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index a5773dd7e4f..5f2989ab854 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -1,4 +1,4 @@
-import '~/lib/utils/common_utils';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
/**
* Environments Store.
*
@@ -35,14 +35,23 @@ export default class EnvironmentsStore {
*/
storeEnvironments(environments = []) {
const filteredEnvironments = environments.map((env) => {
+ const oldEnvironmentState = this.state.environments
+ .find((element) => {
+ if (env.latest) {
+ return element.id === env.latest.id;
+ }
+ return element.id === env.id;
+ }) || {};
+
let filtered = {};
if (env.size > 1) {
filtered = Object.assign({}, env, {
isFolder: true,
+ isLoadingFolderContent: oldEnvironmentState.isLoading || false,
folderName: env.name,
- isOpen: false,
- children: [],
+ isOpen: oldEnvironmentState.isOpen || false,
+ children: oldEnvironmentState.children || [],
});
}
@@ -62,8 +71,8 @@ export default class EnvironmentsStore {
}
setPagination(pagination = {}) {
- const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
- const paginationInformation = gl.utils.parseIntPagination(normalizedHeaders);
+ const normalizedHeaders = normalizeHeaders(pagination);
+ const paginationInformation = parseIntPagination(normalizedHeaders);
this.state.paginationInformation = paginationInformation;
return paginationInformation;
@@ -98,7 +107,7 @@ export default class EnvironmentsStore {
* @return {Array}
*/
toggleFolder(folder) {
- return this.updateFolder(folder, 'isOpen', !folder.isOpen);
+ return this.updateEnvironmentProp(folder, 'isOpen', !folder.isOpen);
}
/**
@@ -125,23 +134,23 @@ export default class EnvironmentsStore {
return updated;
});
- return this.updateFolder(folder, 'children', updatedEnvironments);
+ return this.updateEnvironmentProp(folder, 'children', updatedEnvironments);
}
/**
- * Given a folder a prop and a new value updates the correct folder.
+ * Given a environment, a prop and a new value updates the correct environment.
*
- * @param {Object} folder
+ * @param {Object} environment
* @param {String} prop
* @param {String|Boolean|Object|Array} newValue
* @return {Array}
*/
- updateFolder(folder, prop, newValue) {
+ updateEnvironmentProp(environment, prop, newValue) {
const environments = this.state.environments;
const updatedEnvironments = environments.map((env) => {
const updateEnv = Object.assign({}, env);
- if (env.isFolder && env.id === folder.id) {
+ if (env.id === environment.id) {
updateEnv[prop] = newValue;
}
@@ -149,8 +158,6 @@ export default class EnvironmentsStore {
});
this.state.environments = updatedEnvironments;
-
- return updatedEnvironments;
}
getOpenFolders() {
diff --git a/app/assets/javascripts/experimental_flags.js b/app/assets/javascripts/experimental_flags.js
index dbd3843cef7..1d60847147b 100644
--- a/app/assets/javascripts/experimental_flags.js
+++ b/app/assets/javascripts/experimental_flags.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
export default () => {
@@ -7,5 +8,8 @@ export default () => {
Cookies.set(el.name, el.value, {
expires: 365 * 10,
});
+
+ document.body.scrollTop = 0;
+ window.location.reload();
});
};
diff --git a/app/assets/javascripts/extensions/array.js b/app/assets/javascripts/extensions/array.js
deleted file mode 100644
index 027222f804d..00000000000
--- a/app/assets/javascripts/extensions/array.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// TODO: remove this
-
-// eslint-disable-next-line no-extend-native
-Array.prototype.first = function first() {
- return this[0];
-};
-
-// eslint-disable-next-line no-extend-native
-Array.prototype.last = function last() {
- return this[this.length - 1];
-};
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js
new file mode 100644
index 00000000000..c50ac667c20
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -0,0 +1,66 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import {
+ getSelector,
+ togglePopover,
+ inserted,
+ mouseenter,
+ mouseleave,
+} from './feature_highlight_helper';
+
+export function setupFeatureHighlightPopover(id, debounceTimeout = 300) {
+ const $selector = $(getSelector(id));
+ const $parent = $selector.parent();
+ const $popoverContent = $parent.siblings('.feature-highlight-popover-content');
+ const hideOnScroll = togglePopover.bind($selector, false);
+ const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout);
+
+ $selector
+ // Setup popover
+ .data('content', $popoverContent.prop('outerHTML'))
+ .popover({
+ html: true,
+ // Override the existing template to add custom CSS classes
+ template: `
+ <div class="popover feature-highlight-popover" role="tooltip">
+ <div class="arrow"></div>
+ <div class="popover-content"></div>
+ </div>
+ `,
+ })
+ .on('mouseenter', mouseenter)
+ .on('mouseleave', debouncedMouseleave)
+ .on('inserted.bs.popover', inserted)
+ .on('show.bs.popover', () => {
+ window.addEventListener('scroll', hideOnScroll);
+ })
+ .on('hide.bs.popover', () => {
+ window.removeEventListener('scroll', hideOnScroll);
+ })
+ // Display feature highlight
+ .removeAttr('disabled');
+}
+
+export function findHighestPriorityFeature() {
+ let priorityFeature;
+
+ const sortedFeatureEls = [].slice.call(document.querySelectorAll('.js-feature-highlight')).sort((a, b) =>
+ (a.dataset.highlightPriority || 0) < (b.dataset.highlightPriority || 0));
+
+ const [priorityFeatureEl] = sortedFeatureEls;
+ if (priorityFeatureEl) {
+ priorityFeature = priorityFeatureEl.dataset.highlight;
+ }
+
+ return priorityFeature;
+}
+
+export function highlightFeatures() {
+ const priorityFeature = findHighestPriorityFeature();
+
+ if (priorityFeature) {
+ setupFeatureHighlightPopover(priorityFeature);
+ }
+
+ return priorityFeature;
+}
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
new file mode 100644
index 00000000000..f480e72961c
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import axios from '../lib/utils/axios_utils';
+import { __ } from '../locale';
+import Flash from '../flash';
+import LazyLoader from '../lazy_loader';
+
+export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`;
+
+export function togglePopover(show) {
+ const isAlreadyShown = this.hasClass('js-popover-show');
+ if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) {
+ return false;
+ }
+ this.popover(show ? 'show' : 'hide');
+ this.toggleClass('disable-animation js-popover-show', show);
+
+ return true;
+}
+
+export function dismiss(highlightId) {
+ axios.post(this.attr('data-dismiss-endpoint'), {
+ feature_name: highlightId,
+ })
+ .catch(() => Flash(__('An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.')));
+
+ togglePopover.call(this, false);
+ this.hide();
+}
+
+export function mouseleave() {
+ if (!$('.popover:hover').length > 0) {
+ const $featureHighlight = $(this);
+ togglePopover.call($featureHighlight, false);
+ }
+}
+
+export function mouseenter() {
+ const $featureHighlight = $(this);
+
+ const showedPopover = togglePopover.call($featureHighlight, true);
+ if (showedPopover) {
+ $('.popover')
+ .on('mouseleave', mouseleave.bind($featureHighlight));
+ }
+}
+
+export function inserted() {
+ const popoverId = this.getAttribute('aria-describedby');
+ const highlightId = this.dataset.highlight;
+ const $popover = $(this);
+ const dismissWrapper = dismiss.bind($popover, highlightId);
+
+ $(`#${popoverId} .dismiss-feature-highlight`)
+ .on('click', dismissWrapper);
+
+ const lazyImg = $(`#${popoverId} .feature-highlight-illustration`)[0];
+ if (lazyImg) {
+ LazyLoader.loadImage(lazyImg);
+ }
+}
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
new file mode 100644
index 00000000000..212643b1e04
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
@@ -0,0 +1,12 @@
+import { highlightFeatures } from './feature_highlight';
+import bp from '../breakpoints';
+
+export default function domContentLoaded() {
+ if (bp.getBreakpointSize() === 'lg') {
+ highlightFeatures();
+ return true;
+ }
+ return false;
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded);
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 534e651b030..6a4874e1ab8 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -1,150 +1,72 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len, one-var, one-var-declaration-per-line, quotes, prefer-template, newline-per-chained-call, comma-dangle, new-cap, no-else-return, consistent-return */
-/* global FilesCommentButton */
-/* global notes */
-
-let $commentButtonTemplate;
-
-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;
-
- COMMENT_BUTTON_CLASS = '.add-diff-note';
-
- LINE_HOLDER_CLASS = '.line_holder';
-
- LINE_NUMBER_CLASS = 'diff-line-num';
-
- LINE_CONTENT_CLASS = 'line_content';
-
- UNFOLDABLE_LINE_CLASS = 'js-unfold';
-
- EMPTY_CELL_CLASS = 'empty-cell';
-
- OLD_LINE_CLASS = 'old_line';
-
- LINE_COLUMN_CLASSES = "." + LINE_NUMBER_CLASS + ", .line_content";
-
- TEXT_FILE_SELECTOR = '.text-file';
-
- function FilesCommentButton(filesContainerElement) {
- 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);
- }
-
- FilesCommentButton.prototype.render = function(e) {
- var $currentTarget, buttonParentElement, lineContentElement, textFileElement, $button;
- $currentTarget = $(e.currentTarget);
-
- if ($currentTarget.hasClass('js-no-comment-btn')) return;
-
- lineContentElement = this.getLineContent($currentTarget);
- buttonParentElement = this.getButtonParent($currentTarget);
-
- if (!this.validateButtonParent(buttonParentElement) || !this.validateLineContent(lineContentElement)) return;
-
- $button = $(COMMENT_BUTTON_CLASS, buttonParentElement);
- buttonParentElement.addClass('is-over')
- .nextUntil(`.${LINE_CONTENT_CLASS}`).addClass('is-over');
-
- if ($button.length) {
- return;
+/* Developer beware! Do not add logic to showButton or hideButton
+ * that will force a reflow. Doing so will create a signficant performance
+ * bottleneck for pages with large diffs. For a comprehensive list of what
+ * causes reflows, visit https://gist.github.com/paulirish/5d52fb081b3570c81e3a
+ */
+
+import Cookies from 'js-cookie';
+
+const LINE_NUMBER_CLASS = 'diff-line-num';
+const UNFOLDABLE_LINE_CLASS = 'js-unfold';
+const NO_COMMENT_CLASS = 'no-comment-btn';
+const EMPTY_CELL_CLASS = 'empty-cell';
+const OLD_LINE_CLASS = 'old_line';
+const LINE_COLUMN_CLASSES = `.${LINE_NUMBER_CLASS}, .line_content`;
+const DIFF_CONTAINER_SELECTOR = '.files';
+const DIFF_EXPANDED_CLASS = 'diff-expanded';
+
+export default {
+ init($diffFile) {
+ /* Caching is used only when the following members are *true*.
+ * This is because there are likely to be
+ * differently configured versions of diffs in the same session.
+ * However if these values are true, they
+ * will be true in all cases */
+
+ if (!this.userCanCreateNote) {
+ // data-can-create-note is an empty string when true, otherwise undefined
+ this.userCanCreateNote = $diffFile.closest(DIFF_CONTAINER_SELECTOR).data('canCreateNote') === '';
}
- textFileElement = this.getTextFileElement($currentTarget);
- buttonParentElement.append(this.buildButton({
- discussionID: lineContentElement.attr('data-discussion-id'),
- lineType: lineContentElement.attr('data-line-type'),
-
- noteableType: textFileElement.attr('data-noteable-type'),
- noteableID: textFileElement.attr('data-noteable-id'),
- commitID: textFileElement.attr('data-commit-id'),
- noteType: lineContentElement.attr('data-note-type'),
-
- // LegacyDiffNote
- lineCode: lineContentElement.attr('data-line-code'),
-
- // DiffNote
- position: lineContentElement.attr('data-position')
- }));
- };
-
- FilesCommentButton.prototype.hideButton = function(e) {
- var $currentTarget = $(e.currentTarget);
- var buttonParentElement = this.getButtonParent($currentTarget);
-
- buttonParentElement.removeClass('is-over')
- .nextUntil(`.${LINE_CONTENT_CLASS}`).removeClass('is-over');
- };
-
- FilesCommentButton.prototype.buildButton = function(buttonAttributes) {
- return $commentButtonTemplate.clone().attr({
- 'data-discussion-id': buttonAttributes.discussionID,
- 'data-line-type': buttonAttributes.lineType,
-
- 'data-noteable-type': buttonAttributes.noteableType,
- 'data-noteable-id': buttonAttributes.noteableID,
- 'data-commit-id': buttonAttributes.commitID,
- 'data-note-type': buttonAttributes.noteType,
+ this.isParallelView = Cookies.get('diff_view') === 'parallel';
- // LegacyDiffNote
- 'data-line-code': buttonAttributes.lineCode,
-
- // DiffNote
- 'data-position': buttonAttributes.position
- });
- };
-
- FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) {
- return hoveredElement.closest(TEXT_FILE_SELECTOR);
- };
-
- FilesCommentButton.prototype.getLineContent = function(hoveredElement) {
- if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) {
- return hoveredElement;
- }
- if (!this.isParallelView) {
- return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS);
- } else {
- return $(hoveredElement).next("." + LINE_CONTENT_CLASS);
+ if (this.userCanCreateNote) {
+ $diffFile.on('mouseover', LINE_COLUMN_CLASSES, e => this.showButton(this.isParallelView, e))
+ .on('mouseleave', LINE_COLUMN_CLASSES, e => this.hideButton(this.isParallelView, e));
}
- };
+ },
- FilesCommentButton.prototype.getButtonParent = function(hoveredElement) {
- if (!this.isParallelView) {
- if (hoveredElement.hasClass(OLD_LINE_CLASS)) {
- return hoveredElement;
- }
- return hoveredElement.parent().find("." + OLD_LINE_CLASS);
- } else {
- if (hoveredElement.hasClass(LINE_NUMBER_CLASS)) {
- return hoveredElement;
- }
- return $(hoveredElement).prev("." + LINE_NUMBER_CLASS);
- }
- };
+ showButton(isParallelView, e) {
+ const buttonParentElement = this.getButtonParent(e.currentTarget, isParallelView);
- FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) {
- return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS);
- };
+ if (!this.validateButtonParent(buttonParentElement)) return;
- FilesCommentButton.prototype.validateLineContent = function(lineContentElement) {
- return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== '';
- };
+ buttonParentElement.classList.add('is-over');
+ buttonParentElement.nextElementSibling.classList.add('is-over');
+ },
- return FilesCommentButton;
-})();
+ hideButton(isParallelView, e) {
+ const buttonParentElement = this.getButtonParent(e.currentTarget, isParallelView);
-$.fn.filesCommentButton = function() {
- $commentButtonTemplate = $('<button name="button" type="submit" class="add-diff-note js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>');
+ buttonParentElement.classList.remove('is-over');
+ buttonParentElement.nextElementSibling.classList.remove('is-over');
+ },
- if (!(this && (this.parent().data('can-create-note') != null))) {
- return;
- }
- return this.each(function() {
- if (!$.data(this, 'filesCommentButton')) {
- return $.data(this, 'filesCommentButton', new FilesCommentButton($(this)));
+ getButtonParent(hoveredElement, isParallelView) {
+ if (isParallelView) {
+ if (!hoveredElement.classList.contains(LINE_NUMBER_CLASS)) {
+ return hoveredElement.previousElementSibling;
+ }
+ } else if (!hoveredElement.classList.contains(OLD_LINE_CLASS)) {
+ return hoveredElement.parentNode.querySelector(`.${OLD_LINE_CLASS}`);
}
- });
+ return hoveredElement;
+ },
+
+ validateButtonParent(buttonParentElement) {
+ return !buttonParentElement.classList.contains(EMPTY_CELL_CLASS) &&
+ !buttonParentElement.classList.contains(UNFOLDABLE_LINE_CLASS) &&
+ !buttonParentElement.classList.contains(NO_COMMENT_CLASS) &&
+ !buttonParentElement.parentNode.classList.contains(DIFF_EXPANDED_CLASS);
+ },
};
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 139206cc185..b17ba3c21db 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -1,13 +1,18 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+
/**
* Makes search request for content when user types a value in the search input.
* Updates the html content of the page with the received one.
*/
export default class FilterableList {
- constructor(form, filter, holder) {
+ constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
+ this.filterInputField = filterInputField;
this.isBusy = false;
}
@@ -30,10 +35,10 @@ export default class FilterableList {
onFilterInput() {
const $form = $(this.filterForm);
const queryData = {};
- const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
- queryData.filter_groups = filterGroupsParam;
+ queryData[this.filterInputField] = filterGroupsParam;
}
this.filterResults(queryData);
@@ -51,32 +56,26 @@ export default class FilterableList {
this.listFilterElement.removeEventListener('input', this.debounceFilter);
}
- filterResults(queryData) {
+ filterResults(params) {
if (this.isBusy) {
return false;
}
$(this.listHolderElement).fadeTo(250, 0.5);
- return $.ajax({
- url: this.getFilterEndpoint(),
- data: queryData,
- type: 'GET',
- dataType: 'json',
- context: this,
- complete: this.onFilterComplete,
- beforeSend: () => {
- this.isBusy = true;
- },
- success: (response, textStatus, xhr) => {
- this.onFilterSuccess(response, xhr, queryData);
- },
- });
+ this.isBusy = true;
+
+ return axios.get(this.getFilterEndpoint(), {
+ params,
+ }).then((res) => {
+ this.onFilterSuccess(res, params);
+ this.onFilterComplete();
+ }).catch(() => this.onFilterComplete());
}
- onFilterSuccess(response, xhr, queryData) {
- if (response.html) {
- this.listHolderElement.innerHTML = response.html;
+ onFilterSuccess(response, queryData) {
+ if (response.data.html) {
+ this.listHolderElement.innerHTML = response.data.html;
}
// Change url so if user reload a page - search results are saved
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
deleted file mode 100644
index c51d4b056af..00000000000
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import eventHub from '../event_hub';
-
-export default {
- name: 'RecentSearchesDropdownContent',
-
- props: {
- items: {
- type: Array,
- required: true,
- },
- isLocalStorageAvailable: {
- type: Boolean,
- required: false,
- default: true,
- },
- allowedKeys: {
- type: Array,
- required: true,
- },
- },
-
- computed: {
- processedItems() {
- return this.items.map((item) => {
- const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
-
- const resultantTokens = tokens.map(token => ({
- prefix: `${token.key}:`,
- suffix: `${token.symbol}${token.value}`,
- }));
-
- return {
- text: item,
- tokens: resultantTokens,
- searchToken,
- };
- });
- },
- hasItems() {
- return this.items.length > 0;
- },
- },
-
- methods: {
- onItemActivated(text) {
- eventHub.$emit('recentSearchesItemSelected', text);
- },
- onRequestClearRecentSearches(e) {
- // Stop the dropdown from closing
- e.stopPropagation();
-
- eventHub.$emit('requestClearRecentSearches');
- },
- },
-
- template: `
- <div>
- <div
- v-if="!isLocalStorageAvailable"
- class="dropdown-info-note">
- This feature requires local storage to be enabled
- </div>
- <ul v-else-if="hasItems">
- <li
- v-for="(item, index) in processedItems"
- :key="index">
- <button
- type="button"
- class="filtered-search-history-dropdown-item"
- @click="onItemActivated(item.text)">
- <span>
- <span
- v-for="(token, tokenIndex) in item.tokens"
- class="filtered-search-history-dropdown-token">
- <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
- </span>
- </span>
- <span class="filtered-search-history-dropdown-search-token">
- {{ item.searchToken }}
- </span>
- </button>
- </li>
- <li class="divider"></li>
- <li>
- <button
- type="button"
- class="filtered-search-history-clear-button"
- @click="onRequestClearRecentSearches($event)">
- Clear recent searches
- </button>
- </li>
- </ul>
- <div
- v-else
- class="dropdown-info-note">
- You don't have any recent searches
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
new file mode 100644
index 00000000000..26618af9515
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -0,0 +1,104 @@
+<script>
+import eventHub from '../event_hub';
+import FilteredSearchTokenizer from '../filtered_search_tokenizer';
+
+export default {
+ name: 'RecentSearchesDropdownContent',
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ isLocalStorageAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ allowedKeys: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ processedItems() {
+ return this.items.map((item) => {
+ const { tokens, searchToken }
+ = FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
+
+ const resultantTokens = tokens.map(token => ({
+ prefix: `${token.key}:`,
+ suffix: `${token.symbol}${token.value}`,
+ }));
+
+ return {
+ text: item,
+ tokens: resultantTokens,
+ searchToken,
+ };
+ });
+ },
+ hasItems() {
+ return this.items.length > 0;
+ },
+ },
+ methods: {
+ onItemActivated(text) {
+ eventHub.$emit('recentSearchesItemSelected', text);
+ },
+ onRequestClearRecentSearches(e) {
+ // Stop the dropdown from closing
+ e.stopPropagation();
+
+ eventHub.$emit('requestClearRecentSearches');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ v-if="!isLocalStorageAvailable"
+ class="dropdown-info-note">
+ This feature requires local storage to be enabled
+ </div>
+ <ul v-else-if="hasItems">
+ <li
+ v-for="(item, index) in processedItems"
+ :key="`processed-items-${index}`"
+ >
+ <button
+ type="button"
+ class="filtered-search-history-dropdown-item"
+ @click="onItemActivated(item.text)">
+ <span>
+ <span
+ class="filtered-search-history-dropdown-token"
+ v-for="(token, index) in item.tokens"
+ :key="`dropdown-token-${index}`"
+ >
+ <span class="name">{{ token.prefix }}</span>
+ <span class="value">{{ token.suffix }}</span>
+ </span>
+ </span>
+ <span class="filtered-search-history-dropdown-search-token">
+ {{ item.searchToken }}
+ </span>
+ </button>
+ </li>
+ <li class="divider"></li>
+ <li>
+ <button
+ type="button"
+ class="filtered-search-history-clear-button"
+ @click="onRequestClearRecentSearches($event)">
+ Clear recent searches
+ </button>
+ </li>
+ </ul>
+ <div
+ v-else
+ class="dropdown-info-note">
+ You don't have any recent searches
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
new file mode 100644
index 00000000000..5ddd0e5e690
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -0,0 +1,79 @@
+import Flash from '../flash';
+import Ajax from '../droplab/plugins/ajax';
+import Filter from '../droplab/plugins/filter';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+
+export default class DropdownEmoji extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ super(options);
+ this.config = {
+ Ajax: {
+ endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`,
+ method: 'setData',
+ loadingTemplate: this.loadingTemplate,
+ onError() {
+ /* eslint-disable no-new */
+ new Flash('An error occurred fetching the dropdown data.');
+ /* eslint-enable no-new */
+ },
+ },
+ Filter: {
+ template: 'name',
+ },
+ };
+
+ import(/* webpackChunkName: 'emoji' */ '~/emoji')
+ .then(({ glEmojiTag }) => { this.glEmojiTag = glEmojiTag; })
+ .catch(() => { /* ignore error and leave emoji name in the search bar */ });
+
+ this.unbindEvents();
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ super.bindEvents();
+
+ this.listRenderedWrapper = this.listRendered.bind(this);
+ this.dropdown.addEventListener('render.dl', this.listRenderedWrapper);
+ }
+
+ unbindEvents() {
+ this.dropdown.removeEventListener('render.dl', this.listRenderedWrapper);
+ super.unbindEvents();
+ }
+
+ listRendered() {
+ this.replaceEmojiElement();
+ }
+
+ itemClicked(e) {
+ super.itemClicked(e, (selected) => {
+ const name = selected.querySelector('.js-data-value').innerText.trim();
+ return DropdownUtils.getEscapedText(name);
+ });
+ }
+
+ renderContent(forceShowList = false) {
+ this.droplab.changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config);
+ super.renderContent(forceShowList);
+ }
+
+ replaceEmojiElement() {
+ if (!this.glEmojiTag) return;
+
+ // Replace empty gl-emoji tag to real content
+ const dropdownItems = [...this.dropdown.querySelectorAll('.filter-dropdown-item')];
+ dropdownItems.forEach((dropdownItem) => {
+ const name = dropdownItem.querySelector('.js-data-value').innerText;
+ const emojiTag = this.glEmojiTag(name);
+ const emojiElement = dropdownItem.querySelector('gl-emoji');
+ emojiElement.outerHTML = emojiTag;
+ });
+ }
+
+ init() {
+ this.droplab
+ .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 5838b1bdbb7..184b34b7b5e 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,13 +1,17 @@
import Filter from '~/droplab/plugins/filter';
-import './filtered_search_dropdown';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
-class DropdownHint extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, tokenKeys, filter) {
- super(droplab, dropdown, input, filter);
+export default class DropdownHint extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { input, tokenKeys } = options;
+ super(options);
this.config = {
Filter: {
template: 'hint',
- filterFunction: gl.DropdownUtils.filterHint.bind(null, {
+ filterFunction: DropdownUtils.filterHint.bind(null, {
input,
allowedKeys: tokenKeys.getKeys(),
}),
@@ -44,10 +48,10 @@ class DropdownHint extends gl.FilteredSearchDropdown {
});
if (searchTerms.length > 0) {
- gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
+ FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
}
- gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
+ FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
}
this.dismissDropdown();
this.dispatchInputEvent();
@@ -60,7 +64,7 @@ class DropdownHint extends gl.FilteredSearchDropdown {
.map(tokenKey => ({
icon: `fa-${tokenKey.icon}`,
hint: tokenKey.key,
- tag: `<${tokenKey.symbol}${tokenKey.key}>`,
+ tag: `:${tokenKey.tag}`,
type: tokenKey.type,
}));
@@ -72,6 +76,3 @@ class DropdownHint extends gl.FilteredSearchDropdown {
this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownHint = DropdownHint;
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 34a9e34070c..2ffda7e2037 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,26 +1,28 @@
-/* global Flash */
+import Flash from '../flash';
+import Ajax from '../droplab/plugins/ajax';
+import Filter from '../droplab/plugins/filter';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
-import Ajax from '~/droplab/plugins/ajax';
-import Filter from '~/droplab/plugins/filter';
-import './filtered_search_dropdown';
-
-class DropdownNonUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) {
- super(droplab, dropdown, input, filter);
+export default class DropdownNonUser extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { input, endpoint, symbol, preprocessing } = options;
+ super(options);
this.symbol = symbol;
this.config = {
Ajax: {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
+ preprocessing,
onError() {
/* eslint-disable no-new */
- new Flash('An error occured fetching the dropdown data.');
+ new Flash('An error occurred fetching the dropdown data.');
/* eslint-enable no-new */
},
},
Filter: {
- filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
+ filterFunction: DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
template: 'title',
},
};
@@ -29,7 +31,7 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
itemClicked(e) {
super.itemClicked(e, (selected) => {
const title = selected.querySelector('.js-data-value').innerText.trim();
- return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`;
+ return `${this.symbol}${DropdownUtils.getEscapedText(title)}`;
});
}
@@ -44,6 +46,3 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownNonUser = DropdownNonUser;
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 65c1b2050ac..d36f38a70b5 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,18 +1,21 @@
-/* global Flash */
+import Flash from '../flash';
+import AjaxFilter from '../droplab/plugins/ajax_filter';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import { addClassIfElementExists } from '../lib/utils/dom_utils';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
-import AjaxFilter from '~/droplab/plugins/ajax_filter';
-import './filtered_search_dropdown';
-
-class DropdownUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, tokenKeys, filter) {
- super(droplab, dropdown, input, filter);
+export default class DropdownUser extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { tokenKeys } = options;
+ super(options);
this.config = {
AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
- per_page: 20,
active: true,
+ group_id: this.getGroupId(),
project_id: this.getProjectId(),
current_user: true,
},
@@ -23,7 +26,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
onError() {
/* eslint-disable no-new */
- new Flash('An error occured fetching the dropdown data.');
+ new Flash('An error occurred fetching the dropdown data.');
/* eslint-enable no-new */
},
},
@@ -32,8 +35,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
}
hideCurrentUser() {
- const currenUserItem = this.dropdown.querySelector('.js-current-user');
- currenUserItem.classList.add('hidden');
+ addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden');
}
itemClicked(e) {
@@ -46,13 +48,17 @@ class DropdownUser extends gl.FilteredSearchDropdown {
super.renderContent(forceShowList);
}
+ getGroupId() {
+ return this.input.getAttribute('data-group-id');
+ }
+
getProjectId() {
return this.input.getAttribute('data-project-id');
}
getSearchInput() {
- const query = gl.DropdownUtils.getSearchInput(this.input);
- const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
+ const query = DropdownUtils.getSearchInput(this.input);
+ const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
@@ -73,6 +79,3 @@ class DropdownUser extends gl.FilteredSearchDropdown {
this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownUser = DropdownUser;
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index ef8fe071012..9bc36c1f9b6 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -1,6 +1,10 @@
+import _ from 'underscore';
import FilteredSearchContainer from './container';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
-class DropdownUtils {
+export default class DropdownUtils {
static getEscapedText(text) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
@@ -23,7 +27,7 @@ class DropdownUtils {
static filterWithSymbol(filterSymbol, input, item) {
const updatedItem = item;
- const searchInput = gl.DropdownUtils.getSearchInput(input);
+ const searchInput = DropdownUtils.getSearchInput(input);
const title = updatedItem.title.toLowerCase();
let value = searchInput.toLowerCase();
@@ -50,23 +54,83 @@ class DropdownUtils {
return updatedItem;
}
+ static mergeDuplicateLabels(dataMap, newLabel) {
+ const updatedMap = dataMap;
+ const key = newLabel.title;
+
+ const hasKeyProperty = Object.prototype.hasOwnProperty.call(updatedMap, key);
+
+ if (!hasKeyProperty) {
+ updatedMap[key] = newLabel;
+ } else {
+ const existing = updatedMap[key];
+
+ if (!existing.multipleColors) {
+ existing.multipleColors = [existing.color];
+ }
+
+ existing.multipleColors.push(newLabel.color);
+ }
+
+ return updatedMap;
+ }
+
+ static duplicateLabelColor(labelColors) {
+ const colors = labelColors;
+ const spacing = 100 / colors.length;
+
+ // Reduce the colors to 4
+ colors.length = Math.min(colors.length, 4);
+
+ const color = colors.map((c, i) => {
+ const percentFirst = Math.floor(spacing * i);
+ const percentSecond = Math.floor(spacing * (i + 1));
+ return `${c} ${percentFirst}%, ${c} ${percentSecond}%`;
+ }).join(', ');
+
+ return `linear-gradient(${color})`;
+ }
+
+ static duplicateLabelPreprocessing(data) {
+ const results = [];
+ const dataMap = {};
+
+ data.forEach(DropdownUtils.mergeDuplicateLabels.bind(null, dataMap));
+
+ Object.keys(dataMap)
+ .forEach((key) => {
+ const label = dataMap[key];
+
+ if (label.multipleColors) {
+ label.color = DropdownUtils.duplicateLabelColor(label.multipleColors);
+ label.text_color = '#000000';
+ }
+
+ results.push(label);
+ });
+
+ results.preprocessed = true;
+
+ return results;
+ }
+
static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item;
- const searchInput = gl.DropdownUtils.getSearchQuery(input);
+ const searchInput = DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } =
- gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
+ FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
- } else if (!lastKey || searchInput.split('').last() === ' ') {
+ } else if (!lastKey || _.last(searchInput.split('')) === ' ') {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
- const tokenName = split[0].split(' ').last();
+ const tokenName = _.last(split[0].split(' '));
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false;
@@ -79,13 +143,23 @@ class DropdownUtils {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
- gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
+ FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
}
// Return boolean based on whether it was set
return dataValue !== null;
}
+ static getVisualTokenValues(visualToken) {
+ const tokenName = visualToken && visualToken.querySelector('.name').textContent.trim();
+ let tokenValue = visualToken && visualToken.querySelector('.value') && visualToken.querySelector('.value').textContent.trim();
+ if (tokenName === 'label' && tokenValue) {
+ // remove leading symbol and wrapping quotes
+ tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
+ }
+ return { tokenName, tokenValue };
+ }
+
// Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) {
const container = FilteredSearchContainer.container;
@@ -119,7 +193,7 @@ class DropdownUtils {
}
} else if (token.classList.contains('input-token')) {
const { isLastVisualTokenValid } =
- gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const inputValue = input && input.value;
@@ -140,7 +214,7 @@ class DropdownUtils {
static getSearchInput(filteredSearchInput) {
const inputValue = filteredSearchInput.value;
- const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput);
+ const { right } = DropdownUtils.getInputSelectionPosition(filteredSearchInput);
return inputValue.slice(0, right);
}
@@ -181,6 +255,3 @@ class DropdownUtils {
};
}
}
-
-window.gl = window.gl || {};
-gl.DropdownUtils = DropdownUtils;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
deleted file mode 100644
index 132b6fe698a..00000000000
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ /dev/null
@@ -1,10 +0,0 @@
-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.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 4209ca0d6e2..fb4ae1d17dd 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,7 +1,10 @@
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
-class FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
+export default class FilteredSearchDropdown {
+ constructor({ droplab, dropdown, input, filter }) {
this.droplab = droplab;
this.hookId = input && input.id;
this.input = input;
@@ -30,11 +33,11 @@ class FilteredSearchDropdown {
const { selected } = e.detail;
if (selected.tagName === 'LI' && selected.innerHTML) {
- const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected);
+ const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected);
if (!dataValueSet) {
const value = getValueFunction(selected);
- gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
+ FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
}
this.resetFilters();
@@ -108,6 +111,9 @@ class FilteredSearchDropdown {
if (hook) {
const data = hook.list.data || [];
+
+ if (!data) return;
+
const results = data.map((o) => {
const updated = o;
updated.droplab_hidden = false;
@@ -117,6 +123,3 @@ class FilteredSearchDropdown {
}
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchDropdown = FilteredSearchDropdown;
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 6bc6bc43f51..e6390f0855b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,14 +1,33 @@
+import _ from 'underscore';
import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
-
-class FilteredSearchDropdownManager {
- constructor(baseEndpoint = '', tokenizer, page) {
+import FilteredSearchTokenKeys from './filtered_search_token_keys';
+import DropdownUtils from './dropdown_utils';
+import DropdownHint from './dropdown_hint';
+import DropdownEmoji from './dropdown_emoji';
+import DropdownNonUser from './dropdown_non_user';
+import DropdownUser from './dropdown_user';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+
+export default class FilteredSearchDropdownManager {
+ constructor({
+ baseEndpoint = '',
+ tokenizer,
+ page,
+ isGroup,
+ isGroupAncestor,
+ isGroupDecendent,
+ filteredSearchTokenKeys,
+ }) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = tokenizer;
- this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
+ this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
+ this.groupsOnly = isGroup;
+ this.groupAncestor = isGroupAncestor;
+ this.isGroupDecendent = isGroupDecendent;
this.setupMapping();
@@ -28,45 +47,80 @@ class FilteredSearchDropdownManager {
}
setupMapping() {
- this.mapping = {
+ const supportedTokens = this.filteredSearchTokenKeys.getKeys();
+ const allowedMappings = {
+ hint: {
+ reference: null,
+ gl: DropdownHint,
+ element: this.container.querySelector('#js-dropdown-hint'),
+ },
+ };
+ const availableMappings = {
author: {
reference: null,
- gl: 'DropdownUser',
+ gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
- gl: 'DropdownUser',
+ gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
- gl: 'DropdownNonUser',
- extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'],
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getMilestoneEndpoint(),
+ symbol: '%',
+ },
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
- gl: 'DropdownNonUser',
- extraArguments: [`${this.baseEndpoint}/labels.json`, '~'],
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getLabelsEndpoint(),
+ symbol: '~',
+ preprocessing: DropdownUtils.duplicateLabelPreprocessing,
+ },
element: this.container.querySelector('#js-dropdown-label'),
},
- hint: {
+ 'my-reaction': {
reference: null,
- gl: 'DropdownHint',
- element: this.container.querySelector('#js-dropdown-hint'),
+ gl: DropdownEmoji,
+ element: this.container.querySelector('#js-dropdown-my-reaction'),
},
};
+
+ supportedTokens.forEach((type) => {
+ if (availableMappings[type]) {
+ allowedMappings[type] = availableMappings[type];
+ }
+ });
+
+ this.mapping = allowedMappings;
+ }
+
+ getMilestoneEndpoint() {
+ const endpoint = `${this.baseEndpoint}/milestones.json`;
+
+ return endpoint;
+ }
+
+ getLabelsEndpoint() {
+ const endpoint = `${this.baseEndpoint}/labels.json`;
+
+ return endpoint;
}
static addWordToInput(tokenName, tokenValue = '', clicked = false) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
input.value = '';
if (clicked) {
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ FilteredSearchVisualTokens.moveInputToTheRight();
}
}
@@ -97,13 +151,19 @@ class FilteredSearchDropdownManager {
let forceShowList = false;
if (!mappingKey.reference) {
- const dl = this.droplab;
- const defaultArguments =
- [null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key];
- const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
-
- // Passing glArguments to `new gl[glClass](<arguments>)`
- mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))();
+ const defaultArguments = {
+ droplab: this.droplab,
+ dropdown: element,
+ input: this.filteredSearchInput,
+ tokenKeys: this.filteredSearchTokenKeys,
+ filter: key,
+ };
+ const extraArguments = mappingKey.extraArguments || {};
+ const glArguments = Object.assign({}, defaultArguments, extraArguments);
+
+ // Passing glArguments to `new glClass(<arguments>)`
+ mappingKey.reference =
+ new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
}
if (firstLoad) {
@@ -141,7 +201,7 @@ class FilteredSearchDropdownManager {
}
setDropdown() {
- const query = gl.DropdownUtils.getSearchQuery(true);
+ const query = DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } =
this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
@@ -154,7 +214,7 @@ class FilteredSearchDropdownManager {
// Eg. token = 'label:'
const split = lastToken.split(':');
- const dropdownName = split[0].split(' ').last();
+ const dropdownName = _.last(split[0].split(' '));
this.loadDropdown(split.length > 1 ? dropdownName : '');
} else if (lastToken) {
// Token has been initialized into an object because it has a value
@@ -186,6 +246,3 @@ class FilteredSearchDropdownManager {
this.droplab.destroy();
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 1425769d2de..71b7e80335b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,31 +1,57 @@
+import _ from 'underscore';
+import {
+ getParameterByName,
+ getUrlParamsArray,
+} from '~/lib/utils/common_utils';
+import { visitUrl } from '../lib/utils/url_utility';
+import Flash from '../flash';
import FilteredSearchContainer from './container';
+import FilteredSearchTokenKeys from './filtered_search_token_keys';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
import eventHub from './event_hub';
+import { addClassIfElementExists } from '../lib/utils/dom_utils';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import DropdownUtils from './dropdown_utils';
+
+export default class FilteredSearchManager {
+ constructor({
+ page,
+ isGroup = false,
+ isGroupAncestor = false,
+ isGroupDecendent = false,
+ filteredSearchTokenKeys = FilteredSearchTokenKeys,
+ stateFiltersSelector = '.issues-state-filters',
+ }) {
+ this.isGroup = isGroup;
+ this.isGroupAncestor = isGroupAncestor;
+ this.isGroupDecendent = isGroupDecendent;
+ this.states = ['opened', 'closed', 'merged', 'all'];
-class FilteredSearchManager {
- constructor(page) {
this.page = page;
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
- this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
+ this.filteredSearchTokenKeys = filteredSearchTokenKeys;
+ this.stateFiltersSelector = stateFiltersSelector;
+ this.recentsStorageKeyNames = {
+ issues: 'issue-recent-searches',
+ merge_requests: 'merge-request-recent-searches',
+ };
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
});
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}`;
+ const fullPath = this.searchHistoryDropdownElement ?
+ this.searchHistoryDropdownElement.dataset.fullPath : 'project';
+ const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
@@ -35,7 +61,7 @@ class FilteredSearchManager {
.catch((error) => {
if (error.name === 'RecentSearchesServiceError') return undefined;
// eslint-disable-next-line no-new
- new window.Flash('An error occured while parsing recent searches');
+ new Flash('An error occurred while parsing recent searches');
// Gracefully fail to empty array
return [];
})
@@ -53,8 +79,15 @@ class FilteredSearchManager {
});
if (this.filteredSearchInput) {
- this.tokenizer = gl.FilteredSearchTokenizer;
- this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
+ this.tokenizer = FilteredSearchTokenizer;
+ this.dropdownManager = new FilteredSearchDropdownManager({
+ baseEndpoint: this.filteredSearchInput.getAttribute('data-base-endpoint') || '',
+ tokenizer: this.tokenizer,
+ page: this.page,
+ isGroup: this.isGroup,
+ isGroupAncestor: this.isGroupAncestor,
+ filteredSearchTokenKeys: this.filteredSearchTokenKeys,
+ });
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
@@ -66,7 +99,6 @@ class FilteredSearchManager {
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
-
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
}
@@ -82,40 +114,33 @@ class FilteredSearchManager {
}
bindStateEvents() {
- this.stateFilters = document.querySelector('.container-fluid .issues-state-filters');
+ this.stateFilters = document.querySelector(`.container-fluid ${this.stateFiltersSelector}`);
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);
- }
+ this.applyToStateFilters((filterEl) => {
+ filterEl.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);
- }
+ this.applyToStateFilters((filterEl) => {
+ filterEl.removeEventListener('click', this.searchStateWrapper);
+ });
}
}
+ applyToStateFilters(callback) {
+ this.stateFilters.querySelectorAll('a[data-state]').forEach((filterEl) => {
+ if (this.states.indexOf(filterEl.dataset.state) > -1) {
+ callback(filterEl);
+ }
+ });
+ }
+
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
@@ -124,7 +149,7 @@ class FilteredSearchManager {
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
- this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
+ this.checkForBackspaceWrapper = this.checkForBackspace.call(this);
this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
this.editTokenWrapper = this.editToken.bind(this);
@@ -177,22 +202,34 @@ class FilteredSearchManager {
this.unbindStateEvents();
}
- checkForBackspace(e) {
- // 8 = Backspace Key
- // 46 = Delete Key
- if (e.keyCode === 8 || e.keyCode === 46) {
- const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ checkForBackspace() {
+ let backspaceCount = 0;
+
+ // closure for keeping track of the number of backspace keystrokes
+ return (e) => {
+ // 8 = Backspace Key
+ // 46 = Delete Key
+ if (e.keyCode === 8 || e.keyCode === 46) {
+ const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken);
+ const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
+
+ if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
+ backspaceCount += 1;
+
+ if (backspaceCount === 2) {
+ backspaceCount = 0;
+ this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial();
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ }
+ }
- 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();
+ // Reposition dropdown so that it is aligned with cursor
+ this.dropdownManager.updateCurrentDropdownOffset();
+ } else {
+ backspaceCount = 0;
}
-
- // Reposition dropdown so that it is aligned with cursor
- this.dropdownManager.updateCurrentDropdownOffset();
- }
+ };
}
checkForEnter(e) {
@@ -227,11 +264,7 @@ class FilteredSearchManager {
}
addInputContainerFocus() {
- const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
-
- if (inputContainer) {
- inputContainer.classList.add('focus');
- }
+ addClassIfElementExists(this.filteredSearchInput.closest('.filtered-search-box'), 'focus');
}
removeInputContainerFocus(e) {
@@ -255,7 +288,7 @@ class FilteredSearchManager {
e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
- gl.FilteredSearchVisualTokens.selectToken(button, true);
+ FilteredSearchVisualTokens.selectToken(button, true);
this.removeSelectedToken();
}
}
@@ -267,7 +300,7 @@ class FilteredSearchManager {
const isElementTokensContainer = e.target.classList.contains('tokens-container');
if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementTokensContainer) {
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ FilteredSearchVisualTokens.moveInputToTheRight();
this.dropdownManager.resetDropdowns();
}
}
@@ -280,13 +313,13 @@ class FilteredSearchManager {
if (token && canEdit) {
e.preventDefault();
e.stopPropagation();
- gl.FilteredSearchVisualTokens.editToken(token);
+ FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
}
toggleClearSearchButton() {
- const query = gl.DropdownUtils.getSearchQuery();
+ const query = DropdownUtils.getSearchQuery();
const hidden = 'hidden';
const hasHidden = this.clearSearchButton.classList.contains(hidden);
@@ -298,7 +331,7 @@ class FilteredSearchManager {
}
handleInputPlaceholder() {
- const query = gl.DropdownUtils.getSearchQuery();
+ const query = DropdownUtils.getSearchQuery();
const placeholder = 'Search or filter results...';
const currentPlaceholder = this.filteredSearchInput.placeholder;
@@ -318,7 +351,7 @@ class FilteredSearchManager {
}
removeSelectedToken() {
- gl.FilteredSearchVisualTokens.removeSelectedToken();
+ FilteredSearchVisualTokens.removeSelectedToken();
this.handleInputPlaceholder();
this.toggleClearSearchButton();
this.dropdownManager.updateCurrentDropdownOffset();
@@ -335,7 +368,14 @@ class FilteredSearchManager {
const removeElements = [];
[].forEach.call(this.tokensContainer.children, (t) => {
- if (t.classList.contains('js-visual-token')) {
+ let canClearToken = t.classList.contains('js-visual-token');
+
+ if (canClearToken) {
+ const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(t);
+ canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue);
+ }
+
+ if (canClearToken) {
removeElements.push(t);
}
});
@@ -359,28 +399,28 @@ class FilteredSearchManager {
const { tokens, searchToken }
= this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (isLastVisualTokenValid) {
tokens.forEach((t) => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
- gl.FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`);
+ FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`);
});
const fragments = searchToken.split(':');
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
- const tokenKey = inputValues.last();
+ const tokenKey = _.last(inputValues);
if (inputValues.length > 1) {
inputValues.pop();
const searchTerms = inputValues.join(' ');
input.value = input.value.replace(searchTerms, '');
- gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
+ FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenKey);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenKey);
input.value = input.value.replace(`${tokenKey}:`, '');
}
} else {
@@ -388,7 +428,7 @@ class FilteredSearchManager {
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
- gl.FilteredSearchVisualTokens.addFilterVisualToken(searchToken);
+ FilteredSearchVisualTokens.addFilterVisualToken(searchToken);
// Trim the last space as seen in the if statement above
input.value = input.value.replace(searchToken, '').trim();
@@ -404,7 +444,7 @@ class FilteredSearchManager {
saveCurrentSearchQuery() {
// Don't save before we have fetched the already saved searches
this.fetchingRecentSearchesPromise.then(() => {
- const searchQuery = gl.DropdownUtils.getSearchQuery();
+ const searchQuery = DropdownUtils.getSearchQuery();
if (searchQuery.length > 0) {
const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
this.recentSearchesService.save(resultantSearches);
@@ -414,8 +454,14 @@ class FilteredSearchManager {
});
}
+ // allows for modifying params array when a param can't be included in the URL (e.g. Service Desk)
+ getAllParams(urlParams) {
+ return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams;
+ }
+
loadSearchParamsFromURL() {
- const params = gl.utils.getUrlParamsArray();
+ const urlParams = getUrlParamsArray();
+ const params = this.getAllParams(urlParams);
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
@@ -430,7 +476,7 @@ class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey,
condition.value,
canEdit,
@@ -442,8 +488,13 @@ class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) {
- const indexOf = keyParam.indexOf('_');
- const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam;
+ // Use lastIndexOf because the token key is allowed to contain underscore
+ // e.g. 'my_reaction' is the token key of 'my_reaction_emoji'
+ const lastIndexOf = keyParam.lastIndexOf('_');
+ let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam;
+ // Replace underscore with hyphen in the sanitizedkey.
+ // e.g. 'my_reaction' => 'my-reaction'
+ sanitizedKey = sanitizedKey.replace('_', '-');
const symbol = match.symbol;
let quotationsToUse = '';
@@ -453,8 +504,8 @@ class FilteredSearchManager {
}
hasFilteredSearch = true;
- const canEdit = this.canEdit && this.canEdit(sanitizedKey);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
+ FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit,
@@ -465,7 +516,7 @@ class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
@@ -473,7 +524,7 @@ class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
@@ -505,20 +556,23 @@ class FilteredSearchManager {
search(state = null) {
const paths = [];
- const searchQuery = gl.DropdownUtils.getSearchQuery();
+ const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
- const currentState = state || gl.utils.getParameterByName('state') || 'opened';
+ const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase());
const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
- const keyParam = param ? `${token.key}_${param}` : token.key;
+ // Replace hyphen with underscore to use as request parameter
+ // e.g. 'my-reaction' => 'my_reaction'
+ const underscoredKey = token.key.replace('-', '_');
+ const keyParam = param ? `${underscoredKey}_${param}` : underscoredKey;
let tokenPath = '';
if (condition) {
@@ -547,7 +601,7 @@ class FilteredSearchManager {
if (this.updateObject) {
this.updateObject(parameterizedUrl);
} else {
- gl.utils.visitUrl(parameterizedUrl);
+ visitUrl(parameterizedUrl);
}
}
@@ -587,6 +641,3 @@ class FilteredSearchManager {
return true;
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchManager = FilteredSearchManager;
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 025d4d8795b..087ef5cd6f2 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -4,26 +4,42 @@ const tokenKeys = [{
param: 'username',
symbol: '@',
icon: 'pencil',
+ tag: '@author',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
icon: 'user',
+ tag: '@assignee',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
icon: 'clock-o',
+ tag: '%milestone',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'tag',
+ tag: '~label',
}];
+if (gon.current_user_id) {
+ // Appending tokenkeys only logged-in
+ tokenKeys.push({
+ key: 'my-reaction',
+ type: 'string',
+ param: 'emoji',
+ symbol: '',
+ icon: 'thumbs-up',
+ tag: 'emoji',
+ });
+}
+
const alternativeTokenKeys = [{
key: 'label',
type: 'string',
@@ -55,7 +71,7 @@ const conditions = [{
value: 'none',
}];
-class FilteredSearchTokenKeys {
+export default class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
}
@@ -84,6 +100,10 @@ class FilteredSearchTokenKeys {
return tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key;
+ // Replace hyphen with underscore to compare keyParam with tokenKeyParam
+ // e.g. 'my-reaction' => 'my_reaction'
+ tokenKeyParam = tokenKeyParam.replace('-', '_');
+
if (tokenKey.param) {
tokenKeyParam += `_${tokenKey.param}`;
}
@@ -101,6 +121,3 @@ class FilteredSearchTokenKeys {
.find(condition => condition.tokenKey === key && condition.value === value) || null;
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index f2e66503e5e..d75610f6d68 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -1,6 +1,6 @@
import './filtered_search_token_keys';
-class FilteredSearchTokenizer {
+export default class FilteredSearchTokenizer {
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)
@@ -50,6 +50,3 @@ class FilteredSearchTokenizer {
};
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchTokenizer = FilteredSearchTokenizer;
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 e9278140af0..600024c21c3 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,9 +1,12 @@
-import AjaxCache from '../lib/utils/ajax_cache';
-import '../flash'; /* global Flash */
+import _ from 'underscore';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import { objectToQueryString } from '~/lib/utils/common_utils';
+import Flash from '../flash';
import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
+import DropdownUtils from './dropdown_utils';
-class FilteredSearchVisualTokens {
+export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
@@ -14,6 +17,21 @@ class FilteredSearchVisualTokens {
};
}
+ /**
+ * Returns a computed API endpoint
+ * and query string composed of values from endpointQueryParams
+ * @param {String} endpoint
+ * @param {String} endpointQueryParams
+ */
+ static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
+ if (!endpointQueryParams) {
+ return endpoint;
+ }
+
+ const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
+ return `${endpoint}?${queryString}`;
+ }
+
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token .selectable.selected');
[].forEach.call(otherTokens, t => t.classList.remove('selected'));
@@ -38,49 +56,70 @@ class FilteredSearchVisualTokens {
}
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="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="name"></div>
<div class="value-container">
<div class="value"></div>
- ${removeTokenMarkup}
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
</div>
</div>
`;
}
+ static setTokenStyle(tokenContainer, backgroundColor, textColor) {
+ const token = tokenContainer;
+
+ // Labels with linear gradient should not override default background color
+ if (backgroundColor.indexOf('linear-gradient') === -1) {
+ token.style.backgroundColor = backgroundColor;
+ }
+
+ token.style.color = textColor;
+
+ if (textColor === '#FFFFFF') {
+ const removeToken = token.querySelector('.remove-token');
+ removeToken.classList.add('inverted');
+ }
+
+ return token;
+ }
+
+ static preprocessLabel(labelsEndpoint, labels) {
+ let processed = labels;
+
+ if (!labels.preprocessed) {
+ processed = DropdownUtils.duplicateLabelPreprocessing(labels);
+ AjaxCache.override(labelsEndpoint, processed);
+ processed.preprocessed = true;
+ }
+
+ return processed;
+ }
+
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
- const labelsEndpoint = `${baseEndpoint}/labels.json`;
+ const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
+ `${baseEndpoint}/labels.json`,
+ filteredSearchInput.dataset.endpointQueryParams,
+ );
return AjaxCache.retrieve(labelsEndpoint)
- .then((labels) => {
- const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
-
- if (!matchingLabel) {
- return;
- }
+ .then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
+ .then((labels) => {
+ const matchingLabel = (labels || []).find(label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue);
- const tokenValueStyle = tokenValueContainer.style;
- tokenValueStyle.backgroundColor = matchingLabel.color;
- tokenValueStyle.color = matchingLabel.text_color;
+ if (!matchingLabel) {
+ return;
+ }
- if (matchingLabel.text_color === '#FFFFFF') {
- const removeToken = tokenValueContainer.querySelector('.remove-token');
- removeToken.classList.add('inverted');
- }
- })
- .catch(() => new Flash('An error occurred while fetching label colors.'));
+ FilteredSearchVisualTokens
+ .setTokenStyle(tokenValueContainer, matchingLabel.color, matchingLabel.text_color);
+ })
+ .catch(() => new Flash('An error occurred while fetching label colors.'));
}
static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
@@ -98,8 +137,8 @@ class FilteredSearchVisualTokens {
/* 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}
+ <img class="avatar s20" src="${user.avatar_url}" alt="">
+ ${_.escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
@@ -107,6 +146,23 @@ class FilteredSearchVisualTokens {
.catch(() => { });
}
+ static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
+ const container = tokenValueContainer;
+ const element = tokenValueElement;
+
+ return import(/* webpackChunkName: 'emoji' */ '../emoji')
+ .then((Emoji) => {
+ if (!Emoji.isEmojiNameValid(tokenValue)) {
+ return;
+ }
+
+ container.dataset.originalValue = tokenValue;
+ element.innerHTML = Emoji.glEmojiTag(tokenValue);
+ })
+ // ignore error and leave emoji name in the search bar
+ .catch(() => { });
+ }
+
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
@@ -119,6 +175,10 @@ class FilteredSearchVisualTokens {
FilteredSearchVisualTokens.updateUserTokenAppearance(
tokenValueContainer, tokenValueElement, tokenValue,
);
+ } else if (tokenType === 'my-reaction') {
+ FilteredSearchVisualTokens.updateEmojiTokenAppearance(
+ tokenValueContainer, tokenValueElement, tokenValue,
+ );
}
}
@@ -219,11 +279,11 @@ class FilteredSearchVisualTokens {
static tokenizeInput() {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const { isLastVisualTokenValid } =
- gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (input.value) {
if (isLastVisualTokenValid) {
- gl.FilteredSearchVisualTokens.addSearchVisualToken(input.value);
+ FilteredSearchVisualTokens.addSearchVisualToken(input.value);
} else {
FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement(input.value);
}
@@ -284,12 +344,12 @@ class FilteredSearchVisualTokens {
if (!tokenContainer.lastElementChild.isEqualNode(inputLi)) {
const { isLastVisualTokenValid } =
- gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!isLastVisualTokenValid) {
- const lastPartial = gl.FilteredSearchVisualTokens.getLastTokenPartial();
- gl.FilteredSearchVisualTokens.removeLastTokenPartial();
- gl.FilteredSearchVisualTokens.addSearchVisualToken(lastPartial);
+ const lastPartial = FilteredSearchVisualTokens.getLastTokenPartial();
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ FilteredSearchVisualTokens.addSearchVisualToken(lastPartial);
}
tokenContainer.removeChild(inputLi);
@@ -297,6 +357,3 @@ class FilteredSearchVisualTokens {
}
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchVisualTokens = FilteredSearchVisualTokens;
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
index 27e49d4fb96..f9338b82acf 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_root.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
+import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content.vue';
import eventHub from './event_hub';
class RecentSearchesRoot {
@@ -32,6 +32,9 @@ class RecentSearchesRoot {
const state = this.store.state;
this.vm = new Vue({
el: this.wrapperElement,
+ components: {
+ RecentSearchesDropdownContent,
+ },
data() { return state; },
template: `
<recent-searches-dropdown-content
@@ -40,9 +43,6 @@ class RecentSearchesRoot {
:allowed-keys="allowedKeys"
/>
`,
- components: {
- 'recent-searches-dropdown-content': RecentSearchesDropdownContent,
- },
});
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index ccff8f0ace7..a0af2875ab5 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,71 +1,103 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, no-param-reassign, quotes, quote-props, prefer-template, comma-dangle, max-len */
-
-window.Flash = (function() {
- var hideFlash;
-
- hideFlash = function() {
- return $(this).fadeOut();
- };
-
- /**
- * 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';
- }
- if (parent == null) {
- parent = null;
- }
- if (parent) {
- this.flashContainer = parent.find('.flash-container');
- } else {
- this.flashContainer = $('.flash-container-page');
- }
- this.flashContainer.html('');
- flash = $('<div/>', {
- "class": "flash-" + type
- });
- flash.on('click', hideFlash);
- textDiv = $('<div/>', {
- "class": 'flash-text',
- text: message
+import _ from 'underscore';
+
+const hideFlash = (flashEl, fadeTransition = true) => {
+ if (fadeTransition) {
+ Object.assign(flashEl.style, {
+ transition: 'opacity .3s',
+ opacity: '0',
});
- textDiv.appendTo(flash);
+ }
- if (actionConfig) {
- const actionLinkConfig = {
- class: 'flash-action',
- href: actionConfig.href || '#',
- text: actionConfig.title
- };
+ flashEl.addEventListener('transitionend', () => {
+ flashEl.remove();
+ if (document.body.classList.contains('flash-shown')) document.body.classList.remove('flash-shown');
+ }, {
+ once: true,
+ passive: true,
+ });
- if (!actionConfig.href) {
- actionLinkConfig.role = 'button';
- }
+ if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend'));
+};
- actionLink = $('<a/>', actionLinkConfig);
+const createAction = config => `
+ <a
+ href="${config.href || '#'}"
+ class="flash-action"
+ ${config.href ? '' : 'role="button"'}
+ >
+ ${_.escape(config.title)}
+ </a>
+`;
- actionLink.appendTo(flash);
- this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler);
- }
- if (this.flashContainer.parent().hasClass('content-wrapper')) {
- textDiv.addClass('container-fluid container-limited');
+const createFlashEl = (message, type, isInContentWrapper = false) => `
+ <div
+ class="flash-${type}"
+ >
+ <div
+ class="flash-text ${isInContentWrapper ? 'container-fluid container-limited' : ''}"
+ >
+ ${_.escape(message)}
+ </div>
+ </div>
+`;
+
+const removeFlashClickListener = (flashEl, fadeTransition) => {
+ flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+};
+
+/*
+ * 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 text
+ * @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} actonConfig Map of config to show action on banner
+ * @param {String} href URL to which action config should point to (default: '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
+ */
+const createFlash = function createFlash(
+ message,
+ type = 'alert',
+ parent = document,
+ actionConfig = null,
+ fadeTransition = true,
+ addBodyClass = false,
+) {
+ const flashContainer = parent.querySelector('.flash-container');
+
+ if (!flashContainer) return null;
+
+ const isInContentWrapper = flashContainer.parentNode.classList.contains('content-wrapper');
+
+ flashContainer.innerHTML = createFlashEl(message, type, isInContentWrapper);
+
+ const flashEl = flashContainer.querySelector(`.flash-${type}`);
+ removeFlashClickListener(flashEl, fadeTransition);
+
+ if (actionConfig) {
+ flashEl.innerHTML += createAction(actionConfig);
+
+ if (actionConfig.clickHandler) {
+ flashEl.querySelector('.flash-action').addEventListener('click', e => actionConfig.clickHandler(e));
}
- flash.appendTo(this.flashContainer);
- this.flashContainer.show();
}
- return Flash;
-})();
+ flashContainer.style.display = 'block';
+
+ if (addBodyClass) document.body.classList.add('flash-shown');
+
+ return flashContainer;
+};
+
+export {
+ createFlash as default,
+ createFlashEl,
+ createAction,
+ hideFlash,
+ removeFlashClickListener,
+};
+window.Flash = createFlash;
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
new file mode 100644
index 00000000000..8b4f3b05ee7
--- /dev/null
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -0,0 +1,189 @@
+import bp from './breakpoints';
+
+const HIDE_INTERVAL_TIMEOUT = 300;
+const IS_OVER_CLASS = 'is-over';
+const IS_ABOVE_CLASS = 'is-above';
+const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out';
+let currentOpenMenu = null;
+let menuCornerLocs;
+let timeoutId;
+let sidebar;
+
+export const mousePos = [];
+
+export const setSidebar = (el) => { sidebar = el; };
+export const getOpenMenu = () => currentOpenMenu;
+export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
+
+export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
+
+let headerHeight = 50;
+
+export const getHeaderHeight = () => headerHeight;
+
+export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains('sidebar-collapsed-desktop');
+
+export const canShowActiveSubItems = (el) => {
+ if (el.classList.contains('active') && !isSidebarCollapsed()) {
+ return false;
+ }
+
+ return true;
+};
+
+export const canShowSubItems = () => bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md' || bp.getBreakpointSize() === 'lg';
+
+export const getHideSubItemsInterval = () => {
+ if (!currentOpenMenu || !mousePos.length) return 0;
+
+ const currentMousePos = mousePos[mousePos.length - 1];
+ const prevMousePos = mousePos[0];
+ const currentMousePosY = currentMousePos.y;
+ const [menuTop, menuBottom] = menuCornerLocs;
+
+ if (currentMousePosY < menuTop.y ||
+ currentMousePosY > menuBottom.y) return 0;
+
+ if (slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) &&
+ slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop)) {
+ return HIDE_INTERVAL_TIMEOUT;
+ }
+
+ return 0;
+};
+
+export const calculateTop = (boundingRect, outerHeight) => {
+ const windowHeight = window.innerHeight;
+ const bottomOverflow = windowHeight - (boundingRect.top + outerHeight);
+
+ return bottomOverflow < 0 ? (boundingRect.top - outerHeight) + boundingRect.height :
+ boundingRect.top;
+};
+
+export const hideMenu = (el) => {
+ if (!el) return;
+
+ const parentEl = el.parentNode;
+
+ el.style.display = ''; // eslint-disable-line no-param-reassign
+ el.style.transform = ''; // eslint-disable-line no-param-reassign
+ el.classList.remove(IS_ABOVE_CLASS);
+ parentEl.classList.remove(IS_OVER_CLASS);
+ parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS);
+
+ setOpenMenu();
+};
+
+export const moveSubItemsToPosition = (el, subItems) => {
+ const boundingRect = el.getBoundingClientRect();
+ const top = calculateTop(boundingRect, subItems.offsetHeight);
+ const left = sidebar ? sidebar.offsetWidth : 50;
+ const isAbove = top < boundingRect.top;
+
+ subItems.classList.add('fly-out-list');
+ subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - headerHeight}px, 0)`; // eslint-disable-line no-param-reassign
+
+ const subItemsRect = subItems.getBoundingClientRect();
+
+ menuCornerLocs = [
+ {
+ x: subItemsRect.left, // left position of the sub items
+ y: subItemsRect.top, // top position of the sub items
+ },
+ {
+ x: subItemsRect.left, // left position of the sub items
+ y: subItemsRect.top + subItemsRect.height, // bottom position of the sub items
+ },
+ ];
+
+ if (isAbove) {
+ subItems.classList.add(IS_ABOVE_CLASS);
+ }
+};
+
+export const showSubLevelItems = (el) => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+ const isIconOnly = subItems && subItems.classList.contains('is-fly-out-only');
+
+ if (!canShowSubItems() || !canShowActiveSubItems(el)) return;
+
+ el.classList.add(IS_OVER_CLASS);
+
+ if (!subItems || (!isSidebarCollapsed() && isIconOnly)) return;
+
+ subItems.style.display = 'block';
+ el.classList.add(IS_SHOWING_FLY_OUT_CLASS);
+
+ setOpenMenu(subItems);
+ moveSubItemsToPosition(el, subItems);
+};
+
+export const mouseEnterTopItems = (el, timeout = getHideSubItemsInterval()) => {
+ clearTimeout(timeoutId);
+
+ timeoutId = setTimeout(() => {
+ if (currentOpenMenu) hideMenu(currentOpenMenu);
+
+ showSubLevelItems(el);
+ }, timeout);
+};
+
+export const mouseLeaveTopItem = (el) => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+
+ if (!canShowSubItems() || !canShowActiveSubItems(el) ||
+ (subItems && subItems === currentOpenMenu)) return;
+
+ el.classList.remove(IS_OVER_CLASS);
+};
+
+export const documentMouseMove = (e) => {
+ mousePos.push({
+ x: e.clientX,
+ y: e.clientY,
+ });
+
+ if (mousePos.length > 6) mousePos.shift();
+};
+
+export const subItemsMouseLeave = (relatedTarget) => {
+ clearTimeout(timeoutId);
+
+ if (relatedTarget && !relatedTarget.closest(`.${IS_OVER_CLASS}`)) {
+ hideMenu(currentOpenMenu);
+ }
+};
+
+export default () => {
+ sidebar = document.querySelector('.nav-sidebar');
+
+ if (!sidebar) return;
+
+ const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')];
+
+ const topItems = sidebar.querySelector('.sidebar-top-level-items');
+ if (topItems) {
+ sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => {
+ clearTimeout(timeoutId);
+
+ timeoutId = setTimeout(() => {
+ if (currentOpenMenu) hideMenu(currentOpenMenu);
+ }, getHideSubItemsInterval());
+ });
+ }
+
+ headerHeight = document.querySelector('.nav-sidebar').offsetTop;
+
+ items.forEach((el) => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+
+ if (subItems) {
+ subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget));
+ }
+
+ el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));
+ el.addEventListener('mouseleave', e => mouseLeaveTopItem(e.currentTarget));
+ });
+
+ document.addEventListener('mousemove', documentMouseMove);
+};
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 105762cb1ba..8259133c95b 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,8 +1,7 @@
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-import { glEmojiTag } from '~/behaviors/gl_emoji';
-import glRegexp from '~/lib/utils/regexp';
-import AjaxCache from '~/lib/utils/ajax_cache';
+import $ from 'jquery';
+import _ from 'underscore';
+import glRegexp from './lib/utils/regexp';
+import AjaxCache from './lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
@@ -33,6 +32,7 @@ class GfmAutoComplete {
this.input.each((i, input) => {
const $input = $(input);
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
+ $input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
// Needed for quick actions with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
@@ -58,12 +58,12 @@ class GfmAutoComplete {
displayTpl(value) {
if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
// eslint-disable-next-line no-template-curly-in-string
- let tpl = '<li>/${name}';
+ let tpl = '<li><span class="name">/${name}</span>';
if (value.aliases.length > 0) {
- tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
+ tpl += ' <small class="aliases">(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
- tpl += ' <small><%- params.join(" ") %></small>';
+ tpl += ' <small class="params"><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
@@ -132,9 +132,8 @@ class GfmAutoComplete {
callbacks: {
...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);
+ const match = regexp.exec(subtext);
return match && match.length ? match[1] : null;
},
@@ -288,6 +287,10 @@ class GfmAutoComplete {
}
setupLabels($input) {
+ const fetchData = this.fetchData.bind(this);
+ const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
+ let command = '';
+
$input.atwho({
at: '~',
alias: 'labels',
@@ -310,8 +313,45 @@ class GfmAutoComplete {
title: sanitize(m.title),
color: m.color,
search: m.title,
+ set: m.set,
}));
},
+ matcher(flag, subtext) {
+ const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
+ const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext);
+
+ // Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands.
+ command = subtextNodes.find((node) => {
+ if (node === LABEL_COMMAND.LABEL ||
+ node === LABEL_COMMAND.RELABEL ||
+ node === LABEL_COMMAND.UNLABEL) { return node; }
+ return null;
+ });
+
+ return match && match.length ? match[1] : null;
+ },
+ filter(query, data, searchKey) {
+ if (GfmAutoComplete.isLoading(data)) {
+ fetchData(this.$inputor, this.at);
+ return data;
+ }
+
+ if (data === GfmAutoComplete.defaultLoadingData) {
+ return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
+ }
+
+ // The `LABEL_COMMAND.RELABEL` is intentionally skipped
+ // because we want to return all the labels (unfiltered) for that command.
+ if (command === LABEL_COMMAND.LABEL) {
+ // Return labels with set: undefined.
+ return data.filter(label => !label.set);
+ } else if (command === LABEL_COMMAND.UNLABEL) {
+ // Return labels with set: true.
+ return data.filter(label => label.set);
+ }
+
+ return data;
+ },
},
});
}
@@ -339,27 +379,15 @@ class GfmAutoComplete {
let resultantValue = value;
if (value && !this.setting.skipSpecialCharacterTest) {
const withoutAt = value.substring(1);
- if (withoutAt && /[^\w\d]/.test(withoutAt)) {
+ const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/;
+ if (withoutAt && regex.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, '\\$&');
-
- 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);
+ const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
if (match) {
return match[1];
@@ -375,7 +403,12 @@ class GfmAutoComplete {
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
- this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
+ import(/* webpackChunkName: 'emoji' */ './emoji')
+ .then(({ validEmojiNames, glEmojiTag }) => {
+ this.loadData($input, at, validEmojiNames);
+ GfmAutoComplete.glEmojiTag = glEmojiTag;
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
} else {
AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
.then((data) => {
@@ -398,6 +431,13 @@ class GfmAutoComplete {
this.cachedData = {};
}
+ destroy() {
+ this.input.each((i, input) => {
+ const $input = $(input);
+ $input.atwho('destroy');
+ });
+ }
+
static isLoading(data) {
let dataToInspect = data;
if (data && data.length > 0) {
@@ -408,8 +448,27 @@ class GfmAutoComplete {
return dataToInspect &&
(dataToInspect === loadingState || dataToInspect.name === loadingState);
}
+
+ static defaultMatcher(flag, subtext, controllers) {
+ // 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(controllers).join('|');
+ const atSymbolsWithoutBar = Object.keys(controllers).join('');
+ const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
+ const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
+
+ 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');
+
+ return regexp.exec(targetSubtext);
+ }
}
+GfmAutoComplete.regexSubtext = new RegExp(/\s+/g);
+
GfmAutoComplete.defaultLoadingData = ['loading'];
GfmAutoComplete.atTypeMap = {
@@ -423,12 +482,14 @@ GfmAutoComplete.atTypeMap = {
};
// Emoji
+GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
templateFunction(name) {
- return `<li>
- ${name} ${glEmojiTag(name)}
- </li>
- `;
+ // glEmojiTag helper is loaded on-demand in fetchData()
+ if (GfmAutoComplete.glEmojiTag) {
+ return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
+ }
+ return `<li>${name}</li>`;
},
};
// Team Members
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 3babe273100..86b34a6e360 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,8 +1,58 @@
-/* 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 */
+/* eslint-disable func-names, no-underscore-dangle, 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 $ from 'jquery';
+import _ from 'underscore';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import axios from './lib/utils/axios_utils';
+import { visitUrl } from './lib/utils/url_utility';
import { isObject } from './lib/utils/type_utility';
-var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote;
+var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput;
+
+GitLabDropdownInput = (function() {
+ function GitLabDropdownInput(input, options) {
+ var $inputContainer, $clearButton;
+ var _this = this;
+ this.input = input;
+ this.options = options;
+ this.fieldName = this.options.fieldName || 'field-name';
+ $inputContainer = this.input.parent();
+ $clearButton = $inputContainer.find('.js-dropdown-input-clear');
+ $clearButton.on('click', (function(_this) {
+ // Clear click
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.input.val('').trigger('input').focus();
+ };
+ })(this));
+
+ this.input
+ .on('keydown', function (e) {
+ var keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault();
+ }
+ })
+ .on('input', function(e) {
+ var val = e.currentTarget.value || _this.options.inputFieldName;
+ val = val.split(' ').join('-') // replaces space with dash
+ .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() // replace non alphanumeric
+ .replace(/(-)\1+/g, '-'); // replace repeated dashes
+ _this.cb(_this.options.fieldName, val, {}, true);
+ _this.input.closest('.dropdown')
+ .find('.dropdown-toggle-text')
+ .text(val);
+ });
+ }
+
+ GitLabDropdownInput.prototype.onInput = function(cb) {
+ this.cb = cb;
+ };
+
+ return GitLabDropdownInput;
+})();
GitLabDropdownFilter = (function() {
var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS;
@@ -114,7 +164,7 @@ GitLabDropdownFilter = (function() {
} else {
elements = this.options.elements();
if (search_text) {
- return elements.each(function() {
+ elements.each(function() {
var $el, matches;
$el = $(this);
matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
@@ -127,8 +177,10 @@ GitLabDropdownFilter = (function() {
}
});
} else {
- return elements.show().removeClass('option-hidden');
+ elements.show().removeClass('option-hidden');
}
+
+ elements.parent().find('.dropdown-menu-empty-item').toggleClass('hidden', elements.is(':visible'));
}
};
@@ -163,32 +215,24 @@ GitLabDropdownRemote = (function() {
};
GitLabDropdownRemote.prototype.fetchData = function() {
- return $.ajax({
- url: this.dataEndpoint,
- dataType: this.options.dataType,
- beforeSend: (function(_this) {
- return function() {
- if (_this.options.beforeSend) {
- return _this.options.beforeSend();
- }
- };
- })(this),
- success: (function(_this) {
- return function(data) {
- if (_this.options.success) {
- return _this.options.success(data);
- }
- };
- })(this)
- });
- // Fetch the data through ajax if the data is a string
+ if (this.options.beforeSend) {
+ this.options.beforeSend();
+ }
+
+ // Fetch the data through ajax if the data is a string
+ return axios.get(this.dataEndpoint)
+ .then(({ data }) => {
+ if (this.options.success) {
+ return this.options.success(data);
+ }
+ });
};
return GitLabDropdownRemote;
})();
GitLabDropdown = (function() {
- var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex;
+ var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex;
LOADING_CLASS = "is-loading";
@@ -200,13 +244,15 @@ GitLabDropdown = (function() {
currentIndex = -1;
- NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link';
+ NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)";
CURSOR_SELECT_SCROLL_PADDING = 5;
- FILTER_INPUT = '.dropdown-input .dropdown-input-field';
+ FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)';
+
+ NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter';
function GitLabDropdown(el1, options) {
var searchFields, selector, self;
@@ -221,6 +267,7 @@ GitLabDropdown = (function() {
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
// Set Defaults
this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
+ this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
this.highlight = !!this.options.highlight;
this.filterInputBlur = this.options.filterInputBlur != null
? this.options.filterInputBlur
@@ -248,7 +295,7 @@ GitLabDropdown = (function() {
return function(data) {
_this.fullData = data;
_this.parseData(_this.fullData);
- _this.focusTextInput(true);
+ _this.focusTextInput();
if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') {
return _this.filter.input.trigger('input');
}
@@ -259,6 +306,10 @@ GitLabDropdown = (function() {
});
}
}
+ if (this.noFilterInput.length) {
+ this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options);
+ this.plainInput.onInput(this.addInput.bind(this));
+ }
// Init filterable
if (this.options.filterable) {
this.filter = new GitLabDropdownFilter(this.filterInput, {
@@ -276,7 +327,7 @@ GitLabDropdown = (function() {
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
- return $(selector);
+ return $(selector, this.instance.dropdown);
};
})(this),
data: (function(_this) {
@@ -432,11 +483,11 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.shouldPropagate = function(e) {
var $target;
- if (this.options.multiSelect) {
+ if (this.options.multiSelect || this.options.shouldPropagate === false) {
$target = $(e.target);
if ($target && !$target.hasClass('dropdown-menu-close') &&
!$target.hasClass('dropdown-menu-close-icon') &&
- !$target.data('is-link')) {
+ !$target.data('isLink')) {
e.stopPropagation();
return false;
} else {
@@ -459,10 +510,11 @@ GitLabDropdown = (function() {
const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
+ const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open');
const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
// Makes indeterminate items effective
- if (this.fullData && hasFilterBulkUpdate) {
+ if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) {
this.parseData(this.fullData);
}
@@ -492,10 +544,11 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.positionMenuAbove = function() {
- var $button = $(this.el);
var $menu = this.dropdown.find('.dropdown-menu');
- $menu.css('top', ($button.height() + $menu.height()) * -1);
+ $menu.addClass('dropdown-open-top');
+ $menu.css('top', 'initial');
+ $menu.css('bottom', '100%');
};
GitLabDropdown.prototype.hidden = function(e) {
@@ -525,7 +578,7 @@ GitLabDropdown = (function() {
for (var i = 0; i < html.length; i += 1) {
var el = html[i];
- if (el instanceof jQuery) {
+ if (el instanceof $) {
el = el.get(0);
}
@@ -556,7 +609,20 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.renderItem = function(data, group, index) {
- var field, fieldName, html, selected, text, url, value;
+ var field, fieldName, html, selected, text, url, value, rowHidden;
+
+ if (!this.options.renderRow) {
+ value = this.options.id ? this.options.id(data) : data.id;
+
+ if (value) {
+ value = value.toString().replace(/'/g, '\\\'');
+ }
+ }
+
+ // Hide element
+ if (this.options.hideRow && this.options.hideRow(value)) {
+ rowHidden = true;
+ }
if (group == null) {
group = false;
}
@@ -565,6 +631,7 @@ GitLabDropdown = (function() {
index = false;
}
html = document.createElement('li');
+
if (data === 'divider' || data === 'separator') {
html.className = data;
return html;
@@ -580,14 +647,16 @@ GitLabDropdown = (function() {
html = this.options.renderRow.call(this.options, data, this);
} else {
if (!selected) {
- value = this.options.id ? this.options.id(data) : data.id;
fieldName = this.options.fieldName;
- if (value) { value = value.toString().replace(/'/g, '\\\''); }
-
- field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
- if (field.length) {
- selected = true;
+ if (value) {
+ field = this.dropdown.parent().find(`input[name='${fieldName}'][value='${value}']`);
+ if (field.length) {
+ selected = true;
+ }
+ } else {
+ field = this.dropdown.parent().find(`input[name='${fieldName}']`);
+ selected = !field.length;
}
}
// Set URL
@@ -644,7 +713,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.noResults = function() {
var html;
- return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>";
+ return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>';
};
GitLabDropdown.prototype.rowClicked = function(el) {
@@ -679,7 +748,7 @@ GitLabDropdown = (function() {
: selectedObject.id;
if (isInput) {
field = $(this.el);
- } else if (value) {
+ } else if (value != null) {
field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']");
}
@@ -687,7 +756,7 @@ GitLabDropdown = (function() {
return;
}
- if (el.hasClass(ACTIVE_CLASS)) {
+ if (el.hasClass(ACTIVE_CLASS) && value !== 0) {
isMarking = false;
el.removeClass(ACTIVE_CLASS);
if (field && field.length) {
@@ -728,25 +797,27 @@ GitLabDropdown = (function() {
return [selectedObject, isMarking];
};
- GitLabDropdown.prototype.focusTextInput = function(triggerFocus = false) {
+ GitLabDropdown.prototype.focusTextInput = function() {
if (this.options.filterable) {
- $(':focus').blur();
+ const initialScrollTop = $(window).scrollTop();
- this.dropdown.one('transitionend', () => {
+ if (this.dropdown.is('.open')) {
this.filterInput.focus();
- });
+ }
- if (triggerFocus) {
- // This triggers after a ajax request
- // in case of slow requests, the dropdown transition could already be finished
- this.dropdown.trigger('transitionend');
+ if ($(window).scrollTop() < initialScrollTop) {
+ $(window).scrollTop(initialScrollTop);
}
}
};
- GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) {
+ GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) {
var $input;
// Create hidden input for form
+ if (single) {
+ $('input[name="' + fieldName + '"]').remove();
+ }
+
$input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value);
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
@@ -762,7 +833,7 @@ GitLabDropdown = (function() {
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
}
- return this.dropdown.before($input);
+ this.dropdown.before($input).trigger('change');
};
GitLabDropdown.prototype.selectRowAtIndex = function(index) {
@@ -781,9 +852,9 @@ GitLabDropdown = (function() {
if ($el.length) {
var href = $el.attr('href');
if (href && href !== '#') {
- gl.utils.visitUrl(href);
+ visitUrl(href);
} else {
- $el.first().trigger('click');
+ $el.trigger('click');
}
}
};
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 0add7075254..972b2252acb 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/**
* This class overrides the browser's validation error bubbles, displaying custom
* error messages for invalid fields instead. To begin validating any form, add the
@@ -54,7 +56,7 @@ const inputErrorClass = 'gl-field-error-outline';
const errorAnchorSelector = '.gl-field-error-anchor';
const ignoreInputSelector = '.gl-field-error-ignore';
-class GlFieldError {
+export default class GlFieldError {
constructor({ input, formErrors }) {
this.inputElement = $(input);
this.inputDomElement = this.inputElement.get(0);
@@ -159,6 +161,3 @@ class GlFieldError {
this.fieldErrorElement.hide();
}
}
-
-window.gl = window.gl || {};
-window.gl.GlFieldError = GlFieldError;
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index 4bef60264bb..b9c51045b1d 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,42 +1,41 @@
-/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */
-
-import './gl_field_error';
+import $ from 'jquery';
+import GlFieldError from './gl_field_error';
const customValidationFlag = 'gl-field-error-ignore';
-class GlFieldErrors {
+export default class GlFieldErrors {
constructor(form) {
this.form = $(form);
this.state = {
inputs: [],
- valid: false
+ valid: false,
};
this.initValidators();
}
- initValidators () {
+ initValidators() {
// register selectors here as needed
const validateSelectors = [':text', ':password', '[type=email]']
- .map((selector) => `input${selector}`).join(',');
+ .map(selector => `input${selector}`).join(',');
this.state.inputs = this.form.find(validateSelectors).toArray()
- .filter((input) => !input.classList.contains(customValidationFlag))
- .map((input) => new window.gl.GlFieldError({ input, formErrors: this }));
+ .filter(input => !input.classList.contains(customValidationFlag))
+ .map(input => new GlFieldError({ input, formErrors: this }));
- this.form.on('submit', this.catchInvalidFormSubmit);
+ this.form.on('submit', GlFieldErrors.catchInvalidFormSubmit);
}
/* Neccessary to prevent intercept and override invalid form submit
* because Safari & iOS quietly allow form submission when form is invalid
* and prevents disabling of invalid submit button by application.js */
- catchInvalidFormSubmit (event) {
- const $form = $(event.currentTarget);
+ static catchInvalidFormSubmit(e) {
+ const $form = $(e.currentTarget);
if (!$form.attr('novalidate')) {
- if (!event.currentTarget.checkValidity()) {
- event.preventDefault();
- event.stopPropagation();
+ if (!e.currentTarget.checkValidity()) {
+ e.preventDefault();
+ e.stopPropagation();
}
}
}
@@ -50,11 +49,9 @@ class GlFieldErrors {
});
}
- focusOnFirstInvalid () {
- const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0];
+ focusOnFirstInvalid() {
+ const firstInvalid = this.state.inputs
+ .filter(input => !input.inputDomElement.validity.valid)[0];
firstInvalid.inputElement.focus();
}
}
-
-window.gl = window.gl || {};
-window.gl.GlFieldErrors = GlFieldErrors;
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index dc9f114af99..9f5eba353d7 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,100 +1,100 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-new, max-len */
-/* global GitLab */
-/* global DropzoneInput */
-/* global autosize */
-
+import $ from 'jquery';
+import autosize from 'autosize';
import GfmAutoComplete from './gfm_auto_complete';
+import dropzoneInput from './dropzone_input';
+import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
+
+export default class GLForm {
+ constructor(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
+ this.setupForm();
+ this.form.data('glForm', this);
+ }
-window.gl = window.gl || {};
-
-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
- this.setupForm();
- this.form.data('gl-form', this);
-}
-
-GLForm.prototype.destroy = function() {
- // Clean form listeners
- this.clearEventListeners();
- return this.form.data('gl-form', null);
-};
-
-GLForm.prototype.setupForm = function() {
- var isNewForm;
- isNewForm = this.form.is(':not(.gfm-form)');
- this.form.removeClass('js-new-note-form');
- if (isNewForm) {
- this.form.find('.div-dropzone').remove();
- 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'));
- 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);
+ destroy() {
+ // Clean form listeners
+ this.clearEventListeners();
+ if (this.autoComplete) {
+ this.autoComplete.destroy();
+ }
+ this.form.data('glForm', null);
}
- // form and textarea event listeners
- this.addEventListeners();
- gl.text.init(this.form);
- // hide discard button
- this.form.find('.js-note-discard').hide();
- this.form.show();
- if (this.isAutosizeable) this.setupAutosize();
-};
-GLForm.prototype.setupAutosize = function () {
- this.textarea.off('autosize:resized')
- .on('autosize:resized', this.setHeightData.bind(this));
+ setupForm() {
+ const isNewForm = this.form.is(':not(.gfm-form)');
+ this.form.removeClass('js-new-note-form');
+ if (isNewForm) {
+ this.form.find('.div-dropzone').remove();
+ 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'));
+ this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
+ this.autoComplete.setup(this.form.find('.js-gfm-input'), {
+ emojis: true,
+ members: this.enableGFM,
+ issues: this.enableGFM,
+ milestones: this.enableGFM,
+ mergeRequests: this.enableGFM,
+ labels: this.enableGFM,
+ });
+ dropzoneInput(this.form);
+ autosize(this.textarea);
+ }
+ // form and textarea event listeners
+ this.addEventListeners();
+ addMarkdownListeners(this.form);
+ // hide discard button
+ this.form.find('.js-note-discard').hide();
+ this.form.show();
+ if (this.isAutosizeable) this.setupAutosize();
+ }
- this.textarea.off('mouseup.autosize')
- .on('mouseup.autosize', this.destroyAutosize.bind(this));
+ setupAutosize() {
+ this.textarea.off('autosize:resized')
+ .on('autosize:resized', this.setHeightData.bind(this));
- setTimeout(() => {
- autosize(this.textarea);
- this.textarea.css('resize', 'vertical');
- }, 0);
-};
+ this.textarea.off('mouseup.autosize')
+ .on('mouseup.autosize', this.destroyAutosize.bind(this));
-GLForm.prototype.setHeightData = function () {
- this.textarea.data('height', this.textarea.outerHeight());
-};
+ setTimeout(() => {
+ autosize(this.textarea);
+ this.textarea.css('resize', 'vertical');
+ }, 0);
+ }
-GLForm.prototype.destroyAutosize = function () {
- const outerHeight = this.textarea.outerHeight();
+ setHeightData() {
+ this.textarea.data('height', this.textarea.outerHeight());
+ }
- if (this.textarea.data('height') === outerHeight) return;
+ destroyAutosize() {
+ const outerHeight = this.textarea.outerHeight();
- autosize.destroy(this.textarea);
+ if (this.textarea.data('height') === outerHeight) return;
- this.textarea.data('height', outerHeight);
- this.textarea.outerHeight(outerHeight);
- this.textarea.css('max-height', window.outerHeight);
-};
+ autosize.destroy(this.textarea);
-GLForm.prototype.clearEventListeners = function() {
- this.textarea.off('focus');
- this.textarea.off('blur');
- return gl.text.removeListeners(this.form);
-};
+ this.textarea.data('height', outerHeight);
+ this.textarea.outerHeight(outerHeight);
+ this.textarea.css('max-height', window.outerHeight);
+ }
-GLForm.prototype.addEventListeners = function() {
- this.textarea.on('focus', function() {
- return $(this).closest('.md-area').addClass('is-focused');
- });
- return this.textarea.on('blur', function() {
- return $(this).closest('.md-area').removeClass('is-focused');
- });
-};
+ clearEventListeners() {
+ this.textarea.off('focus');
+ this.textarea.off('blur');
+ removeMarkdownListeners(this.form);
+ }
-window.gl.GLForm = GLForm;
+ addEventListeners() {
+ this.textarea.on('focus', function focusTextArea() {
+ $(this).closest('.md-area').addClass('is-focused');
+ });
+ this.textarea.on('blur', function blurTextArea() {
+ $(this).closest('.md-area').removeClass('is-focused');
+ });
+ }
+}
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
new file mode 100644
index 00000000000..502e3569321
--- /dev/null
+++ b/app/assets/javascripts/gpg_badges.js
@@ -0,0 +1,23 @@
+import $ from 'jquery';
+import { parseQueryStringIntoObject } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+export default class GpgBadges {
+ static fetch() {
+ const badges = $('.js-loading-gpg-badge');
+ const form = $('.commits-search-form');
+
+ badges.html('<i class="fa fa-spinner fa-spin"></i>');
+
+ const params = parseQueryStringIntoObject(form.serialize());
+ return axios.get(form.data('signaturesPath'), { params })
+ .then(({ data }) => {
+ data.signatures.forEach((signature) => {
+ badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
+ });
+ })
+ .catch(() => flash(__('An error occurred while loading commits')));
+ }
+}
diff --git a/app/assets/javascripts/graphs/graphs_bundle.js b/app/assets/javascripts/graphs/graphs_bundle.js
deleted file mode 100644
index a433c7ba8f0..00000000000
--- a/app/assets/javascripts/graphs/graphs_bundle.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import Chart from 'vendor/Chart';
-import ContributorsStatGraph from './stat_graph_contributors';
-
-// export to global scope
-window.Chart = Chart;
-window.ContributorsStatGraph = ContributorsStatGraph;
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/graphs/stat_graph_contributors.js
deleted file mode 100644
index c6be4c9e8fe..00000000000
--- a/app/assets/javascripts/graphs/stat_graph_contributors.js
+++ /dev/null
@@ -1,111 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, camelcase, one-var-declaration-per-line, quotes, no-param-reassign, quote-props, comma-dangle, prefer-template, max-len, no-return-assign, no-shadow */
-
-import d3 from 'd3';
-import { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph';
-import ContributorsStatGraphUtil from './stat_graph_contributors_util';
-
-export default (function() {
- function ContributorsStatGraph() {}
-
- ContributorsStatGraph.prototype.init = function(log) {
- var author_commits, total_commits;
- this.parsed_log = ContributorsStatGraphUtil.parse_log(log);
- this.set_current_field("commits");
- total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
- author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field);
- this.add_master_graph(total_commits);
- this.add_authors_graph(author_commits);
- return this.change_date_header();
- };
-
- ContributorsStatGraph.prototype.add_master_graph = function(total_data) {
- this.master_graph = new ContributorsMasterGraph(total_data);
- return this.master_graph.draw();
- };
-
- ContributorsStatGraph.prototype.add_authors_graph = function(author_data) {
- var limited_author_data;
- this.authors = [];
- limited_author_data = author_data.slice(0, 100);
- return _.each(limited_author_data, (function(_this) {
- return function(d) {
- var author_graph, author_header;
- author_header = _this.create_author_header(d);
- $(".contributors-list").append(author_header);
- _this.authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates);
- return author_graph.draw();
- };
- })(this));
- };
-
- ContributorsStatGraph.prototype.format_author_commit_info = function(author) {
- var commits;
- commits = $('<span/>', {
- "class": 'graph-author-commits-count'
- });
- commits.text(author.commits + " commits");
- return $('<span/>').append(commits);
- };
-
- ContributorsStatGraph.prototype.create_author_header = function(author) {
- var author_commit_info, author_commit_info_span, author_email, author_name, list_item;
- list_item = $('<li/>', {
- "class": 'person',
- style: 'display: block;'
- });
- author_name = $('<h4>' + author.author_name + '</h4>');
- author_email = $('<p class="graph-author-email">' + author.author_email + '</p>');
- author_commit_info_span = $('<span/>', {
- "class": 'commits'
- });
- author_commit_info = this.format_author_commit_info(author);
- author_commit_info_span.html(author_commit_info);
- list_item.append(author_name);
- list_item.append(author_email);
- list_item.append(author_commit_info_span);
- return list_item;
- };
-
- ContributorsStatGraph.prototype.redraw_master = function() {
- var total_data;
- total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
- this.master_graph.set_data(total_data);
- return this.master_graph.redraw();
- };
-
- ContributorsStatGraph.prototype.redraw_authors = function() {
- var author_commits, x_domain;
- $("ol").html("");
- x_domain = ContributorsGraph.prototype.x_domain;
- author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain);
- return _.each(author_commits, (function(_this) {
- return function(d) {
- _this.redraw_author_commit_info(d);
- $(_this.authors[d.author_name].list_item).appendTo("ol");
- _this.authors[d.author_name].set_data(d.dates);
- return _this.authors[d.author_name].redraw();
- };
- })(this));
- };
-
- ContributorsStatGraph.prototype.set_current_field = function(field) {
- return this.field = field;
- };
-
- ContributorsStatGraph.prototype.change_date_header = function() {
- var print, print_date_format, x_domain;
- x_domain = ContributorsGraph.prototype.x_domain;
- print_date_format = d3.time.format("%B %e %Y");
- print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]);
- return $("#date_header").text(print);
- };
-
- ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
- var author_commit_info, author_list_item;
- author_list_item = $(this.authors[author.author_name].list_item);
- author_commit_info = this.format_author_commit_info(author);
- return author_list_item.find("span").html(author_commit_info);
- };
-
- return ContributorsStatGraph;
-})();
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
deleted file mode 100644
index 0deb27e522b..00000000000
--- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
+++ /dev/null
@@ -1,271 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */
-
-import d3 from 'd3';
-
-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;
-
-export const ContributorsGraph = (function() {
- function ContributorsGraph() {}
-
- ContributorsGraph.prototype.MARGIN = {
- top: 20,
- right: 20,
- bottom: 30,
- left: 50
- };
-
- ContributorsGraph.prototype.x_domain = null;
-
- ContributorsGraph.prototype.y_domain = null;
-
- ContributorsGraph.prototype.dates = [];
-
- ContributorsGraph.set_x_domain = function(data) {
- return ContributorsGraph.prototype.x_domain = data;
- };
-
- ContributorsGraph.set_y_domain = function(data) {
- return ContributorsGraph.prototype.y_domain = [
- 0, d3.max(data, function(d) {
- return d.commits = d.commits || d.additions || d.deletions;
- })
- ];
- };
-
- ContributorsGraph.init_x_domain = function(data) {
- return ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) {
- return d.date;
- });
- };
-
- ContributorsGraph.init_y_domain = function(data) {
- return ContributorsGraph.prototype.y_domain = [
- 0, d3.max(data, function(d) {
- return d.commits = d.commits || d.additions || d.deletions;
- })
- ];
- };
-
- ContributorsGraph.init_domain = function(data) {
- ContributorsGraph.init_x_domain(data);
- return ContributorsGraph.init_y_domain(data);
- };
-
- ContributorsGraph.set_dates = function(data) {
- return ContributorsGraph.prototype.dates = data;
- };
-
- ContributorsGraph.prototype.set_x_domain = function() {
- return this.x.domain(this.x_domain);
- };
-
- ContributorsGraph.prototype.set_y_domain = function() {
- return this.y.domain(this.y_domain);
- };
-
- ContributorsGraph.prototype.set_domain = function() {
- this.set_x_domain();
- return this.set_y_domain();
- };
-
- ContributorsGraph.prototype.create_scale = function(width, height) {
- this.x = d3.time.scale().range([0, width]).clamp(true);
- return this.y = d3.scale.linear().range([height, 0]).nice();
- };
-
- ContributorsGraph.prototype.draw_x_axis = function() {
- return this.svg.append("g").attr("class", "x axis").attr("transform", "translate(0, " + this.height + ")").call(this.x_axis);
- };
-
- ContributorsGraph.prototype.draw_y_axis = function() {
- return this.svg.append("g").attr("class", "y axis").call(this.y_axis);
- };
-
- ContributorsGraph.prototype.set_data = function(data) {
- return this.data = data;
- };
-
- return ContributorsGraph;
-})();
-
-export const ContributorsMasterGraph = (function(superClass) {
- extend(ContributorsMasterGraph, superClass);
-
- function ContributorsMasterGraph(data1) {
- this.data = data1;
- this.update_content = this.update_content.bind(this);
- this.width = $('.content').width() - 70;
- this.height = 200;
- this.x = null;
- this.y = null;
- this.x_axis = null;
- this.y_axis = null;
- this.area = null;
- this.svg = null;
- this.brush = null;
- this.x_max_domain = null;
- }
-
- ContributorsMasterGraph.prototype.process_dates = function(data) {
- var dates;
- dates = this.get_dates(data);
- this.parse_dates(data);
- return ContributorsGraph.set_dates(dates);
- };
-
- ContributorsMasterGraph.prototype.get_dates = function(data) {
- return _.pluck(data, 'date');
- };
-
- ContributorsMasterGraph.prototype.parse_dates = function(data) {
- var parseDate;
- parseDate = d3.time.format("%Y-%m-%d").parse;
- return data.forEach(function(d) {
- return d.date = parseDate(d.date);
- });
- };
-
- ContributorsMasterGraph.prototype.create_scale = function() {
- return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height);
- };
-
- ContributorsMasterGraph.prototype.create_axes = function() {
- this.x_axis = d3.svg.axis().scale(this.x).orient("bottom");
- return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5);
- };
-
- ContributorsMasterGraph.prototype.create_svg = function() {
- return this.svg = d3.select("#contributors-master").append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "tint-box").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
- };
-
- ContributorsMasterGraph.prototype.create_area = function(x, y) {
- return this.area = d3.svg.area().x(function(d) {
- return x(d.date);
- }).y0(this.height).y1(function(d) {
- d.commits = d.commits || d.additions || d.deletions;
- return y(d.commits);
- }).interpolate("basis");
- };
-
- ContributorsMasterGraph.prototype.create_brush = function() {
- return this.brush = d3.svg.brush().x(this.x).on("brushend", this.update_content);
- };
-
- ContributorsMasterGraph.prototype.draw_path = function(data) {
- return this.svg.append("path").datum(data).attr("class", "area").attr("d", this.area);
- };
-
- ContributorsMasterGraph.prototype.add_brush = function() {
- return this.svg.append("g").attr("class", "selection").call(this.brush).selectAll("rect").attr("height", this.height);
- };
-
- ContributorsMasterGraph.prototype.update_content = function() {
- ContributorsGraph.set_x_domain(this.brush.empty() ? this.x_max_domain : this.brush.extent());
- return $("#brush_change").trigger('change');
- };
-
- ContributorsMasterGraph.prototype.draw = function() {
- this.process_dates(this.data);
- this.create_scale();
- this.create_axes();
- ContributorsGraph.init_domain(this.data);
- this.x_max_domain = this.x_domain;
- this.set_domain();
- this.create_area(this.x, this.y);
- this.create_svg();
- this.create_brush();
- this.draw_path(this.data);
- this.draw_x_axis();
- this.draw_y_axis();
- return this.add_brush();
- };
-
- ContributorsMasterGraph.prototype.redraw = function() {
- this.process_dates(this.data);
- ContributorsGraph.set_y_domain(this.data);
- this.set_y_domain();
- this.svg.select("path").datum(this.data);
- this.svg.select("path").attr("d", this.area);
- return this.svg.select(".y.axis").call(this.y_axis);
- };
-
- return ContributorsMasterGraph;
-})(ContributorsGraph);
-
-export const ContributorsAuthorGraph = (function(superClass) {
- extend(ContributorsAuthorGraph, superClass);
-
- function ContributorsAuthorGraph(data1) {
- this.data = data1;
- // Don't split graph size in half for mobile devices.
- if ($(window).width() < 768) {
- this.width = $('.content').width() - 80;
- } else {
- this.width = ($('.content').width() / 2) - 100;
- }
- this.height = 200;
- this.x = null;
- this.y = null;
- this.x_axis = null;
- this.y_axis = null;
- this.area = null;
- this.svg = null;
- this.list_item = null;
- }
-
- ContributorsAuthorGraph.prototype.create_scale = function() {
- return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height);
- };
-
- ContributorsAuthorGraph.prototype.create_axes = function() {
- this.x_axis = d3.svg.axis().scale(this.x).orient("bottom").ticks(8);
- return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5);
- };
-
- ContributorsAuthorGraph.prototype.create_area = function(x, y) {
- return this.area = d3.svg.area().x(function(d) {
- var parseDate;
- parseDate = d3.time.format("%Y-%m-%d").parse;
- return x(parseDate(d));
- }).y0(this.height).y1((function(_this) {
- return function(d) {
- if (_this.data[d] != null) {
- return y(_this.data[d]);
- } else {
- return y(0);
- }
- };
- })(this)).interpolate("basis");
- };
-
- ContributorsAuthorGraph.prototype.create_svg = function() {
- this.list_item = d3.selectAll(".person")[0].pop();
- return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
- };
-
- ContributorsAuthorGraph.prototype.draw_path = function(data) {
- return this.svg.append("path").datum(data).attr("class", "area-contributor").attr("d", this.area);
- };
-
- ContributorsAuthorGraph.prototype.draw = function() {
- this.create_scale();
- this.create_axes();
- this.set_domain();
- this.create_area(this.x, this.y);
- this.create_svg();
- this.draw_path(this.dates);
- this.draw_x_axis();
- return this.draw_y_axis();
- };
-
- ContributorsAuthorGraph.prototype.redraw = function() {
- this.set_domain();
- this.svg.select("path").datum(this.dates);
- this.svg.select("path").attr("d", this.area);
- this.svg.select(".x.axis").call(this.x_axis);
- return this.svg.select(".y.axis").call(this.y_axis);
- };
-
- return ContributorsAuthorGraph;
-})(ContributorsGraph);
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js b/app/assets/javascripts/graphs/stat_graph_contributors_util.js
deleted file mode 100644
index c583757f3f2..00000000000
--- a/app/assets/javascripts/graphs/stat_graph_contributors_util.js
+++ /dev/null
@@ -1,137 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, object-shorthand, no-var, one-var, camelcase, one-var-declaration-per-line, comma-dangle, no-param-reassign, no-return-assign, quotes, prefer-arrow-callback, wrap-iife, consistent-return, no-unused-vars, max-len, no-cond-assign, no-else-return, max-len */
-
-export default {
- parse_log: function(log) {
- var by_author, by_email, data, entry, i, len, total, normalized_email;
- total = {};
- by_author = {};
- by_email = {};
- for (i = 0, len = log.length; i < len; i += 1) {
- entry = log[i];
- if (total[entry.date] == null) {
- this.add_date(entry.date, total);
- }
- normalized_email = entry.author_email.toLowerCase();
- data = by_author[entry.author_name] || by_email[normalized_email];
- if (data == null) {
- data = this.add_author(entry, by_author, by_email);
- }
- if (!data[entry.date]) {
- this.add_date(entry.date, data);
- }
- this.store_data(entry, total[entry.date], data[entry.date]);
- }
- total = _.toArray(total);
- by_author = _.toArray(by_author);
- return {
- total: total,
- by_author: by_author
- };
- },
- add_date: function(date, collection) {
- collection[date] = {};
- return collection[date].date = date;
- },
- add_author: function(author, by_author, by_email) {
- var data, normalized_email;
- data = {};
- data.author_name = author.author_name;
- data.author_email = author.author_email;
- normalized_email = author.author_email.toLowerCase();
- by_author[author.author_name] = data;
- by_email[normalized_email] = data;
- return data;
- },
- store_data: function(entry, total, by_author) {
- this.store_commits(total, by_author);
- this.store_additions(entry, total, by_author);
- return this.store_deletions(entry, total, by_author);
- },
- store_commits: function(total, by_author) {
- this.add(total, "commits", 1);
- return this.add(by_author, "commits", 1);
- },
- add: function(collection, field, value) {
- if (collection[field] == null) {
- collection[field] = 0;
- }
- return collection[field] += value;
- },
- store_additions: function(entry, total, by_author) {
- if (entry.additions == null) {
- entry.additions = 0;
- }
- this.add(total, "additions", entry.additions);
- return this.add(by_author, "additions", entry.additions);
- },
- store_deletions: function(entry, total, by_author) {
- if (entry.deletions == null) {
- entry.deletions = 0;
- }
- this.add(total, "deletions", entry.deletions);
- return this.add(by_author, "deletions", entry.deletions);
- },
- get_total_data: function(parsed_log, field) {
- var log, total_data;
- log = parsed_log.total;
- total_data = this.pick_field(log, field);
- return _.sortBy(total_data, function(d) {
- return d.date;
- });
- },
- pick_field: function(log, field) {
- var total_data;
- total_data = [];
- _.each(log, function(d) {
- return total_data.push(_.pick(d, [field, 'date']));
- });
- return total_data;
- },
- get_author_data: function(parsed_log, field, date_range) {
- var author_data, log;
- if (date_range == null) {
- date_range = null;
- }
- log = parsed_log.by_author;
- author_data = [];
- _.each(log, (function(_this) {
- return function(log_entry) {
- var parsed_log_entry;
- parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range);
- if (!_.isEmpty(parsed_log_entry.dates)) {
- return author_data.push(parsed_log_entry);
- }
- };
- })(this));
- return _.sortBy(author_data, function(d) {
- return d[field];
- }).reverse();
- },
- parse_log_entry: function(log_entry, field, date_range) {
- var parsed_entry;
- parsed_entry = {};
- parsed_entry.author_name = log_entry.author_name;
- parsed_entry.author_email = log_entry.author_email;
- parsed_entry.dates = {};
- parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0;
- _.each(_.omit(log_entry, 'author_name', 'author_email'), (function(_this) {
- return function(value, key) {
- if (_this.in_range(value.date, date_range)) {
- parsed_entry.dates[value.date] = value[field];
- parsed_entry.commits += value.commits;
- parsed_entry.additions += value.additions;
- return parsed_entry.deletions += value.deletions;
- }
- };
- })(this));
- return parsed_entry;
- },
- in_range: function(date, date_range) {
- var ref;
- if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) {
- return true;
- } else {
- return false;
- }
- }
-};
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 7732edde1e7..4365305c168 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class Group {
constructor() {
this.groupPath = $('#group_path');
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/group_avatar.js
index f03b47b1c1d..beaac61e887 100644
--- a/app/assets/javascripts/group_avatar.js
+++ b/app/assets/javascripts/group_avatar.js
@@ -1,19 +1,14 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, one-var, one-var-declaration-per-line, no-useless-escape, max-len */
+import $ from 'jquery';
-window.GroupAvatar = (function() {
- function GroupAvatar() {
- $('.js-choose-group-avatar-button').on("click", function() {
- var form;
- form = $(this).closest("form");
- return form.find(".js-group-avatar-input").click();
- });
- $('.js-group-avatar-input').on("change", function() {
- var filename, form;
- form = $(this).closest("form");
- filename = $(this).val().replace(/^.*[\\\/]/, '');
- return form.find(".js-avatar-filename").text(filename);
- });
- }
-
- return GroupAvatar;
-})();
+export default function groupAvatar() {
+ $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() {
+ const form = $(this).closest('form');
+ return form.find('.js-group-avatar-input').click();
+ });
+ $('.js-group-avatar-input').on('change', function onChangeAvatarInput() {
+ const form = $(this).closest('form');
+ // eslint-disable-next-line no-useless-escape
+ const filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-avatar-filename').text(filename);
+ });
+}
diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js
index 7dc9ce898e8..5648cb9a888 100644
--- a/app/assets/javascripts/group_label_subscription.js
+++ b/app/assets/javascripts/group_label_subscription.js
@@ -1,6 +1,9 @@
-/* eslint-disable func-names, object-shorthand, comma-dangle, wrap-iife, space-before-function-paren, no-param-reassign, max-len */
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+import { __ } from './locale';
-class GroupLabelSubscription {
+export default class GroupLabelSubscription {
constructor(container) {
const $container = $(container);
this.$dropdown = $container.find('.dropdown');
@@ -15,14 +18,12 @@ class GroupLabelSubscription {
event.preventDefault();
const url = this.$unsubscribeButtons.attr('data-url');
-
- $.ajax({
- type: 'POST',
- url: url
- }).done(() => {
- this.toggleSubscriptionButtons();
- this.$unsubscribeButtons.removeAttr('data-url');
- });
+ axios.post(url)
+ .then(() => {
+ this.toggleSubscriptionButtons();
+ this.$unsubscribeButtons.removeAttr('data-url');
+ })
+ .catch(() => flash(__('There was an error when unsubscribing from this label.')));
}
subscribe(event) {
@@ -33,12 +34,9 @@ class GroupLabelSubscription {
this.$unsubscribeButtons.attr('data-url', url);
- $.ajax({
- type: 'POST',
- url: url
- }).done(() => {
- this.toggleSubscriptionButtons();
- });
+ axios.post(url)
+ .then(() => this.toggleSubscriptionButtons())
+ .catch(() => flash(__('There was an error when subscribing to this label.')));
}
toggleSubscriptionButtons() {
@@ -47,6 +45,3 @@ class GroupLabelSubscription {
this.$unsubscribeButtons.toggleClass('hidden');
}
}
-
-window.gl = window.gl || {};
-window.gl.GroupLabelSubscription = GroupLabelSubscription;
diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js
deleted file mode 100644
index 462d792b8d5..00000000000
--- a/app/assets/javascripts/group_name.js
+++ /dev/null
@@ -1,64 +0,0 @@
-
-import _ from 'underscore';
-
-export default class GroupName {
- constructor() {
- this.titleContainer = document.querySelector('.title-container');
- this.title = document.querySelector('.title');
- this.titleWidth = this.title.offsetWidth;
- this.groupTitle = document.querySelector('.group-title');
- this.groups = document.querySelectorAll('.group-path');
- this.toggle = null;
- this.isHidden = false;
- this.init();
- }
-
- init() {
- if (this.groups.length > 0) {
- this.groups[this.groups.length - 1].classList.remove('hidable');
- this.toggleHandler();
- window.addEventListener('resize', _.debounce(this.toggleHandler.bind(this), 100));
- }
- this.render();
- }
-
- toggleHandler() {
- if (this.titleWidth > this.titleContainer.offsetWidth) {
- if (!this.toggle) this.createToggle();
- this.showToggle();
- } else if (this.toggle) {
- this.hideToggle();
- }
- }
-
- createToggle() {
- this.toggle = document.createElement('button');
- this.toggle.className = 'text-expander group-name-toggle';
- this.toggle.setAttribute('aria-label', 'Toggle full path');
- this.toggle.innerHTML = '...';
- this.toggle.addEventListener('click', this.toggleGroups.bind(this));
- this.titleContainer.insertBefore(this.toggle, this.title);
- this.toggleGroups();
- }
-
- showToggle() {
- this.title.classList.add('wrap');
- this.toggle.classList.remove('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('hidden');
- }
-
- toggleGroups() {
- this.isHidden = !this.isHidden;
- this.groupTitle.classList.toggle('hidden');
- }
-
- render() {
- this.title.classList.remove('initializing');
- }
-}
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
new file mode 100644
index 00000000000..22eb7bd44c5
--- /dev/null
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -0,0 +1,241 @@
+<script>
+/* global Flash */
+
+import $ from 'jquery';
+import { s__ } from '~/locale';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+
+import eventHub from '../event_hub';
+import { COMMON_STR } from '../constants';
+import groupsComponent from './groups.vue';
+
+export default {
+ components: {
+ loadingIcon,
+ DeprecatedModal,
+ groupsComponent,
+ },
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ hideProjects: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: true,
+ isSearchEmpty: false,
+ searchEmptyMessage: '',
+ showModal: false,
+ groupLeaveConfirmationMessage: '',
+ targetGroup: null,
+ targetParentGroup: null,
+ };
+ },
+ computed: {
+ groups() {
+ return this.store.getGroups();
+ },
+ pageInfo() {
+ return this.store.getPaginationInfo();
+ },
+ },
+ created() {
+ this.searchEmptyMessage = this.hideProjects
+ ? COMMON_STR.GROUP_SEARCH_EMPTY
+ : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
+
+ eventHub.$on('fetchPage', this.fetchPage);
+ eventHub.$on('toggleChildren', this.toggleChildren);
+ eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal);
+ eventHub.$on('updatePagination', this.updatePagination);
+ eventHub.$on('updateGroups', this.updateGroups);
+ },
+ mounted() {
+ this.fetchAllGroups();
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchPage', this.fetchPage);
+ eventHub.$off('toggleChildren', this.toggleChildren);
+ eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal);
+ eventHub.$off('updatePagination', this.updatePagination);
+ eventHub.$off('updateGroups', this.updateGroups);
+ },
+ methods: {
+ fetchGroups({
+ parentId,
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination,
+ }) {
+ return this.service
+ .getGroups(parentId, page, filterGroupsBy, sortBy, archived)
+ .then(res => {
+ if (updatePagination) {
+ this.updatePagination(res.headers);
+ }
+
+ return res;
+ })
+ .then(res => res.json())
+ .catch(() => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ Flash(COMMON_STR.FAILURE);
+ });
+ },
+ fetchAllGroups() {
+ const page = getParameterByName('page') || null;
+ const sortBy = getParameterByName('sort') || null;
+ const archived = getParameterByName('archived') || null;
+ const filterGroupsBy = getParameterByName('filter') || null;
+
+ this.isLoading = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination: true,
+ }).then(res => {
+ this.isLoading = false;
+ this.updateGroups(res, Boolean(filterGroupsBy));
+ });
+ },
+ fetchPage(page, filterGroupsBy, sortBy, archived) {
+ this.isLoading = true;
+
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination: true,
+ }).then(res => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ const currentPath = mergeUrlParams({ page }, window.location.href);
+ window.history.replaceState(
+ {
+ page: currentPath,
+ },
+ document.title,
+ currentPath,
+ );
+
+ this.updateGroups(res);
+ });
+ },
+ toggleChildren(group) {
+ const parentGroup = group;
+ if (!parentGroup.isOpen) {
+ if (parentGroup.children.length === 0) {
+ parentGroup.isChildrenLoading = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ parentId: parentGroup.id,
+ })
+ .then(res => {
+ this.store.setGroupChildren(parentGroup, res);
+ })
+ .catch(() => {
+ parentGroup.isChildrenLoading = false;
+ });
+ } else {
+ parentGroup.isOpen = true;
+ }
+ } else {
+ parentGroup.isOpen = false;
+ }
+ },
+ showLeaveGroupModal(group, parentGroup) {
+ this.targetGroup = group;
+ this.targetParentGroup = parentGroup;
+ this.showModal = true;
+ this.groupLeaveConfirmationMessage = s__(
+ `GroupsTree|Are you sure you want to leave the "${
+ group.fullName
+ }" group?`,
+ );
+ },
+ hideLeaveGroupModal() {
+ this.showModal = false;
+ },
+ leaveGroup() {
+ this.showModal = false;
+ this.targetGroup.isBeingRemoved = true;
+ this.service
+ .leaveGroup(this.targetGroup.leavePath)
+ .then(res => res.json())
+ .then(res => {
+ $.scrollTo(0);
+ this.store.removeGroup(this.targetGroup, this.targetParentGroup);
+ Flash(res.notice, 'notice');
+ })
+ .catch(err => {
+ let message = COMMON_STR.FAILURE;
+ if (err.status === 403) {
+ message = COMMON_STR.LEAVE_FORBIDDEN;
+ }
+ Flash(message);
+ this.targetGroup.isBeingRemoved = false;
+ });
+ },
+ updatePagination(headers) {
+ this.store.setPaginationInfo(headers);
+ },
+ updateGroups(groups, fromSearch) {
+ this.isSearchEmpty = groups ? groups.length === 0 : false;
+ if (fromSearch) {
+ this.store.setSearchedGroups(groups);
+ } else {
+ this.store.setGroups(groups);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <loading-icon
+ class="loading-animation prepend-top-20"
+ size="2"
+ v-if="isLoading"
+ :label="s__('GroupsTree|Loading groups')"
+ />
+ <groups-component
+ v-if="!isLoading"
+ :groups="groups"
+ :search-empty="isSearchEmpty"
+ :search-empty-message="searchEmptyMessage"
+ :page-info="pageInfo"
+ />
+ <deprecated-modal
+ v-show="showModal"
+ kind="warning"
+ :primary-button-label="__('Leave')"
+ :title="__('Are you sure?')"
+ :text="groupLeaveConfirmationMessage"
+ @cancel="hideLeaveGroupModal"
+ @submit="leaveGroup"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 7cc6c4b0359..647c9d0046d 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,15 +1,31 @@
<script>
+import { n__ } from '../../locale';
+import { MAX_CHILDREN_COUNT } from '../constants';
+
export default {
props: {
- groups: {
- type: Object,
- required: true,
- },
- baseGroup: {
+ parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
+ groups: {
+ type: Array,
+ required: false,
+ default: () => ([]),
+ },
+ },
+ computed: {
+ hasMoreChildren() {
+ return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
+ },
+ moreChildrenStats() {
+ return n__(
+ 'One more item',
+ '%d more items',
+ this.parentGroup.childrenCount - this.parentGroup.children.length,
+ );
+ },
},
};
</script>
@@ -20,8 +36,21 @@ export default {
v-for="(group, index) in groups"
:key="index"
:group="group"
- :base-group="baseGroup"
- :collection="groups"
+ :parent-group="parentGroup"
/>
+ <li
+ v-if="hasMoreChildren"
+ class="group-row">
+ <a
+ :href="parentGroup.relativePath"
+ class="group-row-contents has-more-items">
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true"
+ >
+ </i>
+ {{ moreChildrenStats }}
+ </a>
+ </li>
</ul>
</template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index b1db34b9c50..764b130fdb8 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,46 +1,34 @@
<script>
+import { visitUrl } from '../../lib/utils/url_utility';
+import tooltip from '../../vue_shared/directives/tooltip';
+import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub';
+import itemCaret from './item_caret.vue';
+import itemTypeIcon from './item_type_icon.vue';
+import itemStats from './item_stats.vue';
+import itemActions from './item_actions.vue';
+
export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ identicon,
+ itemCaret,
+ itemTypeIcon,
+ itemStats,
+ itemActions,
+ },
props: {
- group: {
- type: Object,
- required: true,
- },
- baseGroup: {
+ parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
- collection: {
+ group: {
type: Object,
- required: false,
- default: () => ({}),
- },
- },
- methods: {
- onClickRowGroup(e) {
- e.stopPropagation();
-
- // Skip for buttons
- if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
- if (this.group.hasSubgroups) {
- eventHub.$emit('toggleSubGroups', this.group);
- } else {
- window.location.href = this.group.groupPath;
- }
- }
- },
- onLeaveGroup(e) {
- e.preventDefault();
-
- // eslint-disable-next-line no-alert
- if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
- this.leaveGroup();
- }
- },
- leaveGroup() {
- eventHub.$emit('leaveGroup', this.group, this.collection);
+ required: true,
},
},
computed: {
@@ -49,48 +37,33 @@ export default {
},
rowClass() {
return {
- 'group-row': true,
'is-open': this.group.isOpen,
- 'has-subgroups': this.group.hasSubgroups,
- 'no-description': !this.group.description,
+ 'has-children': this.hasChildren,
+ 'has-description': this.group.description,
+ 'being-removed': this.group.isBeingRemoved,
};
},
- visibilityIcon() {
- return {
- fa: true,
- 'fa-globe': this.group.visibility === 'public',
- 'fa-shield': this.group.visibility === 'internal',
- 'fa-lock': this.group.visibility === 'private',
- };
+ hasChildren() {
+ return this.group.childrenCount > 0;
},
- fullPath() {
- let fullPath = '';
-
- if (this.group.isOrphan) {
- // check if current group is baseGroup
- if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
- // Remove baseGroup prefix from our current group.fullName. e.g:
- // baseGroup.fullName: `level1`
- // group.fullName: `level1 / level2 / level3`
- // Result: `level2 / level3`
- const gfn = this.group.fullName;
- const bfn = this.baseGroup.fullName;
- const length = bfn.length;
- const start = gfn.indexOf(bfn);
- const extraPrefixChars = 3;
-
- fullPath = gfn.substr(start + length + extraPrefixChars);
+ hasAvatar() {
+ return this.group.avatarUrl !== null;
+ },
+ isGroup() {
+ return this.group.type === 'group';
+ },
+ },
+ methods: {
+ onClickRowGroup(e) {
+ const NO_EXPAND_CLS = 'no-expand';
+ if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
+ e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
+ if (this.hasChildren) {
+ eventHub.$emit('toggleChildren', this.group);
} else {
- fullPath = this.group.fullName;
+ visitUrl(this.group.relativePath);
}
- } else {
- fullPath = this.group.name;
}
-
- return fullPath;
- },
- hasGroups() {
- return Object.keys(this.group.subGroups).length > 0;
},
},
};
@@ -101,120 +74,83 @@ export default {
@click.stop="onClickRowGroup"
:id="groupDomId"
:class="rowClass"
- >
+ class="group-row"
+ >
<div
- class="group-row-contents">
- <div
- class="controls">
- <a
- v-if="group.canEdit"
- class="edit-group btn"
- :href="group.editPath">
- <i
- class="fa fa-cogs"
- aria-hidden="true"
- >
- </i>
- </a>
- <a
- @click="onLeaveGroup"
- :href="group.leavePath"
- class="leave-group btn"
- title="Leave this group">
- <i
- class="fa fa-sign-out"
- aria-hidden="true"
- >
- </i>
- </a>
- </div>
- <div
- class="stats">
- <span
- class="number-projects">
- <i
- class="fa fa-bookmark"
- aria-hidden="true"
- >
- </i>
- {{group.numberProjects}}
- </span>
- <span
- class="number-users">
- <i
- class="fa fa-users"
- aria-hidden="true"
- >
- </i>
- {{group.numberUsers}}
- </span>
- <span
- class="group-visibility">
- <i
- :class="visibilityIcon"
- aria-hidden="true"
- >
- </i>
- </span>
- </div>
+ class="group-row-contents"
+ :class="{ 'project-row-contents': !isGroup }">
+ <item-actions
+ v-if="isGroup"
+ :group="group"
+ :parent-group="parentGroup"
+ />
+ <item-stats
+ :item="group"
+ />
<div
- class="folder-toggle-wrap">
- <span
- class="folder-caret"
- v-if="group.hasSubgroups">
- <i
- v-if="group.isOpen"
- class="fa fa-caret-down"
- aria-hidden="true"
- >
- </i>
- <i
- v-if="!group.isOpen"
- class="fa fa-caret-right"
- aria-hidden="true"
- >
- </i>
- </span>
- <span class="folder-icon">
- <i
- v-if="group.isOpen"
- class="fa fa-folder-open"
- aria-hidden="true"
- >
- </i>
- <i
- v-if="!group.isOpen"
- class="fa fa-folder"
- aria-hidden="true">
- </i>
- </span>
+ class="folder-toggle-wrap"
+ >
+ <item-caret
+ :is-group-open="group.isOpen"
+ />
+ <item-type-icon
+ :item-type="group.type"
+ :is-group-open="group.isOpen"
+ />
</div>
<div
- class="avatar-container s40 hidden-xs">
+ class="avatar-container prepend-top-8 prepend-left-5 s24 hidden-xs"
+ :class="{ 'content-loading': group.isChildrenLoading }"
+ >
<a
- :href="group.groupPath">
+ :href="group.relativePath"
+ class="no-expand"
+ >
<img
- class="avatar s40"
+ v-if="hasAvatar"
+ class="avatar s24"
:src="group.avatarUrl"
/>
+ <identicon
+ v-else
+ size-class="s24"
+ :entity-id="group.id"
+ :entity-name="group.name"
+ />
</a>
</div>
<div
- class="title">
+ class="title namespace-title"
+ >
<a
- :href="group.groupPath">{{fullPath}}</a>
- <template v-if="group.permissions.humanGroupAccess">
- as
- <span class="access-type">{{group.permissions.humanGroupAccess}}</span>
- </template>
+ v-tooltip
+ :href="group.relativePath"
+ :title="group.fullName"
+ class="no-expand"
+ data-placement="bottom"
+ >{{
+ // ending bracket must be by closing tag to prevent
+ // link hover text-decoration from over-extending
+ group.name
+ }}</a>
+ <span
+ v-if="group.permission"
+ class="user-access-role"
+ >
+ {{ group.permission }}
+ </span>
</div>
<div
- class="description">{{group.description}}</div>
+ v-if="group.description"
+ class="description">
+ <span v-html="group.description">
+ </span>
+ </div>
</div>
<group-folder
- v-if="group.isOpen && hasGroups"
- :groups="group.subGroups"
- :baseGroup="group"
+ v-if="group.isOpen && hasChildren"
+ :parent-group="group"
+ :groups="group.children"
/>
</li>
</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 36a04d4202f..adde8c8cdb3 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,39 +1,57 @@
<script>
-import tablePagination from '~/vue_shared/components/table_pagination.vue';
-import eventHub from '../event_hub';
+ import tablePagination from '~/vue_shared/components/table_pagination.vue';
+ import eventHub from '../event_hub';
+ import { getParameterByName } from '../../lib/utils/common_utils';
-export default {
- props: {
- groups: {
- type: Object,
- required: true,
+ export default {
+ components: {
+ tablePagination,
},
- pageInfo: {
- type: Object,
- required: true,
+ props: {
+ groups: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ searchEmpty: {
+ type: Boolean,
+ required: true,
+ },
+ searchEmptyMessage: {
+ type: String,
+ required: true,
+ },
},
- },
- components: {
- tablePagination,
- },
- methods: {
- change(page) {
- const filterGroupsParam = gl.utils.getParameterByName('filter_groups');
- const sortParam = gl.utils.getParameterByName('sort');
- eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
+ methods: {
+ change(page) {
+ const filterGroupsParam = getParameterByName('filter_groups');
+ const sortParam = getParameterByName('sort');
+ const archivedParam = getParameterByName('archived');
+ eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
+ },
},
- },
-};
+ };
</script>
<template>
<div class="groups-list-tree-container">
+ <div
+ v-if="searchEmpty"
+ class="has-no-search-results"
+ >
+ {{ searchEmptyMessage }}
+ </div>
<group-folder
+ v-if="!searchEmpty"
:groups="groups"
/>
<table-pagination
+ v-if="!searchEmpty"
:change="change"
- :pageInfo="pageInfo"
+ :page-info="pageInfo"
/>
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
new file mode 100644
index 00000000000..87065b3d6e3
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -0,0 +1,67 @@
+<script>
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import eventHub from '../event_hub';
+import { COMMON_STR } from '../constants';
+
+export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ parentGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ group: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ leaveBtnTitle() {
+ return COMMON_STR.LEAVE_BTN_TITLE;
+ },
+ editBtnTitle() {
+ return COMMON_STR.EDIT_BTN_TITLE;
+ },
+ },
+ methods: {
+ onLeaveGroup() {
+ eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="controls">
+ <a
+ v-tooltip
+ v-if="group.canEdit"
+ :href="group.editPath"
+ :title="editBtnTitle"
+ :aria-label="editBtnTitle"
+ data-container="body"
+ data-placement="bottom"
+ class="edit-group btn no-expand">
+ <icon name="settings"/>
+ </a>
+ <a
+ v-tooltip
+ v-if="group.canLeave"
+ @click.prevent="onLeaveGroup"
+ :href="group.leavePath"
+ :title="leaveBtnTitle"
+ :aria-label="leaveBtnTitle"
+ data-container="body"
+ data-placement="bottom"
+ class="leave-group btn no-expand">
+ <icon name="leave"/>
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
new file mode 100644
index 00000000000..2a5bec5e86c
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -0,0 +1,30 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ isGroupOpen: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ return this.isGroupOpen ? 'angle-down' : 'angle-right';
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="folder-caret">
+ <icon
+ :size="12"
+ :name="iconClass"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
new file mode 100644
index 00000000000..168b4e4af2c
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -0,0 +1,89 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+ import {
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+ } from '../constants';
+ import itemStatsValue from './item_stats_value.vue';
+
+ export default {
+ components: {
+ icon,
+ timeAgoTooltip,
+ itemStatsValue,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.item.visibility];
+ },
+ visibilityTooltip() {
+ if (this.item.type === ITEM_TYPE.GROUP) {
+ return GROUP_VISIBILITY_TYPE[this.item.visibility];
+ }
+ return PROJECT_VISIBILITY_TYPE[this.item.visibility];
+ },
+ isProject() {
+ return this.item.type === ITEM_TYPE.PROJECT;
+ },
+ isGroup() {
+ return this.item.type === ITEM_TYPE.GROUP;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="stats">
+ <item-stats-value
+ v-if="isGroup"
+ css-class="number-subgroups"
+ icon-name="folder"
+ :title="__('Subgroups')"
+ :value="item.subgroupCount"
+ />
+ <item-stats-value
+ v-if="isGroup"
+ css-class="number-projects"
+ icon-name="bookmark"
+ :title="__('Projects')"
+ :value="item.projectCount"
+ />
+ <item-stats-value
+ v-if="isGroup"
+ css-class="number-users"
+ icon-name="users"
+ :title="__('Members')"
+ :value="item.memberCount"
+ />
+ <item-stats-value
+ v-if="isProject"
+ css-class="project-stars"
+ icon-name="star"
+ :value="item.starCount"
+ />
+ <item-stats-value
+ css-class="item-visibility"
+ tooltip-placement="left"
+ :icon-name="visibilityIcon"
+ :title="visibilityTooltip"
+ />
+ <div
+ class="last-updated"
+ v-if="isProject"
+ >
+ <time-ago-tooltip
+ tooltip-placement="bottom"
+ :time="item.updatedAt"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue
new file mode 100644
index 00000000000..4d86ac8023c
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_stats_value.vue
@@ -0,0 +1,68 @@
+<script>
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ iconName: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'bottom',
+ },
+ /**
+ * value could either be number or string
+ * as `memberCount` is always passed as string
+ * while `subgroupCount` & `projectCount`
+ * are always number
+ */
+ value: {
+ type: [Number, String],
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isValuePresent() {
+ return this.value !== '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <span
+ v-tooltip
+ data-container="body"
+ :data-placement="tooltipPlacement"
+ :class="cssClass"
+ :title="title"
+ >
+ <icon :name="iconName" />
+ <span
+ v-if="isValuePresent"
+ class="stat-value"
+ >
+ {{ value }}
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
new file mode 100644
index 00000000000..118d94d4937
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -0,0 +1,35 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+import { ITEM_TYPE } from '../constants';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ itemType: {
+ type: String,
+ required: true,
+ },
+ isGroupOpen: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ if (this.itemType === ITEM_TYPE.GROUP) {
+ return this.isGroupOpen ? 'folder-open' : 'folder';
+ }
+ return 'bookmark';
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="item-type-icon">
+ <icon :name="iconClass"/>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
new file mode 100644
index 00000000000..b8baed682f5
--- /dev/null
+++ b/app/assets/javascripts/groups/constants.js
@@ -0,0 +1,35 @@
+import { __, s__ } from '../locale';
+
+export const MAX_CHILDREN_COUNT = 20;
+
+export const COMMON_STR = {
+ FAILURE: __('An error occurred. Please try again.'),
+ LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
+ LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
+ EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
+ GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
+ GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
+};
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
+};
+
+export const GROUP_VISIBILITY_TYPE = {
+ public: __('Public - The group and any public projects can be viewed without any authentication.'),
+ internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
+ private: __('Private - The group and its projects can only be viewed by members.'),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ public: __('Public - The project can be accessed without any authentication.'),
+ internal: __('Internal - The project can be accessed by any logged in user.'),
+ private: __('Private - Project access must be granted explicitly to each user.'),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'earth',
+ internal: 'shield',
+ private: 'lock',
+};
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index 439a931ddad..e6db1746487 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -1,13 +1,16 @@
+import $ from 'jquery';
import FilterableList from '~/filterable_list';
import eventHub from './event_hub';
+import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
- constructor({ form, filter, holder, filterEndpoint, pagePath }) {
- super(form, filter, holder);
+ constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
+ super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
- this.$dropdown = $('.js-group-filter-dropdown-wrap');
+ this.filterInputField = filterInputField;
+ this.$dropdown = $(dropdownSel);
}
getFilterEndpoint() {
@@ -23,30 +26,34 @@ export default class GroupFilterableList extends FilterableList {
bindEvents() {
super.bindEvents();
- this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
- this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
}
- onFormSubmit(e) {
- e.preventDefault();
-
- const $form = $(this.form);
- const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ onFilterInput() {
const queryData = {};
+ const $form = $(this.form);
+ const archivedParam = getParameterByName('archived', window.location.href);
+ const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
- queryData.filter_groups = filterGroupsParam;
+ queryData[this.filterInputField] = filterGroupsParam;
+ }
+
+ if (archivedParam) {
+ queryData.archived = archivedParam;
}
this.filterResults(queryData);
- this.setDefaultFilterOption();
+
+ if (this.setDefaultFilterOption) {
+ this.setDefaultFilterOption();
+ }
}
setDefaultFilterOption() {
- const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
+ const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
@@ -54,34 +61,48 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault();
const queryData = {};
- const sortParam = gl.utils.getParameterByName('sort', e.currentTarget.href);
+
+ // Get type of option selected from dropdown
+ const currentTargetClassList = e.currentTarget.parentElement.classList;
+ const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
+ const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
+
+ // Get option query param, also preserve currently applied query param
+ const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
+ const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
if (sortParam) {
queryData.sort = sortParam;
}
+ if (archivedParam) {
+ queryData.archived = archivedParam;
+ }
+
this.filterResults(queryData);
// Active selected option
- this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+ if (isOptionFilterBySort) {
+ this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+ this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
+ } else if (isOptionFilterByArchivedProjects) {
+ this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
+ }
+
+ $(e.target).addClass('is-active');
// Clear current value on search form
- this.form.querySelector('[name="filter_groups"]').value = '';
+ this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
}
- onFilterSuccess(data, xhr, queryData) {
- super.onFilterSuccess(data, xhr, queryData);
+ onFilterSuccess(res, queryData) {
+ const currentPath = this.getPagePath(queryData);
- const paginationData = {
- 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
- 'X-Page': xhr.getResponseHeader('X-Page'),
- 'X-Total': xhr.getResponseHeader('X-Total'),
- 'X-Total-Pages': xhr.getResponseHeader('X-Total-Pages'),
- 'X-Next-Page': xhr.getResponseHeader('X-Next-Page'),
- 'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
- };
+ window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
- eventHub.$emit('updateGroups', data);
- eventHub.$emit('updatePagination', paginationData);
+ eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
+ eventHub.$emit('updatePagination', normalizeHeaders(res.headers));
}
}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index ff601db2aa6..57eaac72906 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -1,16 +1,17 @@
-/* global Flash */
-
import Vue from 'vue';
+import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
-import GroupsComponent from './components/groups.vue';
-import GroupFolder from './components/group_folder.vue';
-import GroupItem from './components/group_item.vue';
-import GroupsStore from './stores/groups_store';
-import GroupsService from './services/groups_service';
-import eventHub from './event_hub';
+import GroupsStore from './store/groups_store';
+import GroupsService from './service/groups_service';
+
+import groupsApp from './components/app.vue';
+import groupFolderComponent from './components/group_folder.vue';
+import groupItemComponent from './components/group_item.vue';
+
+Vue.use(Translate);
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('dashboard-group-app');
+export default () => {
+ const el = document.getElementById('js-groups-tree');
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
@@ -18,173 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- Vue.component('groups-component', GroupsComponent);
- Vue.component('group-folder', GroupFolder);
- Vue.component('group-item', GroupItem);
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
// eslint-disable-next-line no-new
new Vue({
el,
+ components: {
+ groupsApp,
+ },
data() {
- this.store = new GroupsStore();
- this.service = new GroupsService(el.dataset.endpoint);
+ const dataset = this.$options.el.dataset;
+ const hideProjects = dataset.hideProjects === 'true';
+ const store = new GroupsStore(hideProjects);
+ const service = new GroupsService(dataset.endpoint);
return {
- store: this.store,
- isLoading: true,
- state: this.store.state,
+ store,
+ service,
+ hideProjects,
loading: true,
};
},
- computed: {
- isEmpty() {
- return Object.keys(this.state.groups).length === 0;
- },
- },
- methods: {
- fetchGroups(parentGroup) {
- let parentId = null;
- let getGroups = null;
- let page = null;
- let sort = null;
- let pageParam = null;
- let sortParam = null;
- let filterGroups = null;
- let filterGroupsParam = null;
-
- if (parentGroup) {
- parentId = parentGroup.id;
- } else {
- this.isLoading = true;
- }
-
- pageParam = gl.utils.getParameterByName('page');
- if (pageParam) {
- page = pageParam;
- }
-
- filterGroupsParam = gl.utils.getParameterByName('filter_groups');
- if (filterGroupsParam) {
- filterGroups = filterGroupsParam;
- }
-
- sortParam = gl.utils.getParameterByName('sort');
- if (sortParam) {
- sort = sortParam;
- }
-
- getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
- getGroups
- .then(response => response.json())
- .then((response) => {
- this.isLoading = false;
-
- this.updateGroups(response, parentGroup);
- })
- .catch(this.handleErrorResponse);
-
- return getGroups;
- },
- fetchPage(page, filterGroups, sort) {
- this.isLoading = true;
-
- return this.service
- .getGroups(null, page, filterGroups, sort)
- .then((response) => {
- this.isLoading = false;
- $.scrollTo(0);
-
- const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
- window.history.replaceState({
- page: currentPath,
- }, document.title, currentPath);
-
- this.updateGroups(response.json());
- this.updatePagination(response.headers);
- })
- .catch(this.handleErrorResponse);
- },
- toggleSubGroups(parentGroup = null) {
- if (!parentGroup.isOpen) {
- this.store.resetGroups(parentGroup);
- this.fetchGroups(parentGroup);
- }
-
- this.store.toggleSubGroups(parentGroup);
- },
- leaveGroup(group, collection) {
- this.service.leaveGroup(group.leavePath)
- .then((response) => {
- $.scrollTo(0);
-
- this.store.removeGroup(group, collection);
-
- // eslint-disable-next-line no-new
- new Flash(response.json().notice, 'notice');
- })
- .catch((response) => {
- let message = 'An error occurred. Please try again.';
-
- if (response.status === 403) {
- message = 'Failed to leave the group. Please make sure you are not the only owner';
- }
-
- // eslint-disable-next-line no-new
- new Flash(message);
- });
- },
- updateGroups(groups, parentGroup) {
- this.store.setGroups(groups, parentGroup);
- },
- updatePagination(headers) {
- this.store.storePagination(headers);
- },
- handleErrorResponse() {
- this.isLoading = false;
- $.scrollTo(0);
-
- // eslint-disable-next-line no-new
- new Flash('An error occurred. Please try again.');
- },
- },
- created() {
- eventHub.$on('fetchPage', this.fetchPage);
- eventHub.$on('toggleSubGroups', this.toggleSubGroups);
- eventHub.$on('leaveGroup', this.leaveGroup);
- eventHub.$on('updateGroups', this.updateGroups);
- eventHub.$on('updatePagination', this.updatePagination);
- },
beforeMount() {
+ const dataset = this.$options.el.dataset;
let groupFilterList = null;
- const form = document.querySelector('form#group-filter-form');
- const filter = document.querySelector('.js-groups-list-filter');
- const holder = document.querySelector('.js-groups-list-holder');
+ const form = document.querySelector(dataset.formSel);
+ const filter = document.querySelector(dataset.filterSel);
+ const holder = document.querySelector(dataset.holderSel);
const opts = {
form,
filter,
holder,
- filterEndpoint: el.dataset.endpoint,
- pagePath: el.dataset.path,
+ filterEndpoint: dataset.endpoint,
+ pagePath: dataset.path,
+ dropdownSel: dataset.dropdownSel,
+ filterInputField: 'filter',
};
groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch();
},
- mounted() {
- this.fetchGroups()
- .then((response) => {
- this.updatePagination(response.headers);
- this.isLoading = false;
- })
- .catch(this.handleErrorResponse);
- },
- beforeDestroy() {
- eventHub.$off('fetchPage', this.fetchPage);
- eventHub.$off('toggleSubGroups', this.toggleSubGroups);
- eventHub.$off('leaveGroup', this.leaveGroup);
- eventHub.$off('updateGroups', this.updateGroups);
- eventHub.$off('updatePagination', this.updatePagination);
+ render(createElement) {
+ return createElement('groups-app', {
+ props: {
+ store: this.store,
+ service: this.service,
+ hideProjects: this.hideProjects,
+ },
+ });
},
});
-});
+};
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
new file mode 100644
index 00000000000..a120d501e35
--- /dev/null
+++ b/app/assets/javascripts/groups/new_group_child.js
@@ -0,0 +1,63 @@
+import { visitUrl } from '../lib/utils/url_utility';
+import DropLab from '../droplab/drop_lab';
+import ISetter from '../droplab/plugins/input_setter';
+
+const InputSetter = Object.assign({}, ISetter);
+
+const NEW_PROJECT = 'new-project';
+const NEW_SUBGROUP = 'new-subgroup';
+
+export default class NewGroupChild {
+ constructor(buttonWrapper) {
+ this.buttonWrapper = buttonWrapper;
+ this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
+ this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
+ this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
+
+ this.newGroupPath = this.buttonWrapper.dataset.projectPath;
+ this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
+
+ this.init();
+ }
+
+ init() {
+ this.initDroplab();
+ this.bindEvents();
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+ this.droplab.init(
+ this.dropdownToggle,
+ this.dropdownList,
+ [InputSetter],
+ this.getDroplabConfig(),
+ );
+ }
+
+ getDroplabConfig() {
+ return {
+ InputSetter: [{
+ input: this.newGroupChildButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ }, {
+ input: this.newGroupChildButton,
+ valueAttribute: 'data-text',
+ }],
+ };
+ }
+
+ bindEvents() {
+ this.newGroupChildButton
+ .addEventListener('click', this.onClickNewGroupChildButton.bind(this));
+ }
+
+ onClickNewGroupChildButton(e) {
+ if (e.target.dataset.action === NEW_PROJECT) {
+ visitUrl(this.newGroupPath);
+ } else if (e.target.dataset.action === NEW_SUBGROUP) {
+ visitUrl(this.subgroupPath);
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/service/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
new file mode 100644
index 00000000000..b79ba291463
--- /dev/null
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import '../../vue_shared/vue_resource_interceptor';
+
+export default class GroupsService {
+ constructor(endpoint) {
+ this.groups = Vue.resource(endpoint);
+ }
+
+ getGroups(parentId, page, filterGroups, sort, archived) {
+ const data = {};
+
+ if (parentId) {
+ data.parent_id = parentId;
+ } else {
+ // Do not send the following param for sub groups
+ if (page) {
+ data.page = page;
+ }
+
+ if (filterGroups) {
+ data.filter = filterGroups;
+ }
+
+ if (sort) {
+ data.sort = sort;
+ }
+
+ if (archived) {
+ data.archived = archived;
+ }
+ }
+
+ return this.groups.get(data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ leaveGroup(endpoint) {
+ return Vue.http.delete(endpoint);
+ }
+}
diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/services/groups_service.js
deleted file mode 100644
index 97e02fcb76d..00000000000
--- a/app/assets/javascripts/groups/services/groups_service.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
-
-export default class GroupsService {
- constructor(endpoint) {
- this.groups = Vue.resource(endpoint);
- }
-
- getGroups(parentId, page, filterGroups, sort) {
- const data = {};
-
- if (parentId) {
- data.parent_id = parentId;
- } else {
- // Do not send the following param for sub groups
- if (page) {
- data.page = page;
- }
-
- if (filterGroups) {
- data.filter_groups = filterGroups;
- }
-
- if (sort) {
- data.sort = sort;
- }
- }
-
- return this.groups.get(data);
- }
-
- // eslint-disable-next-line class-methods-use-this
- leaveGroup(endpoint) {
- return Vue.http.delete(endpoint);
- }
-}
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
new file mode 100644
index 00000000000..4a7569078a1
--- /dev/null
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -0,0 +1,106 @@
+import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
+
+export default class GroupsStore {
+ constructor(hideProjects) {
+ this.state = {};
+ this.state.groups = [];
+ this.state.pageInfo = {};
+ this.hideProjects = hideProjects;
+ }
+
+ setGroups(rawGroups) {
+ if (rawGroups && rawGroups.length) {
+ this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
+ } else {
+ this.state.groups = [];
+ }
+ }
+
+ setSearchedGroups(rawGroups) {
+ const formatGroups = groups => groups.map((group) => {
+ const formattedGroup = this.formatGroupItem(group);
+ if (formattedGroup.children && formattedGroup.children.length) {
+ formattedGroup.children = formatGroups(formattedGroup.children);
+ }
+ return formattedGroup;
+ });
+
+ if (rawGroups && rawGroups.length) {
+ this.state.groups = formatGroups(rawGroups);
+ } else {
+ this.state.groups = [];
+ }
+ }
+
+ setGroupChildren(parentGroup, children) {
+ const updatedParentGroup = parentGroup;
+ updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
+ updatedParentGroup.isOpen = true;
+ updatedParentGroup.isChildrenLoading = false;
+ }
+
+ getGroups() {
+ return this.state.groups;
+ }
+
+ setPaginationInfo(pagination = {}) {
+ let paginationInfo;
+
+ if (Object.keys(pagination).length) {
+ const normalizedHeaders = normalizeHeaders(pagination);
+ paginationInfo = parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = pagination;
+ }
+
+ this.state.pageInfo = paginationInfo;
+ }
+
+ getPaginationInfo() {
+ return this.state.pageInfo;
+ }
+
+ formatGroupItem(rawGroupItem) {
+ const groupChildren = rawGroupItem.children || [];
+ const groupIsOpen = (groupChildren.length > 0) || false;
+ const childrenCount = this.hideProjects ?
+ rawGroupItem.subgroup_count :
+ rawGroupItem.children_count;
+
+ return {
+ id: rawGroupItem.id,
+ name: rawGroupItem.name,
+ fullName: rawGroupItem.full_name,
+ description: rawGroupItem.markdown_description,
+ visibility: rawGroupItem.visibility,
+ avatarUrl: rawGroupItem.avatar_url,
+ relativePath: rawGroupItem.relative_path,
+ editPath: rawGroupItem.edit_path,
+ leavePath: rawGroupItem.leave_path,
+ canEdit: rawGroupItem.can_edit,
+ canLeave: rawGroupItem.can_leave,
+ type: rawGroupItem.type,
+ permission: rawGroupItem.permission,
+ children: groupChildren,
+ isOpen: groupIsOpen,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ parentId: rawGroupItem.parent_id,
+ childrenCount,
+ projectCount: rawGroupItem.project_count,
+ subgroupCount: rawGroupItem.subgroup_count,
+ memberCount: rawGroupItem.number_users_with_delimiter,
+ starCount: rawGroupItem.star_count,
+ updatedAt: rawGroupItem.updated_at,
+ };
+ }
+
+ removeGroup(group, parentGroup) {
+ const updatedParentGroup = parentGroup;
+ if (updatedParentGroup.children && updatedParentGroup.children.length) {
+ updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
+ } else {
+ this.state.groups = this.state.groups.filter(child => group.id !== child.id);
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
deleted file mode 100644
index 6eab6083e8f..00000000000
--- a/app/assets/javascripts/groups/stores/groups_store.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import Vue from 'vue';
-
-export default class GroupsStore {
- constructor() {
- this.state = {};
- this.state.groups = {};
- this.state.pageInfo = {};
- }
-
- setGroups(rawGroups, parent) {
- const parentGroup = parent;
- const tree = this.buildTree(rawGroups, parentGroup);
-
- if (parentGroup) {
- parentGroup.subGroups = tree;
- } else {
- this.state.groups = tree;
- }
-
- return tree;
- }
-
- // eslint-disable-next-line class-methods-use-this
- resetGroups(parent) {
- const parentGroup = parent;
- parentGroup.subGroups = {};
- }
-
- storePagination(pagination = {}) {
- let paginationInfo;
-
- if (Object.keys(pagination).length) {
- const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
- paginationInfo = gl.utils.parseIntPagination(normalizedHeaders);
- } else {
- paginationInfo = pagination;
- }
-
- this.state.pageInfo = paginationInfo;
- }
-
- buildTree(rawGroups, parentGroup) {
- const groups = this.decorateGroups(rawGroups);
- const tree = {};
- const mappedGroups = {};
- const orphans = [];
-
- // Map groups to an object
- groups.map((group) => {
- mappedGroups[`id${group.id}`] = group;
- mappedGroups[`id${group.id}`].subGroups = {};
- return group;
- });
-
- Object.keys(mappedGroups).map((key) => {
- const currentGroup = mappedGroups[key];
- if (currentGroup.parentId) {
- // If the group is not at the root level, add it to its parent array of subGroups.
- const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
- if (findParentGroup) {
- mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
- mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
- } else if (parentGroup && parentGroup.id === currentGroup.parentId) {
- tree[`id${currentGroup.id}`] = currentGroup;
- } else {
- // No parent found. We save it for later processing
- orphans.push(currentGroup);
-
- // Add to tree to preserve original order
- tree[`id${currentGroup.id}`] = currentGroup;
- }
- } else {
- // If the group is at the top level, add it to first level elements array.
- tree[`id${currentGroup.id}`] = currentGroup;
- }
-
- return key;
- });
-
- if (orphans.length) {
- orphans.map((orphan) => {
- let found = false;
- const currentOrphan = orphan;
-
- Object.keys(tree).map((key) => {
- const group = tree[key];
-
- if (
- group &&
- currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
- // Make sure the currently selected orphan is not the same as the group
- // we are checking here otherwise it will end up in an infinite loop
- currentOrphan.id !== group.id
- ) {
- group.subGroups[currentOrphan.id] = currentOrphan;
- group.isOpen = true;
- currentOrphan.isOrphan = true;
- found = true;
-
- // Delete if group was put at the top level. If not the group will be displayed twice.
- if (tree[`id${currentOrphan.id}`]) {
- delete tree[`id${currentOrphan.id}`];
- }
- }
-
- return key;
- });
-
- if (!found) {
- currentOrphan.isOrphan = true;
-
- tree[`id${currentOrphan.id}`] = currentOrphan;
- }
-
- return orphan;
- });
- }
-
- return tree;
- }
-
- decorateGroups(rawGroups) {
- this.groups = rawGroups.map(this.decorateGroup);
- return this.groups;
- }
-
- // eslint-disable-next-line class-methods-use-this
- decorateGroup(rawGroup) {
- return {
- id: rawGroup.id,
- fullName: rawGroup.full_name,
- fullPath: rawGroup.full_path,
- avatarUrl: rawGroup.avatar_url,
- name: rawGroup.name,
- hasSubgroups: rawGroup.has_subgroups,
- canEdit: rawGroup.can_edit,
- description: rawGroup.description,
- webUrl: rawGroup.web_url,
- groupPath: rawGroup.group_path,
- parentId: rawGroup.parent_id,
- visibility: rawGroup.visibility,
- leavePath: rawGroup.leave_path,
- editPath: rawGroup.edit_path,
- isOpen: false,
- isOrphan: false,
- numberProjects: rawGroup.number_projects_with_delimiter,
- numberUsers: rawGroup.number_users_with_delimiter,
- permissions: {
- humanGroupAccess: rawGroup.permissions.human_group_access,
- },
- subGroups: {},
- };
- }
-
- // eslint-disable-next-line class-methods-use-this
- removeGroup(group, collection) {
- Vue.delete(collection, `id${group.id}`);
- }
-
- // eslint-disable-next-line class-methods-use-this
- toggleSubGroups(toggleGroup) {
- const group = toggleGroup;
- group.isOpen = !group.isOpen;
- return group;
- }
-}
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js
new file mode 100644
index 00000000000..e0eb118ddf7
--- /dev/null
+++ b/app/assets/javascripts/groups/transfer_dropdown.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+
+export default class TransferDropdown {
+ constructor() {
+ this.groupDropdown = $('.js-groups-dropdown');
+ this.parentInput = $('#new_parent_group_id');
+ this.data = this.groupDropdown.data('data');
+ this.init();
+ }
+
+ init() {
+ this.buildDropdown();
+ }
+
+ buildDropdown() {
+ const extraOptions = [{ id: '', text: 'No parent group' }, 'divider'];
+
+ this.groupDropdown.glDropdown({
+ selectable: true,
+ filterable: true,
+ toggleLabel: item => item.text,
+ search: { fields: ['text'] },
+ data: extraOptions.concat(this.data),
+ text: item => item.text,
+ clicked: (options) => {
+ const { e } = options;
+ e.preventDefault();
+ this.assignSelected(options.selectedObj);
+ },
+ });
+ }
+
+ assignSelected(selected) {
+ this.parentInput.val(selected.id);
+ }
+}
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index b5975295329..310f6fe06cf 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -1,121 +1,87 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, one-var,
- camelcase, one-var-declaration-per-line, quotes, object-shorthand,
- prefer-arrow-callback, comma-dangle, consistent-return, yoda,
- prefer-rest-params, prefer-spread, no-unused-vars, prefer-template,
- promise/catch-or-return */
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
import Api from './api';
+import { normalizeHeaders } from './lib/utils/common_utils';
-var slice = [].slice;
+export default function groupsSelect() {
+ // Needs to be accessible in rspec
+ window.GROUP_SELECT_PER_PAGE = 20;
+ $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
+ const $select = $(this);
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
+ $select.select2({
+ placeholder: 'Search for a group',
+ multiple: $select.hasClass('multiselect'),
+ minimumInputLength: 0,
+ ajax: {
+ url: Api.buildUrl(Api.groupsPath),
+ dataType: 'json',
+ quietMillis: 250,
+ transport(params) {
+ axios[params.type.toLowerCase()](params.url, {
+ params: params.data,
+ })
+ .then((res) => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
+ const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
+ const more = currentPage < totalPages;
-window.GroupsSelect = (function() {
- function GroupsSelect() {
- $('.ajax-groups-select').each((function(_this) {
- const self = _this;
-
- return function(i, select) {
- var all_available, skip_groups;
- const $select = $(select);
- all_available = $select.data('all-available');
- skip_groups = $select.data('skip-groups') || [];
-
- $select.select2({
- placeholder: "Search for a group",
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(Api.groupsPath),
- dataType: 'json',
- quietMillis: 250,
- transport: function (params) {
- $.ajax(params).then((data, status, xhr) => {
- const results = data || [];
-
- const headers = gl.utils.normalizeCRLFHeaders(xhr.getAllResponseHeaders());
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
-
- return {
- results,
- pagination: {
- more,
- },
- };
- }).then(params.success).fail(params.error);
- },
- data: function (search, page) {
- return {
- search,
- page,
- per_page: GroupsSelect.PER_PAGE,
- all_available,
- };
- },
- results: function (data, page) {
- if (data.length) return { results: [] };
-
- const groups = data.length ? data : data.results || [];
- const more = data.pagination ? data.pagination.more : false;
- const results = groups.filter(group => skip_groups.indexOf(group.id) === -1);
-
- return {
+ params.success({
results,
- page,
- more,
- };
- },
- },
- initSelection: function(element, callback) {
- var id;
- id = $(element).val();
- if (id !== "") {
- return Api.group(id, callback);
- }
- },
- formatResult: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return self.formatResult.apply(self, args);
- },
- formatSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return self.formatSelection.apply(self, args);
- },
- dropdownCssClass: "ajax-groups-dropdown select2-infinite",
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup: function(m) {
- return m;
- }
- });
-
- self.dropdown = document.querySelector('.select2-infinite .select2-results');
-
- $select.on('select2-loaded', self.forceOverflow.bind(self));
- };
- })(this));
- }
-
- GroupsSelect.prototype.formatResult = function(group) {
- var avatar;
- if (group.avatar_url) {
- avatar = group.avatar_url;
- } else {
- avatar = gon.default_avatar_url;
- }
- return "<div class='group-result'> <div class='group-name'>" + group.full_name + "</div> <div class='group-path'>" + group.full_path + "</div> </div>";
- };
-
- GroupsSelect.prototype.formatSelection = function(group) {
- return group.full_name;
- };
+ pagination: {
+ more,
+ },
+ });
+ }).catch(params.error);
+ },
+ data(search, page) {
+ return {
+ search,
+ page,
+ per_page: window.GROUP_SELECT_PER_PAGE,
+ all_available: allAvailable,
+ };
+ },
+ results(data, page) {
+ if (data.length) return { results: [] };
- GroupsSelect.prototype.forceOverflow = function (e) {
- const itemHeight = this.dropdown.querySelector('.select2-result:first-child').clientHeight;
- this.dropdown.style.height = `${Math.floor(this.dropdown.scrollHeight - (itemHeight * 0.9))}px`;
- };
+ const groups = data.length ? data : data.results || [];
+ const more = data.pagination ? data.pagination.more : false;
+ const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
- GroupsSelect.PER_PAGE = 20;
+ return {
+ results,
+ page,
+ more,
+ };
+ },
+ },
+ // eslint-disable-next-line consistent-return
+ initSelection(element, callback) {
+ const id = $(element).val();
+ if (id !== '') {
+ return Api.group(id, callback);
+ }
+ },
+ formatResult(object) {
+ return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`;
+ },
+ formatSelection(object) {
+ return object.full_name;
+ },
+ dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
- return GroupsSelect;
-})();
+ $select.on('select2-loaded', () => {
+ const dropdown = document.querySelector('.select2-infinite .select2-results');
+ dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
+ });
+ });
+}
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index dc170c60456..4ae3a714bee 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,7 +1,19 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var */
+import $ from 'jquery';
+import { highCountTrim } from '~/lib/utils/text_utility';
-$(document).on('todo:toggle', function(e, count) {
- var $todoPendingCount = $('.todos-count');
- $todoPendingCount.text(gl.text.highCountTrim(count));
- $todoPendingCount.toggleClass('hidden', count === 0);
-});
+/**
+ * Updates todo counter when todos are toggled.
+ * When count is 0, we hide the badge.
+ *
+ * @param {jQuery.Event} e
+ * @param {String} count
+ */
+export default function initTodoToggle() {
+ $(document).on('todo:toggle', (e, count) => {
+ const parsedCount = parseInt(count, 10);
+ const $todoPendingCount = $('.todos-count');
+
+ $todoPendingCount.text(highCountTrim(parsedCount));
+ $todoPendingCount.toggleClass('hidden', parsedCount === 0);
+ });
+}
diff --git a/app/assets/javascripts/help/help.js b/app/assets/javascripts/help/help.js
new file mode 100644
index 00000000000..f5333042bb8
--- /dev/null
+++ b/app/assets/javascripts/help/help.js
@@ -0,0 +1,11 @@
+// We will render the icons list here
+
+import $ from 'jquery';
+
+export default () => {
+ if ($('#user-content-gitlab-icons').length > 0) {
+ const $iconsHeader = $('#user-content-gitlab-icons');
+ const $iconsList = $('<div id="iconsList">ICONS</div>');
+ $($iconsList).insertAfter($iconsHeader.parent());
+ }
+};
diff --git a/app/assets/javascripts/helpers/issuables_helper.js b/app/assets/javascripts/helpers/issuables_helper.js
new file mode 100644
index 00000000000..52d0f7e43fc
--- /dev/null
+++ b/app/assets/javascripts/helpers/issuables_helper.js
@@ -0,0 +1,27 @@
+import CloseReopenReportToggle from '../close_reopen_report_toggle';
+
+function initCloseReopenReport() {
+ const container = document.querySelector('.js-issuable-close-dropdown');
+
+ if (!container) return undefined;
+
+ const dropdownTrigger = container.querySelector('.js-issuable-close-toggle');
+ const dropdownList = container.querySelector('.js-issuable-close-menu');
+ const button = container.querySelector('.js-issuable-close-button');
+
+ const closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ closeReopenReportToggle.initDroplab();
+
+ return closeReopenReportToggle;
+}
+
+const IssuablesHelper = {
+ initCloseReopenReport,
+};
+
+export default IssuablesHelper;
diff --git a/app/assets/javascripts/how_to_merge.js b/app/assets/javascripts/how_to_merge.js
new file mode 100644
index 00000000000..bb734246584
--- /dev/null
+++ b/app/assets/javascripts/how_to_merge.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+
+export default () => {
+ const modal = $('#modal_merge_info');
+
+ if (modal) {
+ modal.modal({
+ modal: true,
+ show: false,
+ });
+
+ $('.how_to_merge_link').on('click', modal.show);
+ $('.modal-header .close').on('click', modal.hide);
+ }
+};
diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue
new file mode 100644
index 00000000000..0c54c992e51
--- /dev/null
+++ b/app/assets/javascripts/ide/components/changed_file_icon.vue
@@ -0,0 +1,31 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ changedIcon() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ changedIconClass() {
+ return `multi-${this.changedIcon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <icon
+ :name="changedIcon"
+ :size="12"
+ :css-classes="`ide-file-changed-icon ${changedIconClass}`"
+ />
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
new file mode 100644
index 00000000000..2cbd982af19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -0,0 +1,65 @@
+<script>
+ import { mapState } from 'vuex';
+ import { sprintf, __ } from '~/locale';
+ import * as consts from '../../stores/modules/commit/constants';
+ import RadioGroup from './radio_group.vue';
+
+ export default {
+ components: {
+ RadioGroup,
+ },
+ computed: {
+ ...mapState([
+ 'currentBranchId',
+ ]),
+ newMergeRequestHelpText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName} and re-directs to create a new merge request'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ commitToCurrentBranchText() {
+ return sprintf(
+ __('Commit to %{branchName} branch'),
+ { branchName: `<strong>${this.currentBranchId}</strong>` },
+ false,
+ );
+ },
+ commitToNewBranchText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName}'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ },
+ commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
+ commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
+ commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
+ };
+</script>
+
+<template>
+ <div class="append-bottom-15 ide-commit-radios">
+ <radio-group
+ :value="$options.commitToCurrentBranch"
+ :checked="true"
+ >
+ <span
+ v-html="commitToCurrentBranchText"
+ >
+ </span>
+ </radio-group>
+ <radio-group
+ :value="$options.commitToNewBranch"
+ :label="__('Create a new branch')"
+ :show-input="true"
+ :help-text="commitToNewBranchText"
+ />
+ <radio-group
+ :value="$options.commitToNewBranchMR"
+ :label="__('Create a new branch and merge request')"
+ :show-input="true"
+ :help-text="newMergeRequestHelpText"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
new file mode 100644
index 00000000000..453208f3f19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -0,0 +1,66 @@
+<script>
+ import { mapState } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import listItem from './list_item.vue';
+ import listCollapsed from './list_collapsed.vue';
+
+ export default {
+ components: {
+ icon,
+ listItem,
+ listCollapsed,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ fileList: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'currentProjectId',
+ 'currentBranchId',
+ 'rightPanelCollapsed',
+ ]),
+ isCommitInfoShown() {
+ return this.rightPanelCollapsed || this.fileList.length;
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ this.$emit('toggleCollapsed');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ :class="{
+ 'multi-file-commit-list': isCommitInfoShown
+ }"
+ >
+ <list-collapsed
+ v-if="rightPanelCollapsed"
+ />
+ <template v-else>
+ <ul
+ v-if="fileList.length"
+ class="list-unstyled append-bottom-0"
+ >
+ <li
+ v-for="file in fileList"
+ :key="file.key"
+ >
+ <list-item
+ :file="file"
+ />
+ </li>
+ </ul>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
new file mode 100644
index 00000000000..15918ac9631
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -0,0 +1,35 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ computed: {
+ ...mapGetters([
+ 'addedFiles',
+ 'modifiedFiles',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-list-collapsed text-center"
+ >
+ <icon
+ name="file-addition"
+ :size="18"
+ css-classes="multi-file-addition append-bottom-10"
+ />
+ {{ addedFiles.length }}
+ <icon
+ name="file-modified"
+ :size="18"
+ css-classes="multi-file-modified prepend-top-10 append-bottom-10"
+ />
+ {{ modifiedFiles.length }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
new file mode 100644
index 00000000000..18934af004a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -0,0 +1,60 @@
+<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import router from '../../ide_router';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ iconName() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ iconClass() {
+ return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'discardFileChanges',
+ 'updateViewer',
+ ]),
+ openFileInEditor(file) {
+ this.updateViewer('diff');
+
+ router.push(`/project${file.url}`);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-commit-list-item">
+ <button
+ type="button"
+ class="multi-file-commit-list-path"
+ @click="openFileInEditor(file)">
+ <span class="multi-file-commit-list-file-path">
+ <icon
+ :name="iconName"
+ :size="16"
+ :css-classes="iconClass"
+ />{{ file.path }}
+ </span>
+ </button>
+ <button
+ type="button"
+ class="btn btn-blank multi-file-discard-btn"
+ @click="discardFileChanges(file.path)"
+ >
+ Discard
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
new file mode 100644
index 00000000000..4310d762c78
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -0,0 +1,94 @@
+<script>
+ import { mapActions, mapState, mapGetters } from 'vuex';
+ import tooltip from '~/vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ ...mapState('commit', [
+ 'commitAction',
+ ]),
+ ...mapGetters('commit', [
+ 'newBranchName',
+ ]),
+ },
+ methods: {
+ ...mapActions('commit', [
+ 'updateCommitAction',
+ 'updateBranchName',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label>
+ <input
+ type="radio"
+ name="commit-action"
+ :value="value"
+ @change="updateCommitAction($event.target.value)"
+ :checked="checked"
+ v-once
+ />
+ <span class="prepend-left-10">
+ <template v-if="label">
+ {{ label }}
+ </template>
+ <slot v-else></slot>
+ <span
+ v-if="helpText"
+ v-tooltip
+ class="help-block inline"
+ :title="helpText"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </span>
+ </label>
+ <div
+ v-if="commitAction === value && showInput"
+ class="ide-commit-new-branch"
+ >
+ <input
+ type="text"
+ class="form-control"
+ :placeholder="newBranchName"
+ @input="updateBranchName($event.target.value)"
+ />
+ </div>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
new file mode 100644
index 00000000000..170347881e0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -0,0 +1,91 @@
+<script>
+ import Icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ Icon,
+ },
+ props: {
+ hasChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ showShadow: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ changeMode(mode) {
+ this.$emit('click', mode);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown"
+ :class="{
+ shadow: showShadow,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ :class="{
+ 'btn-inverted': hasChanges,
+ }"
+ data-toggle="dropdown"
+ >
+ <template v-if="viewer === 'editor'">
+ {{ __('Editing') }}
+ </template>
+ <template v-else>
+ {{ __('Reviewing') }}
+ </template>
+ <icon
+ name="angle-down"
+ :size="12"
+ css-classes="caret-down"
+ />
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
+ <ul>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('editor')"
+ :class="{
+ 'is-active': viewer === 'editor',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('View and edit lines') }}
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('diff')"
+ :class="{
+ 'is-active': viewer === 'diff',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('Compare changes with the last commit') }}
+ </span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
new file mode 100644
index 00000000000..015e750525a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -0,0 +1,111 @@
+<script>
+ import { mapState, mapGetters } from 'vuex';
+ import ideSidebar from './ide_side_bar.vue';
+ import ideContextbar from './ide_context_bar.vue';
+ import repoTabs from './repo_tabs.vue';
+ import repoFileButtons from './repo_file_buttons.vue';
+ import ideStatusBar from './ide_status_bar.vue';
+ import repoEditor from './repo_editor.vue';
+
+ export default {
+ components: {
+ ideSidebar,
+ ideContextbar,
+ repoTabs,
+ repoFileButtons,
+ ideStatusBar,
+ repoEditor,
+ },
+ props: {
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'openFiles', 'viewer']),
+ ...mapGetters(['activeFile', 'hasChanges']),
+ },
+ mounted() {
+ const returnValue = 'Are you sure you want to lose unsaved changes?';
+ window.onbeforeunload = e => {
+ if (!this.changedFiles.length) return undefined;
+
+ Object.assign(e, {
+ returnValue,
+ });
+ return returnValue;
+ };
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="ide-view"
+ >
+ <ide-sidebar />
+ <div
+ class="multi-file-edit-pane"
+ >
+ <template
+ v-if="activeFile"
+ >
+ <repo-tabs
+ :files="openFiles"
+ :viewer="viewer"
+ :has-changes="hasChanges"
+ />
+ <repo-editor
+ class="multi-file-edit-pane-content"
+ :file="activeFile"
+ />
+ <repo-file-buttons
+ :file="activeFile"
+ />
+ <ide-status-bar
+ :file="activeFile"
+ />
+ </template>
+ <template
+ v-else
+ >
+ <div
+ v-once
+ class="ide-empty-state"
+ >
+ <div class="row js-empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content svg-250">
+ <img :src="emptyStateSvgPath" />
+ </div>
+ </div>
+ <div class="col-xs-12">
+ <div class="text-content text-center">
+ <h4>
+ Welcome to the GitLab IDE
+ </h4>
+ <p>
+ You can select a file in the left sidebar to begin
+ editing and use the right sidebar to commit your changes.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+ <ide-contextbar
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
new file mode 100644
index 00000000000..79a83b47994
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_context_bar.vue
@@ -0,0 +1,84 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import icon from '~/vue_shared/components/icon.vue';
+import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+import repoCommitSection from './repo_commit_section.vue';
+import ResizablePanel from './resizable_panel.vue';
+
+export default {
+ components: {
+ repoCommitSection,
+ icon,
+ panelResizer,
+ ResizablePanel,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'rightPanelCollapsed']),
+ ...mapGetters(['currentIcon']),
+ },
+ methods: {
+ ...mapActions(['setPanelCollapsedStatus']),
+ },
+};
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="true"
+ :initial-width="340"
+ side="right"
+ >
+ <div
+ class="multi-file-commit-panel-section"
+ >
+ <header
+ class="multi-file-commit-panel-header"
+ :class="{
+ 'is-collapsed': rightPanelCollapsed,
+ }"
+ >
+ <div
+ class="multi-file-commit-panel-header-title"
+ v-if="!rightPanelCollapsed"
+ >
+ <div
+ v-if="changedFiles.length"
+ >
+ <icon
+ name="list-bulleted"
+ :size="18"
+ />
+ Staged
+ </div>
+ </div>
+ <button
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn"
+ @click.stop="setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !rightPanelCollapsed,
+ })"
+ >
+ <icon
+ :name="currentIcon"
+ :size="18"
+ />
+ </button>
+ </header>
+ <repo-commit-section
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue
new file mode 100644
index 00000000000..c6f6e0d2348
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_external_links.vue
@@ -0,0 +1,43 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ projectUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ goBackUrl() {
+ return document.referrer || this.projectUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav
+ class="ide-external-links"
+ v-once
+ >
+ <p>
+ <a
+ :href="goBackUrl"
+ class="ide-sidebar-link"
+ >
+ <icon
+ :size="16"
+ class="append-right-8"
+ name="go-back"
+ />
+ <span class="ide-external-links-text">
+ {{ s__('Go back') }}
+ </span>
+ </a>
+ </p>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
new file mode 100644
index 00000000000..eb2749e6151
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
@@ -0,0 +1,47 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import repoTree from './ide_repo_tree.vue';
+ import newDropdown from './new_dropdown/index.vue';
+
+ export default {
+ components: {
+ repoTree,
+ icon,
+ newDropdown,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ branch: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="branch-container">
+ <div class="branch-header">
+ <div class="branch-header-title str-truncated ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ branch.name }}
+ </div>
+ <div class="branch-header-btns">
+ <new-dropdown
+ :project-id="projectId"
+ :branch="branch.name"
+ path=""
+ />
+ </div>
+ </div>
+ <repo-tree
+ :tree="branch.tree"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue
new file mode 100644
index 00000000000..a6f40286ac1
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_tree.vue
@@ -0,0 +1,65 @@
+<script>
+import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
+import Identicon from '../../vue_shared/components/identicon.vue';
+import BranchesTree from './ide_project_branches_tree.vue';
+import ExternalLinks from './ide_external_links.vue';
+
+export default {
+ components: {
+ BranchesTree,
+ ExternalLinks,
+ ProjectAvatarImage,
+ Identicon,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="projects-sidebar">
+ <div class="context-header">
+ <a
+ :title="project.name"
+ :href="project.web_url"
+ >
+ <div
+ v-if="project.avatar_url"
+ class="avatar-container s40 project-avatar"
+ >
+ <project-avatar-image
+ class="avatar-container project-avatar"
+ :link-href="project.path"
+ :img-src="project.avatar_url"
+ :img-alt="project.name"
+ :img-size="40"
+ />
+ </div>
+ <identicon
+ v-else
+ size-class="s40"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ />
+ <div class="sidebar-context-title">
+ {{ project.name }}
+ </div>
+ </a>
+ </div>
+ <external-links
+ :project-url="project.web_url"
+ />
+ <div class="multi-file-commit-panel-inner-scroll">
+ <branches-tree
+ v-for="branch in project.branches"
+ :key="branch.name"
+ :project-id="project.path_with_namespace"
+ :branch="branch"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue
new file mode 100644
index 00000000000..e6af88e04bc
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue
@@ -0,0 +1,41 @@
+<script>
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import RepoFile from './repo_file.vue';
+
+export default {
+ components: {
+ RepoFile,
+ SkeletonLoadingContainer,
+ },
+ props: {
+ tree: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-file-list"
+ >
+ <template v-if="tree.loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <template v-else>
+ <repo-file
+ v-for="file in tree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
new file mode 100644
index 00000000000..8cf1ccb4fce
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -0,0 +1,51 @@
+<script>
+ import { mapState, mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+ import projectTree from './ide_project_tree.vue';
+ import ResizablePanel from './resizable_panel.vue';
+
+ export default {
+ components: {
+ projectTree,
+ icon,
+ panelResizer,
+ skeletonLoadingContainer,
+ ResizablePanel,
+ },
+ computed: {
+ ...mapState([
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'projectsWithTrees',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="false"
+ :initial-width="290"
+ side="left"
+ >
+ <div class="multi-file-commit-panel-inner">
+ <template v-if="loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <project-tree
+ v-for="project in projectsWithTrees"
+ :key="project.id"
+ :project="project"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
new file mode 100644
index 00000000000..9c386896448
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -0,0 +1,60 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import timeAgoMixin from '~/vue_shared/mixins/timeago';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeAgoMixin,
+ ],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="ide-status-bar">
+ <div class="ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ file.branchId }}
+ </div>
+ <div>
+ <div v-if="file.lastCommit && file.lastCommit.id">
+ Last commit:
+ <a
+ v-tooltip
+ :title="file.lastCommit.message"
+ :href="file.lastCommit.url"
+ >
+ {{ timeFormated(file.lastCommit.updatedAt) }} by
+ {{ file.lastCommit.author }}
+ </a>
+ </div>
+ </div>
+ <div class="text-right">
+ {{ file.name }}
+ </div>
+ <div class="text-right">
+ {{ file.eol }}
+ </div>
+ <div class="text-right">
+ {{ file.editorRow }}:{{ file.editorColumn }}
+ </div>
+ <div class="text-right">
+ {{ file.fileLanguage }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
new file mode 100644
index 00000000000..769e9b79cad
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -0,0 +1,111 @@
+<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import newModal from './modal.vue';
+ import upload from './upload.vue';
+
+ export default {
+ components: {
+ icon,
+ newModal,
+ upload,
+ },
+ props: {
+ branch: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ openModal: false,
+ modalType: '',
+ dropdownOpen: false,
+ };
+ },
+ methods: {
+ ...mapActions([
+ 'createTempEntry',
+ ]),
+ createNewItem(type) {
+ this.modalType = type;
+ this.openModal = true;
+ this.dropdownOpen = false;
+ },
+ hideModal() {
+ this.openModal = false;
+ },
+ openDropdown() {
+ this.dropdownOpen = !this.dropdownOpen;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="ide-new-btn">
+ <div
+ class="dropdown"
+ :class="{
+ open: dropdownOpen,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-sm btn-default dropdown-toggle add-to-tree"
+ aria-label="Create new file or directory"
+ @click.stop="openDropdown()"
+ >
+ <icon
+ name="plus"
+ :size="12"
+ css-classes="pull-left"
+ />
+ <icon
+ name="arrow-down"
+ :size="12"
+ css-classes="pull-left"
+ />
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right">
+ <li>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="createNewItem('blob')"
+ >
+ {{ __('New file') }}
+ </a>
+ </li>
+ <li>
+ <upload
+ :branch-id="branch"
+ :path="path"
+ @create="createTempEntry"
+ />
+ </li>
+ <li>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="createNewItem('tree')"
+ >
+ {{ __('New directory') }}
+ </a>
+ </li>
+ </ul>
+ </div>
+ <new-modal
+ v-if="openModal"
+ :type="modalType"
+ :branch-id="branch"
+ :path="path"
+ @hide="hideModal"
+ @create="createTempEntry"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
new file mode 100644
index 00000000000..4b5a50785b6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -0,0 +1,99 @@
+<script>
+import { __ } from '~/locale';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+
+export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ branchId: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ entryName: this.path !== '' ? `${this.path}/` : '',
+ };
+ },
+ computed: {
+ modalTitle() {
+ if (this.type === 'tree') {
+ return __('Create new directory');
+ }
+
+ return __('Create new file');
+ },
+ buttonLabel() {
+ if (this.type === 'tree') {
+ return __('Create directory');
+ }
+
+ return __('Create file');
+ },
+ formLabelName() {
+ if (this.type === 'tree') {
+ return __('Directory name');
+ }
+
+ return __('File name');
+ },
+ },
+ mounted() {
+ this.$refs.fieldName.focus();
+ },
+ methods: {
+ createEntryInStore() {
+ this.$emit('create', {
+ branchId: this.branchId,
+ name: this.entryName,
+ type: this.type,
+ });
+
+ this.hideModal();
+ },
+ hideModal() {
+ this.$emit('hide');
+ },
+ },
+};
+</script>
+
+<template>
+ <deprecated-modal
+ :title="modalTitle"
+ :primary-button-label="buttonLabel"
+ kind="success"
+ @cancel="hideModal"
+ @submit="createEntryInStore"
+ >
+ <form
+ class="form-horizontal"
+ slot="body"
+ @submit.prevent="createEntryInStore"
+ >
+ <fieldset class="form-group append-bottom-0">
+ <label class="label-light col-sm-3">
+ {{ formLabelName }}
+ </label>
+ <div class="col-sm-9">
+ <input
+ type="text"
+ class="form-control"
+ v-model="entryName"
+ ref="fieldName"
+ />
+ </div>
+ </fieldset>
+ </form>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
new file mode 100644
index 00000000000..c165af5ce52
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -0,0 +1,75 @@
+<script>
+ export default {
+ props: {
+ branchId: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ mounted() {
+ this.$refs.fileUpload.addEventListener('change', this.openFile);
+ },
+ beforeDestroy() {
+ this.$refs.fileUpload.removeEventListener('change', this.openFile);
+ },
+ methods: {
+ createFile(target, file, isText) {
+ const { name } = file;
+ let { result } = target;
+
+ if (!isText) {
+ result = result.split('base64,')[1];
+ }
+
+ this.$emit('create', {
+ name: `${(this.path ? `${this.path}/` : '')}${name}`,
+ branchId: this.branchId,
+ type: 'blob',
+ content: result,
+ base64: !isText,
+ });
+ },
+ readFile(file) {
+ const reader = new FileReader();
+ const isText = file.type.match(/text.*/) !== null;
+
+ reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
+
+ if (isText) {
+ reader.readAsText(file);
+ } else {
+ reader.readAsDataURL(file);
+ }
+ },
+ openFile() {
+ Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
+ },
+ startFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="startFileUpload"
+ >
+ {{ __('Upload file') }}
+ </a>
+ <input
+ id="file-upload"
+ type="file"
+ class="hidden"
+ ref="fileUpload"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
new file mode 100644
index 00000000000..d885ed5e301
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -0,0 +1,172 @@
+<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import commitFilesList from './commit_sidebar/list.vue';
+import * as consts from '../stores/modules/commit/constants';
+import Actions from './commit_sidebar/actions.vue';
+
+export default {
+ components: {
+ DeprecatedModal,
+ icon,
+ commitFilesList,
+ Actions,
+ LoadingButton,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'currentProjectId',
+ 'currentBranchId',
+ 'rightPanelCollapsed',
+ 'lastCommitMsg',
+ 'changedFiles',
+ ]),
+ ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
+ ...mapGetters('commit', [
+ 'commitButtonDisabled',
+ 'discardDraftButtonDisabled',
+ 'branchName',
+ ]),
+ statusSvg() {
+ return this.lastCommitMsg
+ ? this.committedStateSvgPath
+ : this.noChangesStateSvgPath;
+ },
+ },
+ methods: {
+ ...mapActions(['setPanelCollapsedStatus']),
+ ...mapActions('commit', [
+ 'updateCommitMessage',
+ 'discardDraft',
+ 'commitChanges',
+ 'updateCommitAction',
+ ]),
+ toggleCollapsed() {
+ this.setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !this.rightPanelCollapsed,
+ });
+ },
+ forceCreateNewBranch() {
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() =>
+ this.commitChanges(),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel-section"
+ :class="{
+ 'multi-file-commit-empty-state-container': !changedFiles.length
+ }"
+ >
+ <deprecated-modal
+ id="ide-create-branch-modal"
+ :primary-button-label="__('Create new branch')"
+ kind="success"
+ :title="__('Branch has changed')"
+ @submit="forceCreateNewBranch"
+ >
+ <template slot="body">
+ {{ __(`This branch has changed since you started editing.
+ Would you like to create a new branch?`) }}
+ </template>
+ </deprecated-modal>
+ <commit-files-list
+ title="Staged"
+ :file-list="changedFiles"
+ :collapsed="rightPanelCollapsed"
+ @toggleCollapsed="toggleCollapsed"
+ />
+ <template
+ v-if="changedFiles.length"
+ >
+ <form
+ class="form-horizontal multi-file-commit-form"
+ @submit.prevent.stop="commitChanges"
+ v-if="!rightPanelCollapsed"
+ >
+ <div class="multi-file-commit-fieldset">
+ <textarea
+ class="form-control multi-file-commit-message"
+ name="commit-message"
+ :value="commitMessage"
+ :placeholder="__('Write a commit message...')"
+ @input="updateCommitMessage($event.target.value)"
+ >
+ </textarea>
+ </div>
+ <div class="clearfix prepend-top-15">
+ <actions />
+ <loading-button
+ :loading="submitCommitLoading"
+ :disabled="commitButtonDisabled"
+ container-class="btn btn-success btn-sm pull-left"
+ :label="__('Commit')"
+ @click="commitChanges"
+ />
+ <button
+ v-if="!discardDraftButtonDisabled"
+ type="button"
+ class="btn btn-default btn-sm pull-right"
+ @click="discardDraft"
+ >
+ {{ __('Discard draft') }}
+ </button>
+ </div>
+ </form>
+ </template>
+ <div
+ v-else-if="!rightPanelCollapsed"
+ class="row js-empty-state"
+ >
+ <div class="col-xs-10 col-xs-offset-1">
+ <div class="svg-content svg-80">
+ <img :src="statusSvg" />
+ </div>
+ </div>
+ <div class="col-xs-10 col-xs-offset-1">
+ <div
+ class="text-content text-center"
+ v-if="!lastCommitMsg"
+ >
+ <h4>
+ {{ __('No changes') }}
+ </h4>
+ <p>
+ {{ __('Edit files in the editor and commit changes here') }}
+ </p>
+ </div>
+ <div
+ class="text-content text-center"
+ v-else
+ >
+ <h4>
+ {{ __('All changes are committed') }}
+ </h4>
+ <p v-html="lastCommitMsg">
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
new file mode 100644
index 00000000000..e73d1ce839f
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -0,0 +1,161 @@
+<script>
+/* global monaco */
+import { mapState, mapActions } from 'vuex';
+import flash from '~/flash';
+import monacoLoader from '../monaco_loader';
+import Editor from '../lib/editor';
+
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'leftPanelCollapsed',
+ 'rightPanelCollapsed',
+ 'viewer',
+ 'delayViewerUpdated',
+ ]),
+ shouldHideEditor() {
+ return this.file && this.file.binary && !this.file.raw;
+ },
+ },
+ watch: {
+ file(oldVal, newVal) {
+ if (newVal.path !== this.file.path) {
+ this.initMonaco();
+ }
+ },
+ leftPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ rightPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ viewer() {
+ this.createEditorInstance();
+ },
+ },
+ beforeDestroy() {
+ this.editor.dispose();
+ },
+ mounted() {
+ if (this.editor && monaco) {
+ this.initMonaco();
+ } else {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ this.editor = Editor.create(monaco);
+
+ this.initMonaco();
+ });
+ }
+ },
+ methods: {
+ ...mapActions([
+ 'getRawFileData',
+ 'changeFileContent',
+ 'setFileLanguage',
+ 'setEditorPosition',
+ 'setFileEOL',
+ 'updateViewer',
+ 'updateDelayViewerUpdated',
+ ]),
+ initMonaco() {
+ if (this.shouldHideEditor) return;
+
+ this.editor.clearEditor();
+
+ this.getRawFileData(this.file)
+ .then(() => {
+ const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve();
+
+ return viewerPromise;
+ })
+ .then(() => {
+ this.updateDelayViewerUpdated(false);
+ this.createEditorInstance();
+ })
+ .catch((err) => {
+ flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true);
+ throw err;
+ });
+ },
+ createEditorInstance() {
+ this.editor.dispose();
+
+ this.$nextTick(() => {
+ if (this.viewer === 'editor') {
+ this.editor.createInstance(this.$refs.editor);
+ } else {
+ this.editor.createDiffInstance(this.$refs.editor);
+ }
+
+ this.setupEditor();
+ });
+ },
+ setupEditor() {
+ if (!this.file || !this.editor.instance) return;
+
+ this.model = this.editor.createModel(this.file);
+
+ this.editor.attachModel(this.model);
+
+ this.model.onChange((model) => {
+ const { file } = model;
+
+ if (file.active) {
+ this.changeFileContent({
+ path: file.path,
+ content: model.getModel().getValue(),
+ });
+ }
+ });
+
+ // Handle Cursor Position
+ this.editor.onPositionChange((instance, e) => {
+ this.setEditorPosition({
+ editorRow: e.position.lineNumber,
+ editorColumn: e.position.column,
+ });
+ });
+
+ this.editor.setPosition({
+ lineNumber: this.file.editorRow,
+ column: this.file.editorColumn,
+ });
+
+ // Handle File Language
+ this.setFileLanguage({
+ fileLanguage: this.model.language,
+ });
+
+ // Get File eol
+ this.setFileEOL({
+ eol: this.model.eol,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ id="ide"
+ class="blob-viewer-container blob-editor-container"
+ >
+ <div
+ v-if="shouldHideEditor"
+ v-html="file.html"
+ >
+ </div>
+ <div
+ v-show="!shouldHideEditor"
+ ref="editor"
+ class="multi-file-editor-holder"
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue
new file mode 100644
index 00000000000..297b9c2628f
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file.vue
@@ -0,0 +1,128 @@
+<script>
+import { mapActions } from 'vuex';
+import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import fileIcon from '~/vue_shared/components/file_icon.vue';
+import router from '../ide_router';
+import newDropdown from './new_dropdown/index.vue';
+import fileStatusIcon from './repo_file_status_icon.vue';
+import changedFileIcon from './changed_file_icon.vue';
+
+export default {
+ name: 'RepoFile',
+ components: {
+ skeletonLoadingContainer,
+ newDropdown,
+ fileStatusIcon,
+ fileIcon,
+ changedFileIcon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ level: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ isTree() {
+ return this.file.type === 'tree';
+ },
+ isBlob() {
+ return this.file.type === 'blob';
+ },
+ levelIndentation() {
+ return {
+ marginLeft: `${this.level * 16}px`,
+ };
+ },
+ fileClass() {
+ return {
+ 'file-open': this.isBlob && this.file.opened,
+ 'file-active': this.isBlob && this.file.active,
+ folder: this.isTree,
+ 'is-open': this.file.opened,
+ };
+ },
+ },
+ updated() {
+ if (this.file.type === 'blob' && this.file.active) {
+ this.$el.scrollIntoView();
+ }
+ },
+ methods: {
+ ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']),
+ clickFile() {
+ // Manual Action if a tree is selected/opened
+ if (
+ this.isTree &&
+ this.$router.currentRoute.path === `/project${this.file.url}`
+ ) {
+ this.toggleTreeOpen(this.file.path);
+ }
+
+ const delayPromise = this.file.changed
+ ? Promise.resolve()
+ : this.updateDelayViewerUpdated(true);
+
+ return delayPromise.then(() => {
+ router.push(`/project${this.file.url}`);
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="file"
+ :class="fileClass"
+ >
+ <div
+ class="file-name"
+ @click="clickFile"
+ role="button"
+ >
+ <span
+ class="ide-file-name str-truncated"
+ :style="levelIndentation"
+ >
+ <file-icon
+ :file-name="file.name"
+ :loading="file.loading"
+ :folder="isTree"
+ :opened="file.opened"
+ :size="16"
+ />
+ {{ file.name }}
+ <file-status-icon
+ :file="file"
+ />
+ </span>
+ <changed-file-icon
+ :file="file"
+ v-if="file.changed || file.tempFile"
+ class="prepend-top-5 pull-right"
+ />
+ <new-dropdown
+ v-if="isTree"
+ :project-id="file.projectId"
+ :branch="file.branchId"
+ :path="file.path"
+ class="pull-right prepend-left-8"
+ />
+ </div>
+ </div>
+ <template v-if="file.opened">
+ <repo-file
+ v-for="childFile in file.tree"
+ :key="childFile.key"
+ :file="childFile"
+ :level="level + 1"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue
new file mode 100644
index 00000000000..4ea8cf7504b
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue
@@ -0,0 +1,61 @@
+<script>
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showButtons() {
+ return this.file.rawPath ||
+ this.file.blamePath ||
+ this.file.commitsPath ||
+ this.file.permalink;
+ },
+ rawDownloadButtonLabel() {
+ return this.file.binary ? 'Download' : 'Raw';
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="showButtons"
+ class="multi-file-editor-btn-group"
+ >
+ <a
+ :href="file.rawPath"
+ target="_blank"
+ class="btn btn-default btn-sm raw"
+ rel="noopener noreferrer">
+ {{ rawDownloadButtonLabel }}
+ </a>
+
+ <div
+ class="btn-group"
+ role="group"
+ aria-label="File actions"
+ >
+ <a
+ :href="file.blamePath"
+ class="btn btn-default btn-sm blame"
+ >
+ Blame
+ </a>
+ <a
+ :href="file.commitsPath"
+ class="btn btn-default btn-sm history"
+ >
+ History
+ </a>
+ <a
+ :href="file.permalink"
+ class="btn btn-default btn-sm permalink"
+ >
+ Permalink
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
new file mode 100644
index 00000000000..25d311142d5
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -0,0 +1,39 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import '~/lib/utils/datetime_utility';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lockTooltip() {
+ return `Locked by ${this.file.file_lock.user.name}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <span
+ v-if="file.file_lock"
+ v-tooltip
+ :title="lockTooltip"
+ data-container="body"
+ >
+ <icon
+ name="lock"
+ css-classes="file-status-icon"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue
new file mode 100644
index 00000000000..79af8c0b0c7
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_loading_file.vue
@@ -0,0 +1,42 @@
+<script>
+ import { mapState } from 'vuex';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+ export default {
+ components: {
+ skeletonLoadingContainer,
+ },
+ computed: {
+ ...mapState([
+ 'leftPanelCollapsed',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <tr
+ class="loading-file"
+ aria-label="Loading files"
+ >
+ <td class="multi-file-table-col-name">
+ <skeleton-loading-container
+ :small="true"
+ />
+ </td>
+ <template v-if="!leftPanelCollapsed">
+ <td class="hidden-sm hidden-xs">
+ <skeleton-loading-container
+ :small="true"
+ />
+ </td>
+
+ <td class="hidden-xs">
+ <skeleton-loading-container
+ class="animation-container-right"
+ :small="true"
+ />
+ </td>
+ </template>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
new file mode 100644
index 00000000000..c337bc813e6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -0,0 +1,98 @@
+<script>
+ import { mapActions } from 'vuex';
+
+ import fileIcon from '~/vue_shared/components/file_icon.vue';
+ import icon from '~/vue_shared/components/icon.vue';
+ import fileStatusIcon from './repo_file_status_icon.vue';
+ import changedFileIcon from './changed_file_icon.vue';
+
+ export default {
+ components: {
+ fileStatusIcon,
+ fileIcon,
+ icon,
+ changedFileIcon,
+ },
+ props: {
+ tab: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tabMouseOver: false,
+ };
+ },
+ computed: {
+ closeLabel() {
+ if (this.tab.changed || this.tab.tempFile) {
+ return `${this.tab.name} changed`;
+ }
+ return `Close ${this.tab.name}`;
+ },
+ showChangedIcon() {
+ return this.tab.changed ? !this.tabMouseOver : false;
+ },
+ },
+
+ methods: {
+ ...mapActions([
+ 'closeFile',
+ ]),
+ clickFile(tab) {
+ this.$router.push(`/project${tab.url}`);
+ },
+ mouseOverTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = true;
+ }
+ },
+ mouseOutTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = false;
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <li
+ @click="clickFile(tab)"
+ @mouseover="mouseOverTab"
+ @mouseout="mouseOutTab"
+ >
+ <button
+ type="button"
+ class="multi-file-tab-close"
+ @click.stop.prevent="closeFile(tab.path)"
+ :aria-label="closeLabel"
+ >
+ <icon
+ v-if="!showChangedIcon"
+ name="close"
+ :size="12"
+ />
+ <changed-file-icon
+ v-else
+ :file="tab"
+ />
+ </button>
+
+ <div
+ class="multi-file-tab"
+ :class="{active : tab.active }"
+ :title="tab.url"
+ >
+ <file-icon
+ :file-name="tab.name"
+ :size="16"
+ />
+ {{ tab.name }}
+ <file-status-icon
+ :file="tab"
+ />
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
new file mode 100644
index 00000000000..8ea64ddf84a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -0,0 +1,61 @@
+<script>
+ import { mapActions } from 'vuex';
+ import RepoTab from './repo_tab.vue';
+ import EditorMode from './editor_mode_dropdown.vue';
+
+ export default {
+ components: {
+ RepoTab,
+ EditorMode,
+ },
+ props: {
+ files: {
+ type: Array,
+ required: true,
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ hasChanges: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showShadow: false,
+ };
+ },
+ updated() {
+ if (!this.$refs.tabsScroller) return;
+
+ this.showShadow =
+ this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth;
+ },
+ methods: {
+ ...mapActions(['updateViewer']),
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-tabs">
+ <ul
+ class="list-unstyled append-bottom-0"
+ ref="tabsScroller"
+ >
+ <repo-tab
+ v-for="tab in files"
+ :key="tab.key"
+ :tab="tab"
+ />
+ </ul>
+ <editor-mode
+ :viewer="viewer"
+ :show-shadow="showShadow"
+ :has-changes="hasChanges"
+ @click="updateViewer"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
new file mode 100644
index 00000000000..faa690ecba0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -0,0 +1,88 @@
+<script>
+ import { mapActions, mapState } from 'vuex';
+ import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+
+ export default {
+ components: {
+ PanelResizer,
+ },
+ props: {
+ collapsible: {
+ type: Boolean,
+ required: true,
+ },
+ initialWidth: {
+ type: Number,
+ required: true,
+ },
+ minSize: {
+ type: Number,
+ required: false,
+ default: 200,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ width: this.initialWidth,
+ };
+ },
+ computed: {
+ ...mapState({
+ collapsed(state) {
+ return state[`${this.side}PanelCollapsed`];
+ },
+ }),
+ panelStyle() {
+ if (!this.collapsed) {
+ return {
+ width: `${this.width}px`,
+ };
+ }
+
+ return {};
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setPanelCollapsedStatus',
+ 'setResizingStatus',
+ ]),
+ toggleFullbarCollapsed() {
+ if (this.collapsed && this.collapsible) {
+ this.setPanelCollapsedStatus({
+ side: this.side,
+ collapsed: !this.collapsed,
+ });
+ }
+ },
+ },
+ maxSize: (window.innerWidth / 2),
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel"
+ :class="{
+ 'is-collapsed': collapsed && collapsible,
+ }"
+ :style="panelStyle"
+ @click="toggleFullbarCollapsed"
+ >
+ <slot></slot>
+ <panel-resizer
+ :size.sync="width"
+ :enabled="!collapsed"
+ :start-size="initialWidth"
+ :min-size="minSize"
+ :max-size="$options.maxSize"
+ @resize-start="setResizingStatus(true)"
+ @resize-end="setResizingStatus(false)"
+ :side="side === 'right' ? 'left' : 'right'"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/ide/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
new file mode 100644
index 00000000000..db89c1d44db
--- /dev/null
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -0,0 +1,117 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import flash from '~/flash';
+import store from './stores';
+
+Vue.use(VueRouter);
+
+/**
+ * Routes below /-/ide/:
+
+/project/h5bp/html5-boilerplate/blob/master
+/project/h5bp/html5-boilerplate/blob/master/app/js/test.js
+
+/project/h5bp/html5-boilerplate/mr/123
+/project/h5bp/html5-boilerplate/mr/123/app/js/test.js
+
+/workspace/123
+/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch
+/workspace/project/h5bp/html5-boilerplate/mr/123
+
+/ = /workspace
+
+/settings
+*/
+
+// Unfortunately Vue Router doesn't work without at least a fake component
+// If you do only data handling
+const EmptyRouterComponent = {
+ render(createElement) {
+ return createElement('div');
+ },
+};
+
+const router = new VueRouter({
+ mode: 'history',
+ base: `${gon.relative_url_root}/-/ide/`,
+ routes: [
+ {
+ path: '/project/:namespace/:project',
+ component: EmptyRouterComponent,
+ children: [
+ {
+ path: ':targetmode/:branch/*',
+ component: EmptyRouterComponent,
+ },
+ {
+ path: 'mr/:mrid',
+ component: EmptyRouterComponent,
+ },
+ ],
+ },
+ ],
+});
+
+router.beforeEach((to, from, next) => {
+ if (to.params.namespace && to.params.project) {
+ store
+ .dispatch('getProjectData', {
+ namespace: to.params.namespace,
+ projectId: to.params.project,
+ })
+ .then(() => {
+ const fullProjectId = `${to.params.namespace}/${to.params.project}`;
+
+ if (to.params.branch) {
+ store.dispatch('getBranchData', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ });
+
+ store
+ .dispatch('getFiles', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ })
+ .then(() => {
+ if (to.params[0]) {
+ const path =
+ to.params[0].slice(-1) === '/'
+ ? to.params[0].slice(0, -1)
+ : to.params[0];
+ const treeEntry = store.state.entries[path];
+ if (treeEntry) {
+ store.dispatch('handleTreeEntryAction', treeEntry);
+ }
+ }
+ })
+ .catch(e => {
+ flash(
+ 'Error while loading the branch files. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
+ }
+ })
+ .catch(e => {
+ flash(
+ 'Error while loading the project data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
+ }
+
+ next();
+});
+
+export default router;
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
new file mode 100644
index 00000000000..cbfb3dc54f2
--- /dev/null
+++ b/app/assets/javascripts/ide/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import ide from './components/ide.vue';
+import store from './stores';
+import router from './ide_router';
+
+function initIde(el) {
+ if (!el) return null;
+
+ return new Vue({
+ el,
+ store,
+ router,
+ components: {
+ ide,
+ },
+ render(createElement) {
+ return createElement('ide', {
+ props: {
+ emptyStateSvgPath: el.dataset.emptyStateSvgPath,
+ noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
+ committedStateSvgPath: el.dataset.committedStateSvgPath,
+ },
+ });
+ },
+ });
+}
+
+const ideElement = document.getElementById('ide');
+
+Vue.use(Translate);
+
+initIde(ideElement);
diff --git a/app/assets/javascripts/ide/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js
new file mode 100644
index 00000000000..84b29bdb600
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/disposable.js
@@ -0,0 +1,14 @@
+export default class Disposable {
+ constructor() {
+ this.disposers = new Set();
+ }
+
+ add(...disposers) {
+ disposers.forEach(disposer => this.disposers.add(disposer));
+ }
+
+ dispose() {
+ this.disposers.forEach(disposer => disposer.dispose());
+ this.disposers.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
new file mode 100644
index 00000000000..73cd684351c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -0,0 +1,90 @@
+/* global monaco */
+import Disposable from './disposable';
+import eventHub from '../../eventhub';
+
+export default class Model {
+ constructor(monaco, file) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.file = file;
+ this.content = file.content !== '' ? file.content : file.raw;
+
+ this.disposable.add(
+ (this.originalModel = this.monaco.editor.createModel(
+ this.file.raw,
+ undefined,
+ new this.monaco.Uri(null, null, `original/${this.file.path}`),
+ )),
+ (this.model = this.monaco.editor.createModel(
+ this.content,
+ undefined,
+ new this.monaco.Uri(null, null, this.file.path),
+ )),
+ );
+
+ this.events = new Map();
+
+ this.updateContent = this.updateContent.bind(this);
+ this.dispose = this.dispose.bind(this);
+
+ eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose);
+ eventHub.$on(
+ `editor.update.model.content.${this.file.path}`,
+ this.updateContent,
+ );
+ }
+
+ get url() {
+ return this.model.uri.toString();
+ }
+
+ get language() {
+ return this.model.getModeId();
+ }
+
+ get eol() {
+ return this.model.getEOL() === '\n' ? 'LF' : 'CRLF';
+ }
+
+ get path() {
+ return this.file.path;
+ }
+
+ getModel() {
+ return this.model;
+ }
+
+ getOriginalModel() {
+ return this.originalModel;
+ }
+
+ setValue(value) {
+ this.getModel().setValue(value);
+ }
+
+ onChange(cb) {
+ this.events.set(
+ this.path,
+ this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
+ );
+ }
+
+ updateContent(content) {
+ this.getOriginalModel().setValue(content);
+ this.getModel().setValue(content);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+ this.events.clear();
+
+ eventHub.$off(
+ `editor.update.model.dispose.${this.file.path}`,
+ this.dispose,
+ );
+ eventHub.$off(
+ `editor.update.model.content.${this.file.path}`,
+ this.updateContent,
+ );
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js
new file mode 100644
index 00000000000..57d5e59a88b
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/model_manager.js
@@ -0,0 +1,51 @@
+import eventHub from '../../eventhub';
+import Disposable from './disposable';
+import Model from './model';
+
+export default class ModelManager {
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.models = new Map();
+ }
+
+ hasCachedModel(path) {
+ return this.models.has(path);
+ }
+
+ getModel(path) {
+ return this.models.get(path);
+ }
+
+ addModel(file) {
+ if (this.hasCachedModel(file.path)) {
+ return this.getModel(file.path);
+ }
+
+ const model = new Model(this.monaco, file);
+ this.models.set(model.path, model);
+ this.disposable.add(model);
+
+ eventHub.$on(
+ `editor.update.model.dispose.${file.path}`,
+ this.removeCachedModel.bind(this, file),
+ );
+
+ return model;
+ }
+
+ removeCachedModel(file) {
+ this.models.delete(file.path);
+
+ eventHub.$off(
+ `editor.update.model.dispose.${file.path}`,
+ this.removeCachedModel,
+ );
+ }
+
+ dispose() {
+ // dispose of all the models
+ this.disposable.dispose();
+ this.models.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js
new file mode 100644
index 00000000000..42904774747
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/decorations/controller.js
@@ -0,0 +1,45 @@
+export default class DecorationsController {
+ constructor(editor) {
+ this.editor = editor;
+ this.decorations = new Map();
+ this.editorDecorations = new Map();
+ }
+
+ getAllDecorationsForModel(model) {
+ if (!this.decorations.has(model.url)) return [];
+
+ const modelDecorations = this.decorations.get(model.url);
+ const decorations = [];
+
+ modelDecorations.forEach(val => decorations.push(...val));
+
+ return decorations;
+ }
+
+ addDecorations(model, decorationsKey, decorations) {
+ const decorationMap = this.decorations.get(model.url) || new Map();
+
+ decorationMap.set(decorationsKey, decorations);
+
+ this.decorations.set(model.url, decorationMap);
+
+ this.decorate(model);
+ }
+
+ decorate(model) {
+ if (!this.editor.instance) return;
+
+ const decorations = this.getAllDecorationsForModel(model);
+ const oldDecorations = this.editorDecorations.get(model.url) || [];
+
+ this.editorDecorations.set(
+ model.url,
+ this.editor.instance.deltaDecorations(oldDecorations, decorations),
+ );
+ }
+
+ dispose() {
+ this.decorations.clear();
+ this.editorDecorations.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
new file mode 100644
index 00000000000..b136545ad11
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -0,0 +1,72 @@
+/* global monaco */
+import { throttle } from 'underscore';
+import DirtyDiffWorker from './diff_worker';
+import Disposable from '../common/disposable';
+
+export const getDiffChangeType = (change) => {
+ if (change.modified) {
+ return 'modified';
+ } else if (change.added) {
+ return 'added';
+ } else if (change.removed) {
+ return 'removed';
+ }
+
+ return '';
+};
+
+export const getDecorator = change => ({
+ range: new monaco.Range(
+ change.lineNumber,
+ 1,
+ change.endLineNumber,
+ 1,
+ ),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
+ },
+});
+
+export default class DirtyDiffController {
+ constructor(modelManager, decorationsController) {
+ this.disposable = new Disposable();
+ this.editorSimpleWorker = null;
+ this.modelManager = modelManager;
+ this.decorationsController = decorationsController;
+ this.dirtyDiffWorker = new DirtyDiffWorker();
+ this.throttledComputeDiff = throttle(this.computeDiff, 250);
+ this.decorate = this.decorate.bind(this);
+
+ this.dirtyDiffWorker.addEventListener('message', this.decorate);
+ }
+
+ attachModel(model) {
+ model.onChange(() => this.throttledComputeDiff(model));
+ }
+
+ computeDiff(model) {
+ this.dirtyDiffWorker.postMessage({
+ path: model.path,
+ originalContent: model.getOriginalModel().getValue(),
+ newContent: model.getModel().getValue(),
+ });
+ }
+
+ reDecorate(model) {
+ this.decorationsController.decorate(model);
+ }
+
+ decorate({ data }) {
+ const decorations = data.changes.map(change => getDecorator(change));
+ const model = this.modelManager.getModel(data.path);
+ this.decorationsController.addDecorations(model, 'dirtyDiff', decorations);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+
+ this.dirtyDiffWorker.removeEventListener('message', this.decorate);
+ this.dirtyDiffWorker.terminate();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
new file mode 100644
index 00000000000..0e37f5c4704
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -0,0 +1,30 @@
+import { diffLines } from 'diff';
+
+// eslint-disable-next-line import/prefer-default-export
+export const computeDiff = (originalContent, newContent) => {
+ const changes = diffLines(originalContent, newContent);
+
+ let lineNumber = 1;
+ return changes.reduce((acc, change) => {
+ const findOnLine = acc.find(c => c.lineNumber === lineNumber);
+
+ if (findOnLine) {
+ Object.assign(findOnLine, change, {
+ modified: true,
+ endLineNumber: (lineNumber + change.count) - 1,
+ });
+ } else if ('added' in change || 'removed' in change) {
+ acc.push(Object.assign({}, change, {
+ lineNumber,
+ modified: undefined,
+ endLineNumber: (lineNumber + change.count) - 1,
+ }));
+ }
+
+ if (!change.removed) {
+ lineNumber += change.count;
+ }
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/ide/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js
new file mode 100644
index 00000000000..e74c4046330
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js
@@ -0,0 +1,10 @@
+import { computeDiff } from './diff';
+
+self.addEventListener('message', (e) => {
+ const data = e.data;
+
+ self.postMessage({
+ path: data.path,
+ changes: computeDiff(data.originalContent, data.newContent),
+ });
+});
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
new file mode 100644
index 00000000000..887dd7e39b1
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -0,0 +1,168 @@
+import _ from 'underscore';
+import DecorationsController from './decorations/controller';
+import DirtyDiffController from './diff/controller';
+import Disposable from './common/disposable';
+import ModelManager from './common/model_manager';
+import editorOptions, { defaultEditorOptions } from './editor_options';
+import gitlabTheme from './themes/gl_theme';
+
+export const clearDomElement = el => {
+ if (!el || !el.firstChild) return;
+
+ while (el.firstChild) {
+ el.removeChild(el.firstChild);
+ }
+};
+
+export default class Editor {
+ static create(monaco) {
+ if (this.editorInstance) return this.editorInstance;
+
+ this.editorInstance = new Editor(monaco);
+
+ return this.editorInstance;
+ }
+
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.currentModel = null;
+ this.instance = null;
+ this.dirtyDiffController = null;
+ this.disposable = new Disposable();
+ this.modelManager = new ModelManager(this.monaco);
+ this.decorationsController = new DecorationsController(this);
+
+ this.setupMonacoTheme();
+
+ this.debouncedUpdate = _.debounce(() => {
+ this.updateDimensions();
+ }, 200);
+ }
+
+ createInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.create(domElement, {
+ ...defaultEditorOptions,
+ })),
+ (this.dirtyDiffController = new DirtyDiffController(
+ this.modelManager,
+ this.decorationsController,
+ )),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createDiffInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.createDiffEditor(domElement, {
+ ...defaultEditorOptions,
+ readOnly: true,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ renderLineHighlight: 'none',
+ hideCursorInOverviewRuler: true,
+ })),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createModel(file) {
+ return this.modelManager.addModel(file);
+ }
+
+ attachModel(model) {
+ if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') {
+ this.instance.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+
+ return;
+ }
+
+ this.instance.setModel(model.getModel());
+ if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model);
+
+ this.currentModel = model;
+
+ this.instance.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach(key => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+
+ if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model);
+ }
+
+ setupMonacoTheme() {
+ this.monaco.editor.defineTheme(
+ gitlabTheme.themeName,
+ gitlabTheme.monacoTheme,
+ );
+
+ this.monaco.editor.setTheme('gitlab');
+ }
+
+ clearEditor() {
+ if (this.instance) {
+ this.instance.setModel(null);
+ }
+ }
+
+ dispose() {
+ window.removeEventListener('resize', this.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ this.disposable.dispose();
+
+ this.instance = null;
+ } catch (e) {
+ this.instance = null;
+
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }
+ }
+
+ updateDimensions() {
+ this.instance.layout();
+ }
+
+ setPosition({ lineNumber, column }) {
+ this.instance.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ this.instance.setPosition({
+ lineNumber,
+ column,
+ });
+ }
+
+ onPositionChange(cb) {
+ if (!this.instance.onDidChangeCursorPosition) return;
+
+ this.disposable.add(
+ this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)),
+ );
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
new file mode 100644
index 00000000000..d69d4b8c615
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -0,0 +1,15 @@
+export const defaultEditorOptions = {
+ model: null,
+ readOnly: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
+ minimap: {
+ enabled: false,
+ },
+};
+
+export default [
+ {
+ readOnly: model => !!model.file.file_lock,
+ },
+];
diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js
new file mode 100644
index 00000000000..2fc96250c7d
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js
@@ -0,0 +1,14 @@
+export default {
+ themeName: 'gitlab',
+ monacoTheme: {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editorLineNumber.foreground': '#CCCCCC',
+ 'diffEditor.insertedTextBackground': '#ddfbe6',
+ 'diffEditor.removedTextBackground': '#f9d7dc',
+ 'editor.selectionBackground': '#aad6f8',
+ },
+ },
+};
diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js
new file mode 100644
index 00000000000..142a220097b
--- /dev/null
+++ b/app/assets/javascripts/ide/monaco_loader.js
@@ -0,0 +1,16 @@
+import monacoContext from 'monaco-editor/dev/vs/loader';
+
+monacoContext.require.config({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
+ },
+});
+
+// ignore CDN config and use local assets path for service worker which cannot be cross-domain
+const relativeRootPath = (gon && gon.relative_url_root) || '';
+const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`;
+window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` };
+
+// eslint-disable-next-line no-underscore-dangle
+window.__monaco_context__ = monacoContext;
+export default monacoContext.require;
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
new file mode 100644
index 00000000000..5f1fb6cf843
--- /dev/null
+++ b/app/assets/javascripts/ide/services/index.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import Api from '~/api';
+
+Vue.use(VueResource);
+
+export default {
+ getTreeData(endpoint) {
+ return Vue.http.get(endpoint, { params: { format: 'json' } });
+ },
+ getFileData(endpoint) {
+ return Vue.http.get(endpoint, { params: { format: 'json' } });
+ },
+ getRawFileData(file) {
+ if (file.tempFile) {
+ return Promise.resolve(file.content);
+ }
+
+ if (file.raw) {
+ return Promise.resolve(file.raw);
+ }
+
+ return Vue.http.get(file.rawPath, { params: { format: 'json' } })
+ .then(res => res.text());
+ },
+ getProjectData(namespace, project) {
+ return Api.project(`${namespace}/${project}`);
+ },
+ getBranchData(projectId, currentBranchId) {
+ return Api.branchSingle(projectId, currentBranchId);
+ },
+ createBranch(projectId, payload) {
+ const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId);
+
+ return Vue.http.post(url, payload);
+ },
+ commit(projectId, payload) {
+ return Api.commitMultiple(projectId, payload);
+ },
+ getTreeLastCommit(endpoint) {
+ return Vue.http.get(endpoint, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
+ getFiles(projectUrl, branchId) {
+ const url = `${projectUrl}/files/${branchId}`;
+ return Vue.http.get(url, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
new file mode 100644
index 00000000000..7e920aa9f30
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -0,0 +1,121 @@
+import Vue from 'vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import flash from '~/flash';
+import * as types from './mutation_types';
+import FilesDecoratorWorker from './workers/files_decorator_worker';
+
+export const redirectToUrl = (_, url) => visitUrl(url);
+
+export const setInitialData = ({ commit }, data) =>
+ commit(types.SET_INITIAL_DATA, data);
+
+export const discardAllChanges = ({ state, commit, dispatch }) => {
+ state.changedFiles.forEach(file => {
+ commit(types.DISCARD_FILE_CHANGES, file.path);
+
+ if (file.tempFile) {
+ dispatch('closeFile', file.path);
+ }
+ });
+
+ commit(types.REMOVE_ALL_CHANGES_FILES);
+};
+
+export const closeAllFiles = ({ state, dispatch }) => {
+ state.openFiles.forEach(file => dispatch('closeFile', file.path));
+};
+
+export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
+ if (side === 'left') {
+ commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed);
+ } else {
+ commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed);
+ }
+};
+
+export const setResizingStatus = ({ commit }, resizing) => {
+ commit(types.SET_RESIZING_STATUS, resizing);
+};
+
+export const createTempEntry = (
+ { state, commit, dispatch },
+ { branchId, name, type, content = '', base64 = false },
+) =>
+ new Promise(resolve => {
+ const worker = new FilesDecoratorWorker();
+ const fullName =
+ name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
+
+ if (state.entries[name]) {
+ flash(
+ `The name "${name
+ .split('/')
+ .pop()}" is already taken in this directory.`,
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+
+ resolve();
+
+ return null;
+ }
+
+ worker.addEventListener('message', ({ data }) => {
+ const { file } = data;
+
+ worker.terminate();
+
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId,
+ });
+
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ commit(types.ADD_FILE_TO_CHANGED, file.path);
+ dispatch('setFileActive', file.path);
+ }
+
+ resolve(file);
+ });
+
+ worker.postMessage({
+ data: [fullName],
+ projectId: state.currentProjectId,
+ branchId,
+ type,
+ tempFile: true,
+ base64,
+ content,
+ });
+
+ return null;
+ });
+
+export const scrollToTab = () => {
+ Vue.nextTick(() => {
+ const tabs = document.getElementById('tabs');
+
+ if (tabs) {
+ const tabEl = tabs.querySelector('.active .repo-tab');
+
+ tabEl.focus();
+ }
+ });
+};
+
+export const updateViewer = ({ commit }, viewer) => {
+ commit(types.UPDATE_VIEWER, viewer);
+};
+
+export const updateDelayViewerUpdated = ({ commit }, delay) => {
+ commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
+};
+
+export * from './actions/tree';
+export * from './actions/file';
+export * from './actions/project';
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
new file mode 100644
index 00000000000..ddc4b757bf9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -0,0 +1,146 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import eventHub from '../../eventhub';
+import service from '../../services';
+import * as types from '../mutation_types';
+import router from '../../ide_router';
+import { setPageTitle } from '../utils';
+
+export const closeFile = ({ commit, state, getters, dispatch }, path) => {
+ const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path);
+ const file = state.entries[path];
+ const fileWasActive = file.active;
+
+ commit(types.TOGGLE_FILE_OPEN, path);
+ commit(types.SET_FILE_ACTIVE, { path, active: false });
+
+ if (state.openFiles.length > 0 && fileWasActive) {
+ const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1;
+ const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path];
+
+ router.push(`/project${nextFileToOpen.url}`);
+ } else if (!state.openFiles.length) {
+ router.push(`/project/${file.projectId}/tree/${file.branchId}/`);
+ }
+
+ eventHub.$emit(`editor.update.model.dispose.${file.path}`);
+};
+
+export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
+ const file = state.entries[path];
+ const currentActiveFile = getters.activeFile;
+
+ if (file.active) return;
+
+ if (currentActiveFile) {
+ commit(types.SET_FILE_ACTIVE, {
+ path: currentActiveFile.path,
+ active: false,
+ });
+ }
+
+ commit(types.SET_FILE_ACTIVE, { path, active: true });
+ dispatch('scrollToTab');
+
+ commit(types.SET_CURRENT_PROJECT, file.projectId);
+ commit(types.SET_CURRENT_BRANCH, file.branchId);
+};
+
+export const getFileData = ({ state, commit, dispatch }, file) => {
+ commit(types.TOGGLE_LOADING, { entry: file });
+
+ return service
+ .getFileData(file.url)
+ .then(res => {
+ const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
+
+ setPageTitle(pageTitle);
+
+ return res.json();
+ })
+ .then(data => {
+ commit(types.SET_FILE_DATA, { data, file });
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ dispatch('setFileActive', file.path);
+ commit(types.TOGGLE_LOADING, { entry: file });
+ })
+ .catch(() => {
+ commit(types.TOGGLE_LOADING, { entry: file });
+ flash(
+ 'Error loading file data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ });
+};
+
+export const getRawFileData = ({ commit, dispatch }, file) =>
+ service
+ .getRawFileData(file)
+ .then(raw => {
+ commit(types.SET_FILE_RAW_DATA, { file, raw });
+ })
+ .catch(() =>
+ flash(
+ 'Error loading file content. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const changeFileContent = ({ state, commit }, { path, content }) => {
+ const file = state.entries[path];
+ commit(types.UPDATE_FILE_CONTENT, { path, content });
+
+ const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
+
+ if (file.changed && indexOfChangedFile === -1) {
+ commit(types.ADD_FILE_TO_CHANGED, path);
+ } else if (!file.changed && indexOfChangedFile !== -1) {
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+ }
+};
+
+export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage });
+ }
+};
+
+export const setFileEOL = ({ getters, commit }, { eol }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_EOL, { file: getters.activeFile, eol });
+ }
+};
+
+export const setEditorPosition = (
+ { getters, commit },
+ { editorRow, editorColumn },
+) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_POSITION, {
+ file: getters.activeFile,
+ editorRow,
+ editorColumn,
+ });
+ }
+};
+
+export const discardFileChanges = ({ state, commit }, path) => {
+ const file = state.entries[path];
+
+ commit(types.DISCARD_FILE_CHANGES, path);
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+
+ if (file.tempFile && file.opened) {
+ commit(types.TOGGLE_FILE_OPEN, path);
+ }
+
+ eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw);
+};
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
new file mode 100644
index 00000000000..b3882cb8d21
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -0,0 +1,49 @@
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+
+export const getProjectData = (
+ { commit, state, dispatch },
+ { namespace, projectId, force = false } = {},
+) => new Promise((resolve, reject) => {
+ if (!state.projects[`${namespace}/${projectId}`] || force) {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ service.getProjectData(namespace, projectId)
+ .then(res => res.data)
+ .then((data) => {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
+ if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading project data. Please try again.', 'alert', document, null, false, true);
+ reject(new Error(`Project not loaded ${namespace}/${projectId}`));
+ });
+ } else {
+ resolve(state.projects[`${namespace}/${projectId}`]);
+ }
+});
+
+export const getBranchData = (
+ { commit, state, dispatch },
+ { projectId, branchId, force = false } = {},
+) => new Promise((resolve, reject) => {
+ if ((typeof state.projects[`${projectId}`] === 'undefined' ||
+ !state.projects[`${projectId}`].branches[branchId])
+ || force) {
+ service.getBranchData(`${projectId}`, branchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data });
+ commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading branch data. Please try again.', 'alert', document, null, false, true);
+ reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
+ });
+ } else {
+ resolve(state.projects[`${projectId}`].branches[branchId]);
+ }
+});
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
new file mode 100644
index 00000000000..70a969a0325
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -0,0 +1,93 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+import {
+ findEntry,
+} from '../utils';
+import FilesDecoratorWorker from '../workers/files_decorator_worker';
+
+export const toggleTreeOpen = ({ commit, dispatch }, path) => {
+ commit(types.TOGGLE_TREE_OPEN, path);
+};
+
+export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
+ if (row.type === 'tree') {
+ dispatch('toggleTreeOpen', row.path);
+ } else if (row.type === 'blob' && (row.opened || row.changed)) {
+ if (row.changed && !row.opened) {
+ commit(types.TOGGLE_FILE_OPEN, row.path);
+ }
+
+ dispatch('setFileActive', row.path);
+ } else {
+ dispatch('getFileData', row);
+ }
+};
+
+export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => {
+ if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return;
+
+ service.getTreeLastCommit(tree.lastCommitPath)
+ .then((res) => {
+ const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null;
+
+ commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath });
+
+ return res.json();
+ })
+ .then((data) => {
+ data.forEach((lastCommit) => {
+ const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name);
+
+ if (entry) {
+ commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit });
+ }
+ });
+
+ dispatch('getLastCommitData', tree);
+ })
+ .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true));
+};
+
+export const getFiles = (
+ { state, commit, dispatch },
+ { projectId, branchId } = {},
+) => new Promise((resolve, reject) => {
+ if (!state.trees[`${projectId}/${branchId}`]) {
+ const selectedProject = state.projects[projectId];
+ commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
+
+ service
+ .getFiles(selectedProject.web_url, branchId)
+ .then(res => res.json())
+ .then((data) => {
+ const worker = new FilesDecoratorWorker();
+ worker.addEventListener('message', (e) => {
+ const { entries, treeList } = e.data;
+ const selectedTree = state.trees[`${projectId}/${branchId}`];
+
+ commit(types.SET_ENTRIES, entries);
+ commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList });
+ commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false });
+
+ worker.terminate();
+
+ resolve();
+ });
+
+ worker.postMessage({
+ data,
+ projectId,
+ branchId,
+ });
+ })
+ .catch((e) => {
+ flash('Error loading tree data. Please try again.', 'alert', document, null, false, true);
+ reject(e);
+ });
+ } else {
+ resolve();
+ }
+});
+
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
new file mode 100644
index 00000000000..eba325a31df
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -0,0 +1,30 @@
+export const activeFile = state =>
+ state.openFiles.find(file => file.active) || null;
+
+export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
+
+export const modifiedFiles = state =>
+ state.changedFiles.filter(f => !f.tempFile);
+
+export const projectsWithTrees = state =>
+ Object.keys(state.projects).map(projectId => {
+ const project = state.projects[projectId];
+
+ return {
+ ...project,
+ branches: Object.keys(project.branches).map(branchId => {
+ const branch = project.branches[branchId];
+
+ return {
+ ...branch,
+ tree: state.trees[branch.treeId],
+ };
+ }),
+ };
+ });
+
+// eslint-disable-next-line no-confusing-arrow
+export const currentIcon = state =>
+ state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
+
+export const hasChanges = state => !!state.changedFiles.length;
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
new file mode 100644
index 00000000000..7c82ce7976b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import commitModule from './modules/commit';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: state(),
+ actions,
+ mutations,
+ getters,
+ modules: {
+ commit: commitModule,
+ },
+});
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
new file mode 100644
index 00000000000..f536ce6344b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -0,0 +1,218 @@
+import $ from 'jquery';
+import { sprintf, __ } from '~/locale';
+import flash from '~/flash';
+import { stripHtml } from '~/lib/utils/text_utility';
+import * as rootTypes from '../../mutation_types';
+import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
+import router from '../../../ide_router';
+import service from '../../../services';
+import * as types from './mutation_types';
+import * as consts from './constants';
+import eventHub from '../../../eventhub';
+
+export const updateCommitMessage = ({ commit }, message) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, message);
+};
+
+export const discardDraft = ({ commit }) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, '');
+};
+
+export const updateCommitAction = ({ commit }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, commitAction);
+};
+
+export const updateBranchName = ({ commit }, branchName) => {
+ commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
+};
+
+export const setLastCommitMessage = ({ rootState, commit }, data) => {
+ const currentProject = rootState.projects[rootState.currentProjectId];
+ const commitStats = data.stats
+ ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), {
+ additions: data.stats.additions, // eslint-disable-line indent
+ deletions: data.stats.deletions, // eslint-disable-line indent
+ }) // eslint-disable-line indent
+ : '';
+ const commitMsg = sprintf(
+ __('Your changes have been committed. Commit %{commitId} %{commitStats}'),
+ {
+ commitId: `<a href="${currentProject.web_url}/commit/${
+ data.short_id
+ }" class="commit-sha">${data.short_id}</a>`,
+ commitStats,
+ },
+ false,
+ );
+
+ commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
+};
+
+export const checkCommitStatus = ({ rootState }) =>
+ service
+ .getBranchData(rootState.currentProjectId, rootState.currentBranchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ const selectedBranch =
+ rootState.projects[rootState.currentProjectId].branches[
+ rootState.currentBranchId
+ ];
+
+ if (selectedBranch.workingReference !== id) {
+ return true;
+ }
+
+ return false;
+ })
+ .catch(() =>
+ flash(
+ __('Error checking branch data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const updateFilesAfterCommit = (
+ { commit, dispatch, state, rootState, rootGetters },
+ { data, branch },
+) => {
+ const selectedProject = rootState.projects[rootState.currentProjectId];
+ const lastCommit = {
+ commit_path: `${selectedProject.web_url}/commit/${data.id}`,
+ commit: {
+ id: data.id,
+ message: data.message,
+ authored_date: data.committed_date,
+ author_name: data.committer_name,
+ },
+ };
+
+ commit(
+ rootTypes.SET_BRANCH_WORKING_REFERENCE,
+ {
+ projectId: rootState.currentProjectId,
+ branchId: rootState.currentBranchId,
+ reference: data.id,
+ },
+ { root: true },
+ );
+
+ rootState.changedFiles.forEach(entry => {
+ commit(
+ rootTypes.SET_LAST_COMMIT_DATA,
+ {
+ entry,
+ lastCommit,
+ },
+ { root: true },
+ );
+
+ eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
+
+ commit(
+ rootTypes.SET_FILE_RAW_DATA,
+ {
+ file: entry,
+ raw: entry.content,
+ },
+ { root: true },
+ );
+
+ commit(
+ rootTypes.TOGGLE_FILE_CHANGED,
+ {
+ file: entry,
+ changed: false,
+ },
+ { root: true },
+ );
+ });
+
+ commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
+ router.push(
+ `/project/${rootState.currentProjectId}/blob/${branch}/${
+ rootGetters.activeFile.path
+ }`,
+ );
+ }
+
+ dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH);
+};
+
+export const commitChanges = ({
+ commit,
+ state,
+ getters,
+ dispatch,
+ rootState,
+}) => {
+ const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
+ const payload = createCommitPayload(
+ getters.branchName,
+ newBranch,
+ state,
+ rootState,
+ );
+ const getCommitStatus = newBranch
+ ? Promise.resolve(false)
+ : dispatch('checkCommitStatus');
+
+ commit(types.UPDATE_LOADING, true);
+
+ return getCommitStatus
+ .then(
+ branchChanged =>
+ new Promise(resolve => {
+ if (branchChanged) {
+ // show the modal with a Bootstrap call
+ $('#ide-create-branch-modal').modal('show');
+ } else {
+ resolve();
+ }
+ }),
+ )
+ .then(() => service.commit(rootState.currentProjectId, payload))
+ .then(({ data }) => {
+ commit(types.UPDATE_LOADING, false);
+
+ if (!data.short_id) {
+ flash(data.message, 'alert', document, null, false, true);
+ return;
+ }
+
+ dispatch('setLastCommitMessage', data);
+ dispatch('updateCommitMessage', '');
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ dispatch(
+ 'redirectToUrl',
+ createNewMergeRequestUrl(
+ rootState.projects[rootState.currentProjectId].web_url,
+ getters.branchName,
+ rootState.currentBranchId,
+ ),
+ { root: true },
+ );
+ } else {
+ dispatch('updateFilesAfterCommit', {
+ data,
+ branch: getters.branchName,
+ });
+ }
+ })
+ .catch(err => {
+ let errMsg = __('Error committing changes. Please try again.');
+ if (err.response.data && err.response.data.message) {
+ errMsg += ` (${stripHtml(err.response.data.message)})`;
+ }
+ flash(errMsg, 'alert', document, null, false, true);
+ window.dispatchEvent(new Event('resize'));
+
+ commit(types.UPDATE_LOADING, false);
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js
new file mode 100644
index 00000000000..230b0a3d9b5
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js
@@ -0,0 +1,3 @@
+export const COMMIT_TO_CURRENT_BRANCH = '1';
+export const COMMIT_TO_NEW_BRANCH = '2';
+export const COMMIT_TO_NEW_BRANCH_MR = '3';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
new file mode 100644
index 00000000000..f7cdd6adb0c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -0,0 +1,24 @@
+import * as consts from './constants';
+
+export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading;
+
+export const commitButtonDisabled = (state, getters, rootState) =>
+ getters.discardDraftButtonDisabled || !rootState.changedFiles.length;
+
+export const newBranchName = (state, _, rootState) =>
+ `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`;
+
+export const branchName = (state, getters, rootState) => {
+ if (
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
+ ) {
+ if (state.newBranchName === '') {
+ return getters.newBranchName;
+ }
+
+ return state.newBranchName;
+ }
+
+ return rootState.currentBranchId;
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js
new file mode 100644
index 00000000000..3bf65b02847
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/index.js
@@ -0,0 +1,12 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+import * as getters from './getters';
+
+export default {
+ namespaced: true,
+ state: state(),
+ mutations,
+ actions,
+ getters,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
new file mode 100644
index 00000000000..9221f054e9f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -0,0 +1,4 @@
+export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
+export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
+export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
+export const UPDATE_LOADING = 'UPDATE_LOADING';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
new file mode 100644
index 00000000000..797357e3df9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) {
+ Object.assign(state, {
+ commitMessage,
+ });
+ },
+ [types.UPDATE_COMMIT_ACTION](state, commitAction) {
+ Object.assign(state, {
+ commitAction,
+ });
+ },
+ [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
+ Object.assign(state, {
+ newBranchName,
+ });
+ },
+ [types.UPDATE_LOADING](state, submitCommitLoading) {
+ Object.assign(state, {
+ submitCommitLoading,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
new file mode 100644
index 00000000000..8dae50961b0
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ commitMessage: '',
+ commitAction: '1',
+ newBranchName: '',
+ submitCommitLoading: false,
+});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
new file mode 100644
index 00000000000..e28f190897c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -0,0 +1,43 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+export const TOGGLE_LOADING = 'TOGGLE_LOADING';
+export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
+export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
+export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
+export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
+export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
+
+// Project Mutation Types
+export const SET_PROJECT = 'SET_PROJECT';
+export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
+export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
+
+// Branch Mutation Types
+export const SET_BRANCH = 'SET_BRANCH';
+export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
+export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
+
+// Tree mutation types
+export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
+export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN';
+export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
+export const CREATE_TREE = 'CREATE_TREE';
+export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES';
+
+// File mutation types
+export const SET_FILE_DATA = 'SET_FILE_DATA';
+export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN';
+export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE';
+export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA';
+export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
+export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
+export const SET_FILE_POSITION = 'SET_FILE_POSITION';
+export const SET_FILE_EOL = 'SET_FILE_EOL';
+export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
+export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
+export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
+export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED';
+export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH';
+export const SET_ENTRIES = 'SET_ENTRIES';
+export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY';
+export const UPDATE_VIEWER = 'UPDATE_VIEWER';
+export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
new file mode 100644
index 00000000000..da41fc9285c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -0,0 +1,106 @@
+import * as types from './mutation_types';
+import projectMutations from './mutations/project';
+import fileMutations from './mutations/file';
+import treeMutations from './mutations/tree';
+import branchMutations from './mutations/branch';
+
+export default {
+ [types.SET_INITIAL_DATA](state, data) {
+ Object.assign(state, data);
+ },
+ [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) {
+ if (entry.path) {
+ Object.assign(state.entries[entry.path], {
+ loading:
+ forceValue !== undefined
+ ? forceValue
+ : !state.entries[entry.path].loading,
+ });
+ } else {
+ Object.assign(entry, {
+ loading: forceValue !== undefined ? forceValue : !entry.loading,
+ });
+ }
+ },
+ [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ leftPanelCollapsed: collapsed,
+ });
+ },
+ [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ rightPanelCollapsed: collapsed,
+ });
+ },
+ [types.SET_RESIZING_STATUS](state, resizing) {
+ Object.assign(state, {
+ panelResizing: resizing,
+ });
+ },
+ [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
+ Object.assign(entry.lastCommit, {
+ id: lastCommit.commit.id,
+ url: lastCommit.commit_path,
+ message: lastCommit.commit.message,
+ author: lastCommit.commit.author_name,
+ updatedAt: lastCommit.commit.authored_date,
+ });
+ },
+ [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) {
+ Object.assign(state, {
+ lastCommitMsg,
+ });
+ },
+ [types.SET_ENTRIES](state, entries) {
+ Object.assign(state, {
+ entries,
+ });
+ },
+ [types.CREATE_TMP_ENTRY](state, { data, projectId, branchId }) {
+ Object.keys(data.entries).reduce((acc, key) => {
+ const entry = data.entries[key];
+ const foundEntry = state.entries[key];
+
+ if (!foundEntry) {
+ Object.assign(state.entries, {
+ [key]: entry,
+ });
+ } else {
+ const tree = entry.tree.filter(
+ f => foundEntry.tree.find(e => e.path === f.path) === undefined,
+ );
+ Object.assign(foundEntry, {
+ tree: foundEntry.tree.concat(tree),
+ });
+ }
+
+ return acc.concat(key);
+ }, []);
+
+ const foundEntry = state.trees[`${projectId}/${branchId}`].tree.find(
+ e => e.path === data.treeList[0].path,
+ );
+
+ if (!foundEntry) {
+ Object.assign(state.trees[`${projectId}/${branchId}`], {
+ tree: state.trees[`${projectId}/${branchId}`].tree.concat(
+ data.treeList,
+ ),
+ });
+ }
+ },
+ [types.UPDATE_VIEWER](state, viewer) {
+ Object.assign(state, {
+ viewer,
+ });
+ },
+ [types.UPDATE_DELAY_VIEWER_CHANGE](state, delayViewerUpdated) {
+ Object.assign(state, {
+ delayViewerUpdated,
+ });
+ },
+ ...projectMutations,
+ ...fileMutations,
+ ...treeMutations,
+ ...branchMutations,
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js
new file mode 100644
index 00000000000..2972ba5e38e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -0,0 +1,26 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_CURRENT_BRANCH](state, currentBranchId) {
+ Object.assign(state, {
+ currentBranchId,
+ });
+ },
+ [types.SET_BRANCH](state, { projectPath, branchName, branch }) {
+ Object.assign(state.projects[projectPath], {
+ branches: {
+ [branchName]: {
+ ...branch,
+ treeId: `${projectPath}/${branchName}`,
+ active: true,
+ workingReference: '',
+ },
+ },
+ });
+ },
+ [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) {
+ Object.assign(state.projects[projectId].branches[branchId], {
+ workingReference: reference,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
new file mode 100644
index 00000000000..2500f13db7c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -0,0 +1,83 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_FILE_ACTIVE](state, { path, active }) {
+ Object.assign(state.entries[path], {
+ active,
+ });
+ },
+ [types.TOGGLE_FILE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
+ });
+
+ if (state.entries[path].opened) {
+ state.openFiles.push(state.entries[path]);
+ } else {
+ Object.assign(state, {
+ openFiles: state.openFiles.filter(f => f.path !== path),
+ });
+ }
+ },
+ [types.SET_FILE_DATA](state, { data, file }) {
+ Object.assign(state.entries[file.path], {
+ id: data.id,
+ blamePath: data.blame_path,
+ commitsPath: data.commits_path,
+ permalink: data.permalink,
+ rawPath: data.raw_path,
+ binary: data.binary,
+ renderError: data.render_error,
+ });
+ },
+ [types.SET_FILE_RAW_DATA](state, { file, raw }) {
+ Object.assign(state.entries[file.path], {
+ raw,
+ });
+ },
+ [types.UPDATE_FILE_CONTENT](state, { path, content }) {
+ const changed = content !== state.entries[path].raw;
+
+ Object.assign(state.entries[path], {
+ content,
+ changed,
+ });
+ },
+ [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
+ Object.assign(state.entries[file.path], {
+ fileLanguage,
+ });
+ },
+ [types.SET_FILE_EOL](state, { file, eol }) {
+ Object.assign(state.entries[file.path], {
+ eol,
+ });
+ },
+ [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
+ Object.assign(state.entries[file.path], {
+ editorRow,
+ editorColumn,
+ });
+ },
+ [types.DISCARD_FILE_CHANGES](state, path) {
+ Object.assign(state.entries[path], {
+ content: state.entries[path].raw,
+ changed: false,
+ });
+ },
+ [types.ADD_FILE_TO_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.concat(state.entries[path]),
+ });
+ },
+ [types.REMOVE_FILE_FROM_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.path !== path),
+ });
+ },
+ [types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
+ Object.assign(state.entries[file.path], {
+ changed,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
new file mode 100644
index 00000000000..2816562a919
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -0,0 +1,23 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_CURRENT_PROJECT](state, currentProjectId) {
+ Object.assign(state, {
+ currentProjectId,
+ });
+ },
+ [types.SET_PROJECT](state, { projectPath, project }) {
+ // Add client side properties
+ Object.assign(project, {
+ tree: [],
+ branches: {},
+ active: true,
+ });
+
+ Object.assign(state, {
+ projects: Object.assign({}, state.projects, {
+ [projectPath]: project,
+ }),
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
new file mode 100644
index 00000000000..7f7e470c9bb
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -0,0 +1,38 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.TOGGLE_TREE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
+ });
+ },
+ [types.CREATE_TREE](state, { treePath }) {
+ Object.assign(state, {
+ trees: Object.assign({}, state.trees, {
+ [treePath]: {
+ tree: [],
+ loading: true,
+ },
+ }),
+ });
+ },
+ [types.SET_DIRECTORY_DATA](state, { data, treePath }) {
+ Object.assign(state, {
+ trees: Object.assign(state.trees, {
+ [treePath]: {
+ tree: data,
+ },
+ }),
+ });
+ },
+ [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
+ Object.assign(tree, {
+ lastCommitPath: url,
+ });
+ },
+ [types.REMOVE_ALL_CHANGES_FILES](state) {
+ Object.assign(state, {
+ changedFiles: [],
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
new file mode 100644
index 00000000000..6110f54951c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -0,0 +1,19 @@
+export default () => ({
+ currentProjectId: '',
+ currentBranchId: '',
+ changedFiles: [],
+ endpoints: {},
+ lastCommitMsg: '',
+ lastCommitPath: '',
+ loading: false,
+ openFiles: [],
+ parentTreeUrl: '',
+ trees: {},
+ projects: {},
+ leftPanelCollapsed: false,
+ rightPanelCollapsed: false,
+ panelResizing: false,
+ entries: {},
+ viewer: 'editor',
+ delayViewerUpdated: false,
+});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
new file mode 100644
index 00000000000..487ea1ead8e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -0,0 +1,125 @@
+export const dataStructure = () => ({
+ id: '',
+ key: '',
+ type: '',
+ projectId: '',
+ branchId: '',
+ name: '',
+ url: '',
+ path: '',
+ tempFile: false,
+ tree: [],
+ loading: false,
+ opened: false,
+ active: false,
+ changed: false,
+ lastCommitPath: '',
+ lastCommit: {
+ id: '',
+ url: '',
+ message: '',
+ updatedAt: '',
+ author: '',
+ },
+ blamePath: '',
+ commitsPath: '',
+ permalink: '',
+ rawPath: '',
+ binary: false,
+ html: '',
+ raw: '',
+ content: '',
+ parentTreeUrl: '',
+ renderError: false,
+ base64: false,
+ editorRow: 1,
+ editorColumn: 1,
+ fileLanguage: '',
+ eol: '',
+});
+
+export const decorateData = (entity) => {
+ const {
+ id,
+ projectId,
+ branchId,
+ type,
+ url,
+ name,
+ path,
+ renderError,
+ content = '',
+ tempFile = false,
+ active = false,
+ opened = false,
+ changed = false,
+ parentTreeUrl = '',
+ base64 = false,
+
+ file_lock,
+
+ } = entity;
+
+ return {
+ ...dataStructure(),
+ id,
+ projectId,
+ branchId,
+ key: `${name}-${type}-${id}`,
+ type,
+ name,
+ url,
+ path,
+ tempFile,
+ opened,
+ active,
+ parentTreeUrl,
+ changed,
+ renderError,
+ content,
+ base64,
+
+ file_lock,
+
+ };
+};
+
+export const findEntry = (tree, type, name, prop = 'name') => tree.find(
+ f => f.type === type && f[prop] === name,
+);
+
+export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
+
+export const setPageTitle = (title) => {
+ document.title = title;
+};
+
+export const createCommitPayload = (branch, newBranch, state, rootState) => ({
+ branch,
+ commit_message: state.commitMessage,
+ actions: rootState.changedFiles.map(f => ({
+ action: f.tempFile ? 'create' : 'update',
+ file_path: f.path,
+ content: f.content,
+ encoding: f.base64 ? 'base64' : 'text',
+ })),
+ start_branch: newBranch ? rootState.currentBranchId : undefined,
+});
+
+export const createNewMergeRequestUrl = (projectUrl, source, target) =>
+ `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`;
+
+const sortTreesByTypeAndName = (a, b) => {
+ if (a.type === 'tree' && b.type === 'blob') {
+ return -1;
+ } else if (a.type === 'blob' && b.type === 'tree') {
+ return 1;
+ }
+ if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
+ if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
+ return 0;
+};
+
+export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, {
+ tree: entity.tree.length ? sortTree(entity.tree) : [],
+})).sort(sortTreesByTypeAndName);
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
new file mode 100644
index 00000000000..a4cd1ab099f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
@@ -0,0 +1,101 @@
+import { decorateData, sortTree } from '../utils';
+
+self.addEventListener('message', e => {
+ const {
+ data,
+ projectId,
+ branchId,
+ tempFile = false,
+ content = '',
+ base64 = false,
+ } = e.data;
+
+ const treeList = [];
+ let file;
+ const entries = data.reduce((acc, path) => {
+ const pathSplit = path.split('/');
+ const blobName = pathSplit.pop().trim();
+
+ if (pathSplit.length > 0) {
+ pathSplit.reduce((pathAcc, folderName) => {
+ const parentFolder = acc[pathAcc[pathAcc.length - 1]];
+ const folderPath = `${
+ parentFolder ? `${parentFolder.path}/` : ''
+ }${folderName}`;
+ const foundEntry = acc[folderPath];
+
+ if (!foundEntry) {
+ const tree = decorateData({
+ projectId,
+ branchId,
+ id: folderPath,
+ name: folderName,
+ path: folderPath,
+ url: `/${projectId}/tree/${branchId}/${folderPath}/`,
+ type: 'tree',
+ parentTreeUrl: parentFolder
+ ? parentFolder.url
+ : `/${projectId}/tree/${branchId}/`,
+ tempFile,
+ changed: tempFile,
+ opened: tempFile,
+ });
+
+ Object.assign(acc, {
+ [folderPath]: tree,
+ });
+
+ if (parentFolder) {
+ parentFolder.tree.push(tree);
+ } else {
+ treeList.push(tree);
+ }
+
+ pathAcc.push(tree.path);
+ } else {
+ pathAcc.push(foundEntry.path);
+ }
+
+ return pathAcc;
+ }, []);
+ }
+
+ if (blobName !== '') {
+ const fileFolder = acc[pathSplit.join('/')];
+ file = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name: blobName,
+ path,
+ url: `/${projectId}/blob/${branchId}/${path}`,
+ type: 'blob',
+ parentTreeUrl: fileFolder
+ ? fileFolder.url
+ : `/${projectId}/blob/${branchId}`,
+ tempFile,
+ changed: tempFile,
+ content,
+ base64,
+ });
+
+ Object.assign(acc, {
+ [path]: file,
+ });
+
+ if (fileFolder) {
+ fileFolder.tree.push(file);
+ } else {
+ treeList.push(file);
+ }
+ }
+
+ return acc;
+ }, {});
+
+ self.postMessage({
+ entries,
+ treeList: sortTree(treeList),
+ file,
+ });
+});
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
new file mode 100644
index 00000000000..eddaeda9578
--- /dev/null
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -0,0 +1,35 @@
+export function createImageBadge(noteId, { x, y }, classNames = []) {
+ const buttonEl = document.createElement('button');
+ const classList = classNames.concat(['js-image-badge']);
+ classList.forEach(className => buttonEl.classList.add(className));
+ buttonEl.setAttribute('type', 'button');
+ buttonEl.setAttribute('disabled', true);
+ buttonEl.dataset.noteId = noteId;
+ buttonEl.style.left = `${x}px`;
+ buttonEl.style.top = `${y}px`;
+
+ return buttonEl;
+}
+
+export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
+ const buttonEl = createImageBadge(noteId, coordinate, ['badge']);
+ buttonEl.innerText = badgeText;
+
+ containerEl.appendChild(buttonEl);
+}
+
+export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
+ const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']);
+ buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark');
+
+ containerEl.appendChild(buttonEl);
+}
+
+export function addAvatarBadge(el, event) {
+ const { noteId, badgeNumber } = event.detail;
+
+ // Add badge to new comment
+ const avatarBadgeEl = el.querySelector(`#${noteId} .badge`);
+ avatarBadgeEl.innerText = badgeNumber;
+ avatarBadgeEl.classList.remove('hidden');
+}
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
new file mode 100644
index 00000000000..05000c73052
--- /dev/null
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -0,0 +1,58 @@
+export function addCommentIndicator(containerEl, { x, y }) {
+ const buttonEl = document.createElement('button');
+ buttonEl.classList.add('btn-transparent');
+ buttonEl.classList.add('comment-indicator');
+ buttonEl.setAttribute('type', 'button');
+ buttonEl.style.left = `${x}px`;
+ buttonEl.style.top = `${y}px`;
+
+ buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark');
+
+ containerEl.appendChild(buttonEl);
+}
+
+export function removeCommentIndicator(imageFrameEl) {
+ const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator');
+ const imageEl = imageFrameEl.querySelector('img');
+ const willRemove = !!commentIndicatorEl;
+ let meta = {};
+
+ if (willRemove) {
+ meta = {
+ x: parseInt(commentIndicatorEl.style.left, 10),
+ y: parseInt(commentIndicatorEl.style.top, 10),
+ image: {
+ width: imageEl.width,
+ height: imageEl.height,
+ },
+ };
+
+ commentIndicatorEl.remove();
+ }
+
+ return Object.assign({}, meta, {
+ removed: willRemove,
+ });
+}
+
+export function showCommentIndicator(imageFrameEl, coordinate) {
+ const { x, y } = coordinate;
+ const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator');
+
+ if (commentIndicatorEl) {
+ commentIndicatorEl.style.left = `${x}px`;
+ commentIndicatorEl.style.top = `${y}px`;
+ } else {
+ addCommentIndicator(imageFrameEl, coordinate);
+ }
+}
+
+export function commentIndicatorOnClick(event) {
+ // Prevent from triggering onAddImageDiffNote in notes.js
+ event.stopPropagation();
+
+ const buttonEl = event.currentTarget;
+ const diffViewerEl = buttonEl.closest('.diff-viewer');
+ const textareaEl = diffViewerEl.querySelector('.note-container .note-textarea');
+ textareaEl.focus();
+}
diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js
new file mode 100644
index 00000000000..12d56714b34
--- /dev/null
+++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js
@@ -0,0 +1,44 @@
+export function setPositionDataAttribute(el, options) {
+ // Update position data attribute so that the
+ // new comment form can use this data for ajax request
+ const { x, y, width, height } = options;
+ const position = el.dataset.position;
+ const positionObject = Object.assign({}, JSON.parse(position), {
+ x,
+ y,
+ width,
+ height,
+ });
+
+ el.setAttribute('data-position', JSON.stringify(positionObject));
+}
+
+export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
+ const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge');
+ avatarBadgeEl.innerText = newBadgeNumber;
+}
+
+export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) {
+ const discussionBadgeEl = discussionEl.querySelector('.badge');
+ discussionBadgeEl.innerText = newBadgeNumber;
+}
+
+export function toggleCollapsed(event) {
+ const toggleButtonEl = event.currentTarget;
+ const discussionNotesEl = toggleButtonEl.closest('.discussion-notes');
+ const formEl = discussionNotesEl.querySelector('.discussion-form');
+ const isCollapsed = discussionNotesEl.classList.contains('collapsed');
+
+ if (isCollapsed) {
+ discussionNotesEl.classList.remove('collapsed');
+ } else {
+ discussionNotesEl.classList.add('collapsed');
+ }
+
+ // Override the inline display style set in notes.js
+ if (formEl && !isCollapsed) {
+ formEl.style.display = 'none';
+ } else if (formEl && isCollapsed) {
+ formEl.style.display = 'block';
+ }
+}
diff --git a/app/assets/javascripts/image_diff/helpers/index.js b/app/assets/javascripts/image_diff/helpers/index.js
new file mode 100644
index 00000000000..4a100631003
--- /dev/null
+++ b/app/assets/javascripts/image_diff/helpers/index.js
@@ -0,0 +1,25 @@
+import * as badgeHelper from './badge_helper';
+import * as commentIndicatorHelper from './comment_indicator_helper';
+import * as domHelper from './dom_helper';
+import * as utilsHelper from './utils_helper';
+
+export default {
+ addCommentIndicator: commentIndicatorHelper.addCommentIndicator,
+ removeCommentIndicator: commentIndicatorHelper.removeCommentIndicator,
+ showCommentIndicator: commentIndicatorHelper.showCommentIndicator,
+ commentIndicatorOnClick: commentIndicatorHelper.commentIndicatorOnClick,
+
+ addImageBadge: badgeHelper.addImageBadge,
+ addImageCommentBadge: badgeHelper.addImageCommentBadge,
+ addAvatarBadge: badgeHelper.addAvatarBadge,
+
+ setPositionDataAttribute: domHelper.setPositionDataAttribute,
+ updateDiscussionAvatarBadgeNumber: domHelper.updateDiscussionAvatarBadgeNumber,
+ updateDiscussionBadgeNumber: domHelper.updateDiscussionBadgeNumber,
+ toggleCollapsed: domHelper.toggleCollapsed,
+
+ resizeCoordinatesToImageElement: utilsHelper.resizeCoordinatesToImageElement,
+ generateBadgeFromDiscussionDOM: utilsHelper.generateBadgeFromDiscussionDOM,
+ getTargetSelection: utilsHelper.getTargetSelection,
+ initImageDiff: utilsHelper.initImageDiff,
+};
diff --git a/app/assets/javascripts/image_diff/helpers/utils_helper.js b/app/assets/javascripts/image_diff/helpers/utils_helper.js
new file mode 100644
index 00000000000..28d9a969143
--- /dev/null
+++ b/app/assets/javascripts/image_diff/helpers/utils_helper.js
@@ -0,0 +1,95 @@
+import ImageBadge from '../image_badge';
+import ImageDiff from '../image_diff';
+import ReplacedImageDiff from '../replaced_image_diff';
+import ImageFile from '../../commit/image_file';
+
+export function resizeCoordinatesToImageElement(imageEl, meta) {
+ const { x, y, width, height } = meta;
+
+ const imageWidth = imageEl.width;
+ const imageHeight = imageEl.height;
+
+ const widthRatio = imageWidth / width;
+ const heightRatio = imageHeight / height;
+
+ return {
+ x: Math.round(x * widthRatio),
+ y: Math.round(y * heightRatio),
+ width: imageWidth,
+ height: imageHeight,
+ };
+}
+
+export function generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl) {
+ const position = JSON.parse(discussionEl.dataset.position);
+ const firstNoteEl = discussionEl.querySelector('.note');
+ const badge = new ImageBadge({
+ actual: position,
+ imageEl: imageFrameEl.querySelector('img'),
+ noteId: firstNoteEl.id,
+ discussionId: discussionEl.dataset.discussionId,
+ });
+
+ return badge;
+}
+
+export function getTargetSelection(event) {
+ const containerEl = event.currentTarget;
+ const imageEl = containerEl.querySelector('img');
+
+ const x = event.offsetX;
+ const y = event.offsetY;
+
+ const width = imageEl.width;
+ const height = imageEl.height;
+
+ const actualWidth = imageEl.naturalWidth;
+ const actualHeight = imageEl.naturalHeight;
+
+ const widthRatio = actualWidth / width;
+ const heightRatio = actualHeight / height;
+
+ // Browser will include the frame as a clickable target,
+ // which would result in potential 1px out of bounds value
+ // This bound the coordinates to inside the frame
+ const normalizedX = Math.max(0, x) && Math.min(x, width);
+ const normalizedY = Math.max(0, y) && Math.min(y, height);
+
+ return {
+ browser: {
+ x: normalizedX,
+ y: normalizedY,
+ width,
+ height,
+ },
+ actual: {
+ // Round x, y so that we don't need to deal with decimals
+ x: Math.round(normalizedX * widthRatio),
+ y: Math.round(normalizedY * heightRatio),
+ width: actualWidth,
+ height: actualHeight,
+ },
+ };
+}
+
+export function initImageDiff(fileEl, canCreateNote, renderCommentBadge) {
+ const options = {
+ canCreateNote,
+ renderCommentBadge,
+ };
+ let diff;
+
+ // ImageFile needs to be invoked before initImageDiff so that badges
+ // can mount to the correct location
+ new ImageFile(fileEl); // eslint-disable-line no-new
+
+ if (fileEl.querySelector('.diff-file .js-single-image')) {
+ diff = new ImageDiff(fileEl, options);
+ diff.init();
+ } else if (fileEl.querySelector('.diff-file .js-replaced-image')) {
+ diff = new ReplacedImageDiff(fileEl, options);
+ diff.init();
+ }
+
+ return diff;
+}
diff --git a/app/assets/javascripts/image_diff/image_badge.js b/app/assets/javascripts/image_diff/image_badge.js
new file mode 100644
index 00000000000..51a8cda98d7
--- /dev/null
+++ b/app/assets/javascripts/image_diff/image_badge.js
@@ -0,0 +1,23 @@
+import imageDiffHelper from './helpers/index';
+
+const defaultMeta = {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+};
+
+export default class ImageBadge {
+ constructor(options) {
+ const { noteId, discussionId } = options;
+
+ this.actual = options.actual || defaultMeta;
+ this.browser = options.browser || defaultMeta;
+ this.noteId = noteId;
+ this.discussionId = discussionId;
+
+ if (options.imageEl && !options.browser) {
+ this.browser = imageDiffHelper.resizeCoordinatesToImageElement(options.imageEl, this.actual);
+ }
+ }
+}
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
new file mode 100644
index 00000000000..fab0255c378
--- /dev/null
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -0,0 +1,144 @@
+import $ from 'jquery';
+import imageDiffHelper from './helpers/index';
+import ImageBadge from './image_badge';
+import { isImageLoaded } from '../lib/utils/image_utility';
+
+export default class ImageDiff {
+ constructor(el, options) {
+ this.el = el;
+ this.canCreateNote = !!(options && options.canCreateNote);
+ this.renderCommentBadge = !!(options && options.renderCommentBadge);
+ this.$noteContainer = $('.note-container', this.el);
+ this.imageBadges = [];
+ }
+
+ init() {
+ this.imageFrameEl = this.el.querySelector('.diff-file .js-image-frame');
+ this.imageEl = this.imageFrameEl.querySelector('img');
+
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.imageClickedWrapper = this.imageClicked.bind(this);
+ this.imageBlurredWrapper = imageDiffHelper.removeCommentIndicator.bind(null, this.imageFrameEl);
+ this.addBadgeWrapper = this.addBadge.bind(this);
+ this.removeBadgeWrapper = this.removeBadge.bind(this);
+ this.renderBadgesWrapper = this.renderBadges.bind(this);
+
+ // Render badges
+ if (isImageLoaded(this.imageEl)) {
+ this.renderBadges();
+ } else {
+ this.imageEl.addEventListener('load', this.renderBadgesWrapper);
+ }
+
+ // jquery makes the event delegation here much simpler
+ this.$noteContainer.on('click', '.js-diff-notes-toggle', imageDiffHelper.toggleCollapsed);
+ $(this.el).on('click', '.comment-indicator', imageDiffHelper.commentIndicatorOnClick);
+
+ if (this.canCreateNote) {
+ this.el.addEventListener('click.imageDiff', this.imageClickedWrapper);
+ this.el.addEventListener('blur.imageDiff', this.imageBlurredWrapper);
+ this.el.addEventListener('addBadge.imageDiff', this.addBadgeWrapper);
+ this.el.addEventListener('removeBadge.imageDiff', this.removeBadgeWrapper);
+ }
+ }
+
+ imageClicked(event) {
+ const customEvent = event.detail;
+ const selection = imageDiffHelper.getTargetSelection(customEvent);
+ const el = customEvent.currentTarget;
+
+ imageDiffHelper.setPositionDataAttribute(el, selection.actual);
+ imageDiffHelper.showCommentIndicator(this.imageFrameEl, selection.browser);
+ }
+
+ renderBadges() {
+ const discussionsEls = this.el.querySelectorAll('.note-container .discussion-notes .notes');
+ [...discussionsEls].forEach(this.renderBadge.bind(this));
+ }
+
+ renderBadge(discussionEl, index) {
+ const imageBadge = imageDiffHelper
+ .generateBadgeFromDiscussionDOM(this.imageFrameEl, discussionEl);
+
+ this.imageBadges.push(imageBadge);
+
+ const options = {
+ coordinate: imageBadge.browser,
+ noteId: imageBadge.noteId,
+ };
+
+ if (this.renderCommentBadge) {
+ imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options);
+ } else {
+ const numberBadgeOptions = Object.assign({}, options, {
+ badgeText: index + 1,
+ });
+
+ imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions);
+ }
+ }
+
+ addBadge(event) {
+ const { x, y, width, height, noteId, discussionId } = event.detail;
+ const badgeText = this.imageBadges.length + 1;
+ const imageBadge = new ImageBadge({
+ actual: {
+ x,
+ y,
+ width,
+ height,
+ },
+ imageEl: this.imageFrameEl.querySelector('img'),
+ noteId,
+ discussionId,
+ });
+
+ this.imageBadges.push(imageBadge);
+
+ imageDiffHelper.addImageBadge(this.imageFrameEl, {
+ coordinate: imageBadge.browser,
+ badgeText,
+ noteId,
+ });
+
+ imageDiffHelper.addAvatarBadge(this.el, {
+ detail: {
+ noteId,
+ badgeNumber: badgeText,
+ },
+ });
+
+ const discussionEl = this.el.querySelector(`#discussion_${discussionId}`);
+ imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, badgeText);
+ }
+
+ removeBadge(event) {
+ const { badgeNumber } = event.detail;
+ const indexToRemove = badgeNumber - 1;
+ const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge');
+
+ if (this.imageBadges.length !== badgeNumber) {
+ // Cascade badges count numbers for (avatar badges + image badges)
+ this.imageBadges.forEach((badge, index) => {
+ if (index > indexToRemove) {
+ const { discussionId } = badge;
+ const updatedBadgeNumber = index;
+ const discussionEl = this.el.querySelector(`#discussion_${discussionId}`);
+
+ imageBadgeEls[index].innerText = updatedBadgeNumber;
+
+ imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, updatedBadgeNumber);
+ imageDiffHelper.updateDiscussionAvatarBadgeNumber(discussionEl, updatedBadgeNumber);
+ }
+ });
+ }
+
+ this.imageBadges.splice(indexToRemove, 1);
+
+ const imageBadgeEl = imageBadgeEls[indexToRemove];
+ imageBadgeEl.remove();
+ }
+}
diff --git a/app/assets/javascripts/image_diff/init_discussion_tab.js b/app/assets/javascripts/image_diff/init_discussion_tab.js
new file mode 100644
index 00000000000..2f16c6ef115
--- /dev/null
+++ b/app/assets/javascripts/image_diff/init_discussion_tab.js
@@ -0,0 +1,12 @@
+import imageDiffHelper from './helpers/index';
+
+export default () => {
+ // Always pass can-create-note as false because a user
+ // cannot place new badge markers on discussion tab
+ const canCreateNote = false;
+ const renderCommentBadge = true;
+
+ const diffFileEls = document.querySelectorAll('.timeline-content .diff-file.js-image-file');
+ [...diffFileEls].forEach(diffFileEl =>
+ imageDiffHelper.initImageDiff(diffFileEl, canCreateNote, renderCommentBadge));
+};
diff --git a/app/assets/javascripts/image_diff/replaced_image_diff.js b/app/assets/javascripts/image_diff/replaced_image_diff.js
new file mode 100644
index 00000000000..4abd13fb472
--- /dev/null
+++ b/app/assets/javascripts/image_diff/replaced_image_diff.js
@@ -0,0 +1,92 @@
+import imageDiffHelper from './helpers/index';
+import { viewTypes, isValidViewType } from './view_types';
+import ImageDiff from './image_diff';
+
+export default class ReplacedImageDiff extends ImageDiff {
+ init(defaultViewType = viewTypes.TWO_UP) {
+ this.imageFrameEls = {
+ [viewTypes.TWO_UP]: this.el.querySelector('.two-up .js-image-frame'),
+ [viewTypes.SWIPE]: this.el.querySelector('.swipe .js-image-frame'),
+ [viewTypes.ONION_SKIN]: this.el.querySelector('.onion-skin .js-image-frame'),
+ };
+
+ const viewModesEl = this.el.querySelector('.view-modes-menu');
+ this.viewModesEls = {
+ [viewTypes.TWO_UP]: viewModesEl.querySelector('.two-up'),
+ [viewTypes.SWIPE]: viewModesEl.querySelector('.swipe'),
+ [viewTypes.ONION_SKIN]: viewModesEl.querySelector('.onion-skin'),
+ };
+
+ this.currentView = defaultViewType;
+ this.generateImageEls();
+ this.bindEvents();
+ }
+
+ generateImageEls() {
+ this.imageEls = {};
+
+ const viewTypeNames = Object.getOwnPropertyNames(viewTypes);
+ viewTypeNames.forEach((viewType) => {
+ this.imageEls[viewType] = this.imageFrameEls[viewType].querySelector('img');
+ });
+ }
+
+ bindEvents() {
+ super.bindEvents();
+
+ this.changeToViewTwoUp = this.changeView.bind(this, viewTypes.TWO_UP);
+ this.changeToViewSwipe = this.changeView.bind(this, viewTypes.SWIPE);
+ this.changeToViewOnionSkin = this.changeView.bind(this, viewTypes.ONION_SKIN);
+
+ this.viewModesEls[viewTypes.TWO_UP].addEventListener('click', this.changeToViewTwoUp);
+ this.viewModesEls[viewTypes.SWIPE].addEventListener('click', this.changeToViewSwipe);
+ this.viewModesEls[viewTypes.ONION_SKIN].addEventListener('click', this.changeToViewOnionSkin);
+ }
+
+ get imageEl() {
+ return this.imageEls[this.currentView];
+ }
+
+ get imageFrameEl() {
+ return this.imageFrameEls[this.currentView];
+ }
+
+ changeView(newView) {
+ if (!isValidViewType(newView)) {
+ return;
+ }
+
+ const indicator = imageDiffHelper.removeCommentIndicator(this.imageFrameEl);
+
+ this.currentView = newView;
+
+ // Clear existing badges on new view
+ const existingBadges = this.imageFrameEl.querySelectorAll('.badge');
+ [...existingBadges].map(badge => badge.remove());
+
+ // Remove existing references to old view image badges
+ this.imageBadges = [];
+
+ // Image_file.js has a fade animation of 200ms for loading the view
+ // Need to wait an additional 250ms for the images to be displayed
+ // on window in order to re-normalize their dimensions
+ setTimeout(this.renderNewView.bind(this, indicator), 250);
+ }
+
+ renderNewView(indicator) {
+ // Generate badge coordinates on new view
+ this.renderBadges();
+
+ // Re-render indicator in new view
+ if (indicator.removed) {
+ const normalizedIndicator = imageDiffHelper
+ .resizeCoordinatesToImageElement(this.imageEl, {
+ x: indicator.x,
+ y: indicator.y,
+ width: indicator.image.width,
+ height: indicator.image.height,
+ });
+ imageDiffHelper.showCommentIndicator(this.imageFrameEl, normalizedIndicator);
+ }
+ }
+}
diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js
new file mode 100644
index 00000000000..ab0a595571f
--- /dev/null
+++ b/app/assets/javascripts/image_diff/view_types.js
@@ -0,0 +1,9 @@
+export const viewTypes = {
+ TWO_UP: 'TWO_UP',
+ SWIPE: 'SWIPE',
+ ONION_SKIN: 'ONION_SKIN',
+};
+
+export function isValidViewType(validate) {
+ return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate);
+}
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 5b4ca94ed30..b469e1e2adc 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -1,83 +1,126 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, camelcase, no-var, one-var, one-var-declaration-per-line, prefer-template, quotes, object-shorthand, comma-dangle, no-unused-vars, prefer-arrow-callback, no-else-return, vars-on-top, no-new, max-len */
+import $ from 'jquery';
+import _ from 'underscore';
+import { __, sprintf } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
-(function() {
- window.ImporterStatus = (function() {
- function ImporterStatus(jobs_url, import_url) {
- this.jobs_url = jobs_url;
- this.import_url = import_url;
- this.initStatusPage();
- this.setAutoUpdate();
- }
+class ImporterStatus {
+ constructor({ jobsUrl, importUrl, ciCdOnly }) {
+ this.jobsUrl = jobsUrl;
+ this.importUrl = importUrl;
+ this.ciCdOnly = ciCdOnly;
+ this.initStatusPage();
+ this.setAutoUpdate();
+ }
- ImporterStatus.prototype.initStatusPage = function() {
- $('.js-add-to-import').off('click').on('click', (function(_this) {
- return function(e) {
- var $btn, $namespace_input, $target_field, $tr, id, target_namespace, newName;
- $btn = $(e.currentTarget);
- $tr = $btn.closest('tr');
- $target_field = $tr.find('.import-target');
- $namespace_input = $target_field.find('.js-select-namespace option:selected');
- id = $tr.attr('id').replace('repo_', '');
- target_namespace = null;
- newName = null;
- if ($namespace_input.length > 0) {
- target_namespace = $namespace_input[0].innerHTML;
- newName = $target_field.find('#path').prop('value');
- $target_field.empty().append(target_namespace + "/" + newName);
- }
- $btn.disable().addClass('is-loading');
- return $.post(_this.import_url, {
- repo_id: id,
- target_namespace: target_namespace,
- new_name: newName
- }, {
- dataType: 'script'
- });
- };
- })(this));
- return $('.js-import-all').off('click').on('click', function(e) {
- var $btn;
- $btn = $(this);
+ initStatusPage() {
+ $('.js-add-to-import')
+ .off('click')
+ .on('click', this.addToImport.bind(this));
+
+ $('.js-import-all')
+ .off('click')
+ .on('click', function onClickImportAll() {
+ const $btn = $(this);
$btn.disable().addClass('is-loading');
- return $('.js-add-to-import').each(function() {
+ return $('.js-add-to-import').each(function triggerAddImport() {
return $(this).trigger('click');
});
});
- };
+ }
+
+ addToImport(event) {
+ const $btn = $(event.currentTarget);
+ const $tr = $btn.closest('tr');
+ const $targetField = $tr.find('.import-target');
+ const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
+ const id = $tr.attr('id').replace('repo_', '');
+ let targetNamespace;
+ let newName;
+ if ($namespaceInput.length > 0) {
+ targetNamespace = $namespaceInput[0].innerHTML;
+ newName = $targetField.find('#path').prop('value');
+ $targetField.empty().append(`${targetNamespace}/${newName}`);
+ }
+ $btn.disable().addClass('is-loading');
- ImporterStatus.prototype.setAutoUpdate = function() {
- return setInterval(((function(_this) {
- return function() {
- return $.get(_this.jobs_url, function(data) {
- return $.each(data, function(i, job) {
- var job_item, status_field;
- job_item = $("#project_" + job.id);
- status_field = job_item.find(".job-status");
- 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 {
- return status_field.html(job.import_status);
- }
- });
- });
- };
- })(this)), 4000);
- };
+ return axios.post(this.importUrl, {
+ repo_id: id,
+ target_namespace: targetNamespace,
+ new_name: newName,
+ ci_cd_only: this.ciCdOnly,
+ })
+ .then(({ data }) => {
+ const job = $(`tr#repo_${id}`);
+ job.attr('id', `project_${data.id}`);
- return ImporterStatus;
- })();
+ job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`);
+ $('table.import-jobs tbody').prepend(job);
- $(function() {
- if ($('.js-importer-status').length) {
- var jobsImportPath = $('.js-importer-status').data('jobs-import-path');
- var importPath = $('.js-importer-status').data('import-path');
+ job.addClass('active');
+ const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing');
+ job.find('.import-actions').html(sprintf(
+ _.escape(__('%{loadingIcon} Started')), {
+ loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape(connectingVerb)}"></i>`,
+ },
+ false,
+ ));
+ })
+ .catch(() => flash(__('An error occurred while importing project')));
+ }
- new window.ImporterStatus(jobsImportPath, importPath);
- }
- });
-}).call(window);
+ autoUpdate() {
+ return axios.get(this.jobsUrl)
+ .then(({ data = [] }) => {
+ data.forEach((job) => {
+ const jobItem = $(`#project_${job.id}`);
+ const statusField = jobItem.find('.job-status');
+
+ const spinner = '<i class="fa fa-spinner fa-spin"></i>';
+
+ switch (job.import_status) {
+ case 'finished':
+ jobItem.removeClass('active').addClass('success');
+ statusField.html(`<span><i class="fa fa-check"></i> ${__('Done')}</span>`);
+ break;
+ case 'scheduled':
+ statusField.html(`${spinner} ${__('Scheduled')}`);
+ break;
+ case 'started':
+ statusField.html(`${spinner} ${__('Started')}`);
+ break;
+ case 'failed':
+ statusField.html(__('Failed'));
+ break;
+ default:
+ statusField.html(job.import_status);
+ break;
+ }
+ });
+ });
+ }
+
+ setAutoUpdate() {
+ setInterval(this.autoUpdate.bind(this), 4000);
+ }
+}
+
+// eslint-disable-next-line consistent-return
+function initImporterStatus() {
+ const importerStatus = document.querySelector('.js-importer-status');
+
+ if (importerStatus) {
+ const data = importerStatus.dataset;
+ return new ImporterStatus({
+ jobsUrl: data.jobsImportPath,
+ importUrl: data.importPath,
+ ciCdOnly: convertPermissionToBoolean(data.ciCdOnly),
+ });
+ }
+}
+
+export {
+ initImporterStatus as default,
+ ImporterStatus,
+};
diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js
new file mode 100644
index 00000000000..09cca1dc7d9
--- /dev/null
+++ b/app/assets/javascripts/init_changes_dropdown.js
@@ -0,0 +1,11 @@
+import $ from 'jquery';
+import stickyMonitor from './lib/utils/sticky';
+
+export default (stickyTop) => {
+ stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop);
+
+ $('.js-diff-stats-dropdown').glDropdown({
+ filterable: true,
+ remoteFilter: false,
+ });
+};
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
new file mode 100644
index 00000000000..e61b37a2d1f
--- /dev/null
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -0,0 +1,20 @@
+/* eslint-disable no-new */
+
+import MilestoneSelect from './milestone_select';
+import LabelsSelect from './labels_select';
+import IssuableContext from './issuable_context';
+import Sidebar from './right_sidebar';
+
+import DueDateSelectors from './due_date_select';
+
+export default () => {
+ const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+
+ new MilestoneSelect({
+ full_path: sidebarOptions.fullPath,
+ });
+ new LabelsSelect();
+ new IssuableContext(sidebarOptions.currentUser);
+ new DueDateSelectors();
+ Sidebar.initialize();
+};
diff --git a/app/assets/javascripts/init_labels.js b/app/assets/javascripts/init_labels.js
new file mode 100644
index 00000000000..15da5d5cceb
--- /dev/null
+++ b/app/assets/javascripts/init_labels.js
@@ -0,0 +1,19 @@
+import $ from 'jquery';
+import LabelManager from './label_manager';
+import GroupLabelSubscription from './group_label_subscription';
+import ProjectLabelSubscription from './project_label_subscription';
+
+export default () => {
+ if ($('.prioritized-labels').length) {
+ new LabelManager(); // eslint-disable-line no-new
+ }
+ $('.label-subscription').each((i, el) => {
+ const $el = $(el);
+
+ if ($el.find('.dropdown-group-label').length) {
+ new GroupLabelSubscription($el); // eslint-disable-line no-new
+ } else {
+ new ProjectLabelSubscription($el); // eslint-disable-line no-new
+ }
+ });
+};
diff --git a/app/assets/javascripts/init_legacy_filters.js b/app/assets/javascripts/init_legacy_filters.js
new file mode 100644
index 00000000000..b6ff97d1279
--- /dev/null
+++ b/app/assets/javascripts/init_legacy_filters.js
@@ -0,0 +1,14 @@
+/* eslint-disable no-new */
+import LabelsSelect from './labels_select';
+import subscriptionSelect from './subscription_select';
+import UsersSelect from './users_select';
+import issueStatusSelect from './issue_status_select';
+import MilestoneSelect from './milestone_select';
+
+export default () => {
+ new UsersSelect();
+ new LabelsSelect();
+ new MilestoneSelect();
+ issueStatusSelect();
+ subscriptionSelect();
+};
diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_notes.js
new file mode 100644
index 00000000000..882aedfcc76
--- /dev/null
+++ b/app/assets/javascripts/init_notes.js
@@ -0,0 +1,16 @@
+import Notes from './notes';
+
+export default () => {
+ const dataEl = document.querySelector('.js-notes-data');
+ const {
+ notesUrl,
+ notesIds,
+ now,
+ diffView,
+ autocomplete,
+ } = JSON.parse(dataEl.innerHTML);
+
+ // Create a singleton so that we don't need to assign
+ // into the window object, we can just access the current isntance with Notes.instance
+ Notes.initialize(notesUrl, notesIds, now, diffView, autocomplete);
+};
diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js
deleted file mode 100644
index 10fe6bac0e8..00000000000
--- a/app/assets/javascripts/integrations/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/* 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
index ddd3a6aab99..741894b5e6c 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,12 +1,14 @@
-/* global Flash */
+import $ from 'jquery';
+import axios from '../lib/utils/axios_utils';
+import flash from '../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');
+ this.canTestService = this.$form.data('canTest');
+ this.testEndPoint = this.$form.data('testUrl');
// Form Child Elements
this.$serviceToggle = this.$form.find('#service_active');
@@ -95,29 +97,26 @@ export default class IntegrationSettingsForm {
*/
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);
- });
+
+ return axios.put(this.testEndPoint, formData)
+ .then(({ data }) => {
+ if (data.error) {
+ flash(`${data.message} ${data.service_response}`, 'alert', document, {
+ title: 'Save anyway',
+ clickHandler: (e) => {
+ e.preventDefault();
+ this.$form.submit();
+ },
+ });
+ } else {
+ this.$form.submit();
+ }
+
+ this.toggleSubmitBtnState(false);
+ })
+ .catch(() => {
+ flash('Something went wrong on our end.');
+ this.toggleSubmitBtnState(false);
+ });
}
}
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
index 2203a56315e..b2c2de9e5de 100644
--- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
let instanceCount = 0;
class AutoWidthDropdownSelect {
@@ -11,6 +13,14 @@ class AutoWidthDropdownSelect {
const dropdownClass = this.dropdownClass;
this.$selectElement.select2({
dropdownCssClass: dropdownClass,
+ ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
+ });
+
+ return this;
+ }
+
+ static selectOptions(dropdownClass) {
+ return {
dropdownCss() {
let resultantWidth = 'auto';
const $dropdown = $(`.${dropdownClass}`);
@@ -29,9 +39,7 @@ class AutoWidthDropdownSelect {
maxWidth: offsetParentWidth,
};
},
- });
-
- return this;
+ };
}
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index e46c0e90255..e003fb1d127 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,6 +1,9 @@
/* 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 */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import Flash from './flash';
export default {
init({ container, form, issues, prefixId } = {}) {
@@ -22,15 +25,9 @@ export default {
},
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());
+ axios[this.form.attr('method')](this.form.attr('action'), this.getFormDataAsObject())
+ .then(() => window.location.reload())
+ .catch(() => this.onFormSubmitFailure());
},
onFormSubmitFailure() {
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index a8856120c5e..2307c8e0d85 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,10 +1,11 @@
/* eslint-disable class-methods-use-this, no-new */
-/* global LabelsSelect */
-/* global MilestoneSelect */
-/* global IssueStatusSelect */
-/* global SubscriptionSelect */
+import $ from 'jquery';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+import MilestoneSelect from './milestone_select';
+import issueStatusSelect from './issue_status_select';
+import subscriptionSelect from './subscription_select';
+import LabelsSelect from './labels_select';
const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content';
@@ -20,7 +21,7 @@ export default class IssuableBulkUpdateSidebar {
}
initDomElements() {
- this.$page = $('.page-with-sidebar');
+ this.$page = $('.layout-page');
this.$sidebar = $('.right-sidebar');
this.$sidebarInnerContainer = this.$sidebar.find('.issuable-sidebar');
this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide');
@@ -45,27 +46,8 @@ export default class IssuableBulkUpdateSidebar {
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;
- }
+ issueStatusSelect();
+ subscriptionSelect();
}
setupBulkUpdateActions() {
@@ -95,10 +77,6 @@ export default class IssuableBulkUpdateSidebar {
this.toggleBulkEditButtonDisabled(enable);
this.toggleOtherFiltersDisabled(enable);
this.toggleCheckboxDisplay(enable);
-
- if (enable) {
- this.initSidebar();
- }
}
updateSelectedIssuableIds() {
@@ -143,17 +121,6 @@ export default class IssuableBulkUpdateSidebar {
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');
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index a4d7bf096ef..7470d634b99 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,32 +1,33 @@
-/* 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 bp */
-
+import $ from 'jquery';
import Cookies from 'js-cookie';
+import bp from './breakpoints';
import UsersSelect from './users_select';
-(function() {
- this.IssuableContext = (function() {
- function IssuableContext(currentUser) {
- this.initParticipants();
- new UsersSelect(currentUser);
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true
- });
- $(".issuable-sidebar .inline-update").on("change", "select", function() {
- return $(this).submit();
- });
- $(".issuable-sidebar .inline-update").on("change", ".js-assignee", function() {
- return $(this).submit();
- });
- $(document).off('click', '.issuable-sidebar .dropdown-content a').on('click', '.issuable-sidebar .dropdown-content a', function(e) {
- return e.preventDefault();
- });
- $(document).off('click', '.edit-link').on('click', '.edit-link', function(e) {
- var $block, $selectbox;
+export default class IssuableContext {
+ constructor(currentUser) {
+ this.userSelect = new UsersSelect(currentUser);
+
+ $('select.select2').select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+
+ $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() {
+ return $(this).submit();
+ });
+ $('.issuable-sidebar .inline-update').on('change', '.js-assignee', function onClickAssignee() {
+ return $(this).submit();
+ });
+ $(document)
+ .off('click', '.issuable-sidebar .dropdown-content a')
+ .on('click', '.issuable-sidebar .dropdown-content a', e => e.preventDefault());
+
+ $(document)
+ .off('click', '.edit-link')
+ .on('click', '.edit-link', function onClickEdit(e) {
e.preventDefault();
- $block = $(this).parents('.block');
- $selectbox = $block.find('.selectbox');
+ const $block = $(this).parents('.block');
+ const $selectbox = $block.find('.selectbox');
if ($selectbox.is(':visible')) {
$selectbox.hide();
$block.find('.value').show();
@@ -34,46 +35,18 @@ import UsersSelect from './users_select';
$selectbox.show();
$block.find('.value').hide();
}
- if ($selectbox.is(':visible')) {
- return setTimeout(function() {
- return $block.find('.dropdown-menu-toggle').trigger('click');
- }, 0);
- }
- });
- window.addEventListener('beforeunload', function() {
- // collapsed_gutter cookie hides the sidebar
- var bpBreakpoint = bp.getBreakpointSize();
- if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') {
- Cookies.set('collapsed_gutter', true);
- }
- });
- }
- IssuableContext.prototype.initParticipants = function() {
- var _this;
- _this = this;
- $(document).on("click", ".js-participants-more", this.toggleHiddenParticipants);
- return $(".js-participants-author").each(function(i) {
- if (i >= _this.PARTICIPANTS_ROW_COUNT) {
- return $(this).addClass("js-participants-hidden").hide();
+ if ($selectbox.is(':visible')) {
+ setTimeout(() => $block.find('.dropdown-menu-toggle').trigger('click'), 0);
}
});
- };
- IssuableContext.prototype.toggleHiddenParticipants = function(e) {
- var currentText, lessText, originalText;
- e.preventDefault();
- currentText = $(this).text().trim();
- lessText = $(this).data("less-text");
- originalText = $(this).data("original-text");
- if (currentText === originalText) {
- $(this).text(lessText);
- } else {
- $(this).text(originalText);
+ window.addEventListener('beforeunload', () => {
+ // collapsed_gutter cookie hides the sidebar
+ const bpBreakpoint = bp.getBreakpointSize();
+ if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') {
+ Cookies.set('collapsed_gutter', true);
}
- return $(".js-participants-hidden").toggle();
- };
-
- return IssuableContext;
- })();
-}).call(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 92f6f0d4117..bb8b3d91e40 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,160 +1,148 @@
-/* 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 */
+/* eslint-disable func-names, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, 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 ZenMode */
-/* global Autosave */
-/* global dateFormat */
-/* global Pikaday */
+import $ from 'jquery';
+import Pikaday from 'pikaday';
+import Autosave from './autosave';
import UsersSelect from './users_select';
import GfmAutoComplete from './gfm_auto_complete';
+import ZenMode from './zen_mode';
+import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
+
+export default class IssuableForm {
+ constructor(form) {
+ this.form = form;
+ this.toggleWip = this.toggleWip.bind(this);
+ this.renderWipExplanation = this.renderWipExplanation.bind(this);
+ this.resetAutosave = this.resetAutosave.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i;
+
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup();
+ new UsersSelect();
+ new ZenMode();
+
+ this.titleField = this.form.find('input[name*="[title]"]');
+ this.descriptionField = this.form.find('textarea[name*="[description]"]');
+ if (!(this.titleField.length && this.descriptionField.length)) {
+ return;
+ }
-(function() {
- this.IssuableForm = (function() {
- IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
-
- IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i;
-
- function IssuableForm(form) {
- var $issuableDueDate, calendar;
- this.form = form;
- 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]']");
- this.descriptionField = this.form.find("textarea[name*='[description]']");
- this.issueMoveField = this.form.find("#move_to_project_id");
- if (!(this.titleField.length && this.descriptionField.length)) {
- return;
- }
- this.initAutosave();
- this.form.on("submit", this.handleSubmit);
- this.form.on("click", ".btn-cancel", this.resetAutosave);
- this.initWip();
- this.initMoveDropdown();
- $issuableDueDate = $('#issuable-due-date');
- if ($issuableDueDate.length) {
- calendar = new Pikaday({
- field: $issuableDueDate.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- container: $issuableDueDate.parent().get(0),
- onSelect: function(dateText) {
- $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
- }
- });
- calendar.setDate(new Date($issuableDueDate.val()));
- }
+ this.initAutosave();
+ this.form.on('submit', this.handleSubmit);
+ this.form.on('click', '.btn-cancel', this.resetAutosave);
+ this.initWip();
+
+ const $issuableDueDate = $('#issuable-due-date');
+
+ if ($issuableDueDate.length) {
+ const calendar = new Pikaday({
+ field: $issuableDueDate.get(0),
+ theme: 'gitlab-theme animate-picker',
+ format: 'yyyy-mm-dd',
+ container: $issuableDueDate.parent().get(0),
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
+ onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)),
+ });
+ calendar.setDate(parsePikadayDate($issuableDueDate.val()));
}
- IssuableForm.prototype.initAutosave = function() {
- new Autosave(this.titleField, [document.location.pathname, document.location.search, "title"]);
- return new Autosave(this.descriptionField, [document.location.pathname, document.location.search, "description"]);
- };
-
- IssuableForm.prototype.handleSubmit = function() {
- var fieldId = (this.issueMoveField != null) ? this.issueMoveField.val() : null;
- if ((parseInt(fieldId, 10) || 0) > 0) {
- if (!confirm(this.issueMoveConfirmMsg)) {
- return false;
- }
- }
- return this.resetAutosave();
- };
-
- IssuableForm.prototype.resetAutosave = function() {
- this.titleField.data("autosave").reset();
- return this.descriptionField.data("autosave").reset();
- };
-
- IssuableForm.prototype.initWip = function() {
- this.$wipExplanation = this.form.find(".js-wip-explanation");
- this.$noWipExplanation = this.form.find(".js-no-wip-explanation");
- if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) {
- return;
- }
- this.form.on("click", ".js-toggle-wip", this.toggleWip);
- this.titleField.on("keyup blur", this.renderWipExplanation);
- return this.renderWipExplanation();
- };
-
- IssuableForm.prototype.workInProgress = function() {
- return this.wipRegex.test(this.titleField.val());
- };
-
- IssuableForm.prototype.renderWipExplanation = function() {
- if (this.workInProgress()) {
- this.$wipExplanation.show();
- return this.$noWipExplanation.hide();
- } else {
- this.$wipExplanation.hide();
- return this.$noWipExplanation.show();
- }
- };
-
- IssuableForm.prototype.toggleWip = function(event) {
- event.preventDefault();
- if (this.workInProgress()) {
- this.removeWip();
- } else {
- this.addWip();
- }
- return this.renderWipExplanation();
- };
-
- IssuableForm.prototype.removeWip = function() {
- return this.titleField.val(this.titleField.val().replace(this.wipRegex, ""));
- };
-
- IssuableForm.prototype.addWip = function() {
- return this.titleField.val("WIP: " + (this.titleField.val()));
- };
-
- IssuableForm.prototype.initMoveDropdown = function() {
- var $moveDropdown, pageSize;
- $moveDropdown = $('.js-move-dropdown');
- if ($moveDropdown.length) {
- pageSize = $moveDropdown.data('page-size');
- return $('.js-move-dropdown').select2({
- ajax: {
- url: $moveDropdown.data('projects-url'),
- quietMillis: 125,
- data: function(term, page, context) {
- return {
- search: term,
- offset_id: context
- };
- },
- results: function(data) {
- var context,
- more;
-
- if (data.length >= pageSize)
- more = true;
-
- if (data[data.length - 1])
- context = data[data.length - 1].id;
-
- return {
- results: data,
- more: more,
- context: context
- };
- }
- },
- formatResult: function(project) {
- return project.name_with_namespace;
- },
- formatSelection: function(project) {
- return project.name_with_namespace;
- }
- });
- }
- };
+ this.$targetBranchSelect = $('.js-target-branch-select', this.form);
- return IssuableForm;
- })();
-}).call(window);
+ if (this.$targetBranchSelect.length) {
+ this.initTargetBranchDropdown();
+ }
+ }
+
+ initAutosave() {
+ new Autosave(this.titleField, [document.location.pathname, document.location.search, 'title']);
+ return new Autosave(this.descriptionField, [document.location.pathname, document.location.search, 'description']);
+ }
+
+ handleSubmit() {
+ return this.resetAutosave();
+ }
+
+ resetAutosave() {
+ this.titleField.data('autosave').reset();
+ return this.descriptionField.data('autosave').reset();
+ }
+
+ initWip() {
+ this.$wipExplanation = this.form.find('.js-wip-explanation');
+ this.$noWipExplanation = this.form.find('.js-no-wip-explanation');
+ if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) {
+ return;
+ }
+ this.form.on('click', '.js-toggle-wip', this.toggleWip);
+ this.titleField.on('keyup blur', this.renderWipExplanation);
+ return this.renderWipExplanation();
+ }
+
+ workInProgress() {
+ return this.wipRegex.test(this.titleField.val());
+ }
+
+ renderWipExplanation() {
+ if (this.workInProgress()) {
+ this.$wipExplanation.show();
+ return this.$noWipExplanation.hide();
+ } else {
+ this.$wipExplanation.hide();
+ return this.$noWipExplanation.show();
+ }
+ }
+
+ toggleWip(event) {
+ event.preventDefault();
+ if (this.workInProgress()) {
+ this.removeWip();
+ } else {
+ this.addWip();
+ }
+ return this.renderWipExplanation();
+ }
+
+ removeWip() {
+ return this.titleField.val(this.titleField.val().replace(this.wipRegex, ''));
+ }
+
+ addWip() {
+ this.titleField.val(`WIP: ${(this.titleField.val())}`);
+ }
+
+ initTargetBranchDropdown() {
+ this.$targetBranchSelect.select2({
+ ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
+ ajax: {
+ url: this.$targetBranchSelect.data('endpoint'),
+ dataType: 'JSON',
+ quietMillis: 250,
+ data(search) {
+ return {
+ search,
+ };
+ },
+ results(data) {
+ return {
+ // `data` keys are translated so we can't just access them with a string based key
+ results: data[Object.keys(data)[0]].map(name => ({
+ id: name,
+ text: name,
+ })),
+ };
+ },
+ },
+ initSelection(el, callback) {
+ const val = el.val();
+
+ callback({
+ id: val,
+ text: val,
+ });
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index 5c96646def8..06ec4546164 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,171 +1,47 @@
-/* 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 IssuableIndex */
-
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+import { __ } from './locale';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
-((global) => {
- var issuable_created;
-
- issuable_created = false;
-
- global.IssuableIndex = {
- init: function(pagePrefix) {
- IssuableIndex.initTemplates();
- IssuableIndex.initSearch();
- IssuableIndex.initBulkUpdate(pagePrefix);
- IssuableIndex.initResetFilters();
- IssuableIndex.resetIncomingEmailToken();
- IssuableIndex.initLabelFilterRemove();
- },
- initTemplates: function() {
- 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');
-
- IssuableIndex.initSearchState($searchInput);
-
- // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing
- const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false);
-
- $searchInput.off('keyup').on('keyup', debouncedExecSearch);
-
- // ensures existing filters are preserved when manually submitted
- $('#issuable_search_form').on('submit', (e) => {
- e.preventDefault();
- debouncedExecSearch(e);
+export default class IssuableIndex {
+ constructor(pagePrefix) {
+ this.initBulkUpdate(pagePrefix);
+ IssuableIndex.resetIncomingEmailToken();
+ }
+ initBulkUpdate(pagePrefix) {
+ const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
+ const alreadyInitialized = !!this.bulkUpdateSidebar;
+
+ if (userCanBulkUpdate && !alreadyInitialized) {
+ IssuableBulkUpdateActions.init({
+ prefixId: pagePrefix,
});
- },
- initSearchState: function($searchInput) {
- const currentSearchVal = $searchInput.val();
-
- IssuableIndex.searchState = {
- elem: $searchInput,
- current: currentSearchVal
- };
-
- IssuableIndex.maybeFocusOnSearch();
- },
- accessSearchPristine: function(set) {
- // store reference to previous value to prevent search on non-mutating keyup
- const state = IssuableIndex.searchState;
- const currentSearchVal = state.elem.val();
- if (set) {
- state.current = currentSearchVal;
- } else {
- return state.current === currentSearchVal;
- }
- },
- maybeFocusOnSearch: function() {
- const currentSearchVal = IssuableIndex.searchState.current;
- if (currentSearchVal && currentSearchVal !== '') {
- const queryLength = currentSearchVal.length;
- const $searchInput = IssuableIndex.searchState.elem;
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
+ }
+ }
- /* The following ensures that the cursor is initially placed at
- * the end of search input when focus is applied. It accounts
- * for differences in browser implementations of `setSelectionRange`
- * and cursor placement for elements in focus.
- */
- $searchInput.focus();
- if ($searchInput.setSelectionRange) {
- $searchInput.setSelectionRange(queryLength, queryLength);
- } else {
- $searchInput.val(currentSearchVal);
- }
- }
- },
- executeSearch: function(e) {
- const $search = $('#issuable_search');
- const $searchName = $search.attr('name');
- const $searchValue = $search.val();
- const $filtersForm = $('.js-filter-form');
- const $input = $(`input[name='${$searchName}']`, $filtersForm);
- const isPristine = IssuableIndex.accessSearchPristine();
+ static resetIncomingEmailToken() {
+ const $resetToken = $('.incoming-email-token-reset');
- if (isPristine) {
- return;
- }
+ $resetToken.on('click', (e) => {
+ e.preventDefault();
- if (!$input.length) {
- $filtersForm.append(`<input type='hidden' name='${$searchName}' value='${_.escape($searchValue)}'/>`);
- } else {
- $input.val($searchValue);
- }
+ $resetToken.text('resetting...');
- IssuableIndex.filterResults($filtersForm);
- },
- initLabelFilterRemove: function() {
- return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) {
- var $button;
- $button = $(this);
- // Remove the label input box
- $('input[name="label_name[]"]').filter(function() {
- return this.value === $button.data('label');
- }).remove();
- // Submit the form to get new data
- IssuableIndex.filterResults($('.filter-form'));
- });
- },
- filterResults: (function(_this) {
- return function(form) {
- var formAction, formData, issuesUrl;
- formData = form.serializeArray();
- formData = formData.filter(function(data) {
- return data.value !== '';
- });
- formData = $.param(formData);
- formAction = form.attr('action');
- issuesUrl = formAction;
- issuesUrl += "" + (formAction.indexOf('?') === -1 ? '?' : '&');
- issuesUrl += formData;
- return gl.utils.visitUrl(issuesUrl);
- };
- })(this),
- initResetFilters: function() {
- $('.reset-filters').on('click', function(e) {
- e.preventDefault();
- const target = e.target;
- const $form = $(target).parents('.js-filter-form');
- const baseIssuesUrl = target.href;
+ axios.put($resetToken.attr('href'))
+ .then(({ data }) => {
+ $('#issuable_email').val(data.new_address).focus();
- $form.attr('action', baseIssuesUrl);
- gl.utils.visitUrl(baseIssuesUrl);
- });
- },
- initBulkUpdate: function(pagePrefix) {
- const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
- const alreadyInitialized = !!this.bulkUpdateSidebar;
+ $resetToken.text('reset it');
+ })
+ .catch(() => {
+ flash(__('There was an error when reseting email token.'));
- if (userCanBulkUpdate && !alreadyInitialized) {
- IssuableBulkUpdateActions.init({
- prefixId: pagePrefix,
+ $resetToken.text('reset it');
});
-
- this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
- }
- },
- resetIncomingEmailToken: function() {
- $('.incoming-email-token-reset').on('click', function(e) {
- e.preventDefault();
-
- $.ajax({
- type: 'PUT',
- url: $('.incoming-email-token-reset').attr('href'),
- dataType: 'json',
- success: function(response) {
- $('#issue_email').val(response.new_issue_address).focus();
- },
- beforeSend: function() {
- $('.incoming-email-token-reset').text('resetting...');
- },
- complete: function() {
- $('.incoming-email-token-reset').text('reset it');
- }
- });
- });
- }
- };
-})(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 0860e237ce1..5113ac6775d 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,26 +1,16 @@
/* 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 'vendor/jquery.waitforimages';
-import '~/lib/utils/text_utility';
-import './flash';
-import './task_list';
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import { addDelimiter } from './lib/utils/text_utility';
+import flash from './flash';
+import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
+import IssuablesHelper from './helpers/issuables_helper';
-class Issue {
+export default class Issue {
constructor() {
- if ($('a.btn-close').length) {
- this.taskList = new gl.TaskList({
- dataType: 'issue',
- fieldName: 'description',
- selector: '.detail-page-description',
- onSuccess: (result) => {
- document.querySelector('#task_status').innerText = result.task_status;
- document.querySelector('#task_status_short').innerText = result.task_status_short;
- }
- });
- this.initIssueBtnEventListeners();
- }
+ if ($('a.btn-close').length) this.initIssueBtnEventListeners();
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
@@ -28,20 +18,65 @@ class Issue {
Issue.initMergeRequests();
Issue.initRelatedBranches();
+ this.closeButtons = $('a.btn-close');
+ this.reopenButtons = $('a.btn-reopen');
+
+ this.initCloseReopenReport();
+
if (Issue.createMrDropdownWrap) {
this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
}
+
+ // Listen to state changes in the Vue app
+ document.addEventListener('issuable_vue_app:change', (event) => {
+ this.updateTopState(event.detail.isClosed, event.detail.data);
+ });
+ }
+
+ /**
+ * This method updates the top area of the issue.
+ *
+ * Once the issue state changes, either through a click on the top area (jquery)
+ * or a click on the bottom area (Vue) we need to update the top area.
+ *
+ * @param {Boolean} isClosed
+ * @param {Array} data
+ * @param {String} issueFailMessage
+ */
+ updateTopState(isClosed, data, issueFailMessage = 'Unable to update this issue at this time.') {
+ if ('id' in data) {
+ const isClosedBadge = $('div.status-box-issue-closed');
+ const isOpenBadge = $('div.status-box-open');
+ const projectIssuesCounter = $('.issue_counter');
+
+ isClosedBadge.toggleClass('hidden', !isClosed);
+ isOpenBadge.toggleClass('hidden', isClosed);
+
+ $(document).trigger('issuable:change', isClosed);
+ this.toggleCloseReopenButton(isClosed);
+
+ let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''));
+ numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
+ projectIssuesCounter.text(addDelimiter(numProjectIssues));
+
+ if (this.createMergeRequestDropdown) {
+ if (isClosed) {
+ this.createMergeRequestDropdown.unavailable();
+ this.createMergeRequestDropdown.disable();
+ } else {
+ // We should check in case a branch was created in another tab
+ this.createMergeRequestDropdown.checkAbilityToCreateBranch();
+ }
+ }
+ } else {
+ flash(issueFailMessage);
+ }
}
initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.';
- const closeButtons = $('a.btn-close');
- const isClosedBadge = $('div.status-box-closed');
- const isOpenBadge = $('div.status-box-open');
- const projectIssuesCounter = $('.issue_counter');
- const reopenButtons = $('a.btn-reopen');
- return closeButtons.add(reopenButtons).on('click', (e) => {
+ return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => {
var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
@@ -50,49 +85,47 @@ class Issue {
if (shouldSubmit) {
Issue.submitNoteForm($button.closest('form'));
}
- $button.prop('disabled', true);
+
+ this.disableCloseReopenButton($button);
+
url = $button.attr('href');
- return $.ajax({
- type: 'PUT',
- url: url
+ return axios.put(url)
+ .then(({ data }) => {
+ const isClosed = $button.hasClass('btn-close');
+ this.updateTopState(isClosed, data);
})
- .fail(() => new Flash(issueFailMessage))
- .done((data) => {
- if ('id' in data) {
- $(document).trigger('issuable:change');
-
- const isClosed = $button.hasClass('btn-close');
- closeButtons.toggleClass('hidden', isClosed);
- reopenButtons.toggleClass('hidden', !isClosed);
- isClosedBadge.toggleClass('hidden', !isClosed);
- isOpenBadge.toggleClass('hidden', isClosed);
-
- let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
- numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
- projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
-
- if (this.createMergeRequestDropdown) {
- if (isClosed) {
- this.createMergeRequestDropdown.unavailable();
- this.createMergeRequestDropdown.disable();
- } else {
- // We should check in case a branch was created in another tab
- this.createMergeRequestDropdown.checkAbilityToCreateBranch();
- }
- }
- } else {
- new Flash(issueFailMessage);
- }
-
- $button.prop('disabled', false);
+ .catch(() => flash(issueFailMessage))
+ .then(() => {
+ this.disableCloseReopenButton($button, false);
});
});
}
+ initCloseReopenReport() {
+ this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
+
+ if (this.closeButtons) this.closeButtons = this.closeButtons.not('.issuable-close-button');
+ if (this.reopenButtons) this.reopenButtons = this.reopenButtons.not('.issuable-close-button');
+ }
+
+ disableCloseReopenButton($button, shouldDisable) {
+ if (this.closeReopenReportToggle) {
+ this.closeReopenReportToggle.setDisable(shouldDisable);
+ } else {
+ $button.prop('disabled', shouldDisable);
+ }
+ }
+
+ toggleCloseReopenButton(isClosed) {
+ if (this.closeReopenReportToggle) this.closeReopenReportToggle.updateButton(isClosed);
+ this.closeButtons.toggleClass('hidden', isClosed);
+ this.reopenButtons.toggleClass('hidden', !isClosed);
+ }
+
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
- if (noteText.trim().length > 0) {
+ if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}
@@ -100,26 +133,22 @@ class Issue {
static initMergeRequests() {
var $container;
$container = $('#merge-requests');
- return $.getJSON($container.data('url')).fail(function() {
- return new Flash('Failed to load referenced merge requests');
- }).done(function(data) {
- if ('html' in data) {
- return $container.html(data.html);
- }
- });
+ return axios.get($container.data('url'))
+ .then(({ data }) => {
+ if ('html' in data) {
+ $container.html(data.html);
+ }
+ }).catch(() => flash('Failed to load referenced merge requests'));
}
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
- return $.getJSON($container.data('url')).fail(function() {
- return new Flash('Failed to load related branches');
- }).done(function(data) {
- if ('html' in data) {
- return $container.html(data.html);
- }
- });
+ return axios.get($container.data('url'))
+ .then(({ data }) => {
+ if ('html' in data) {
+ $container.html(data.html);
+ }
+ }).catch(() => flash('Failed to load related branches'));
}
}
-
-export default Issue;
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 3d5fb7f441c..e87a8ed7fea 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,273 +1,324 @@
<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';
+ import Visibility from 'visibilityjs';
+ import { visitUrl } from '../../lib/utils/url_utility';
+ 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 recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
-export default {
- props: {
- endpoint: {
- required: true,
- type: String,
+ export default {
+ components: {
+ descriptionComponent,
+ titleComponent,
+ editedComponent,
+ formComponent,
},
- 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: '',
- },
- initialTaskStatus: {
- 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,
+ mixins: [
+ recaptchaModalImplementor,
+ ],
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ updateEndpoint: {
+ required: true,
+ type: String,
+ },
+ canUpdate: {
+ required: true,
+ type: Boolean,
+ },
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ 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: '',
+ },
+ initialTaskStatus: {
+ 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: () => [],
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: 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,
- taskStatus: this.initialTaskStatus,
- });
+ 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,
+ taskStatus: this.initialTaskStatus,
+ });
- return {
- store,
- state: store.state,
- showForm: false,
- };
- },
- computed: {
- formState() {
- return this.store.formState;
+ return {
+ store,
+ state: store.state,
+ showForm: false,
+ };
},
- hasUpdated() {
- return !!this.state.updatedAt;
+ computed: {
+ formState() {
+ return this.store.formState;
+ },
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
+ issueChanged() {
+ const descriptionChanged =
+ this.initialDescriptionText !== this.store.formState.description;
+ const titleChanged =
+ this.initialTitleText !== this.store.formState.title;
+ return descriptionChanged || titleChanged;
+ },
},
- },
- 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,
- });
+ created() {
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getData',
+ successCallback: res => this.store.updateState(res.data),
+ errorCallback(err) {
+ throw new Error(err);
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
}
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ window.addEventListener('beforeunload', this.handleBeforeUnloadEvent);
+
+ eventHub.$on('delete.issuable', this.deleteIssuable);
+ eventHub.$on('update.issuable', this.updateIssuable);
+ eventHub.$on('close.form', this.closeForm);
+ eventHub.$on('open.form', this.openForm);
},
- closeForm() {
- this.showForm = false;
+ 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);
+ window.removeEventListener('beforeunload', this.handleBeforeUnloadEvent);
},
- 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
+ methods: {
+ handleBeforeUnloadEvent(e) {
+ const event = e;
+ if (this.showForm && this.issueChanged) {
+ event.returnValue = 'Are you sure you want to lose your issue information?';
+ }
+ return undefined;
+ },
- if (!canPostUpdate) {
- this.store.setFormState({
- updateLoading: false,
- });
- return;
- }
+ openForm() {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ description: this.state.descriptionText,
+ lockedWarningVisible: false,
+ updateLoading: false,
+ });
+ }
+ },
+ closeForm() {
+ this.showForm = false;
+ },
- 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);
- }
+ updateIssuable() {
+ return this.service.updateIssuable(this.store.formState)
+ .then(res => res.data)
+ .then(data => this.checkForSpam(data))
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ visitUrl(data.web_url);
+ }
- 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();
+ return this.service.getData();
+ })
+ .then(res => res.data)
+ .then((data) => {
+ this.store.updateState(data);
+ eventHub.$emit('close.form');
+ })
+ .catch((error) => {
+ if (error && error.name === 'SpamError') {
+ this.openRecaptcha();
+ } else {
+ eventHub.$emit('close.form');
+ window.Flash(`Error updating ${this.issuableType}`);
+ }
+ });
+ },
- gl.utils.visitUrl(data.web_url);
- })
- .catch(() => {
- eventHub.$emit('close.form');
- return new Flash('Error deleting issue');
+ closeRecaptchaModal() {
+ this.store.setFormState({
+ updateLoading: false,
});
- },
- },
- created() {
- this.service = new Service(this.endpoint);
- this.poll = new Poll({
- resource: this.service,
- method: 'getData',
- successCallback: (res) => {
- const data = res.json();
- this.store.updateState(data);
- },
- errorCallback(err) {
- throw new Error(err);
- },
- });
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
- }
+ this.closeRecaptcha();
+ },
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then(res => res.data)
+ .then((data) => {
+ // Stop the poll so we don't get 404's with the issuable not existing
+ 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);
- },
-};
+ visitUrl(data.web_url);
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ window.Flash(`Error deleting ${this.issuableType}`);
+ });
+ },
+ },
+ };
</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-if="canUpdate && showForm">
+ <form-component
+ :form-state="formState"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs-path="markdownDocsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace"
+ :show-delete-button="showDeleteButton"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ />
+
+ <recaptcha-modal
+ v-show="showRecaptcha"
+ :html="recaptchaHTML"
+ @close="closeRecaptchaModal"
+ />
+ </div>
<div v-else>
<title-component
:issuable-ref="issuableRef"
+ :can-update="canUpdate"
:title-html="state.titleHtml"
- :title-text="state.titleText" />
+ :title-text="state.titleText"
+ :show-inline-edit-button="showInlineEditButton"
+ />
<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" />
+ :task-status="state.taskStatus"
+ :issuable-type="issuableType"
+ :update-url="updateEndpoint"
+ />
<edited-component
v-if="hasUpdated"
:updated-at="state.updatedAt"
:updated-by-name="state.updatedByName"
- :updated-by-path="state.updatedByPath" />
+ :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
index 43db66c8e08..ae577e04a56 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -1,8 +1,15 @@
<script>
+ import $ from 'jquery';
import animateMixin from '../mixins/animate';
+ import TaskList from '../../task_list';
+ import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
export default {
- mixins: [animateMixin],
+ mixins: [
+ animateMixin,
+ recaptchaModalImplementor,
+ ],
+
props: {
canUpdate: {
type: Boolean,
@@ -21,6 +28,16 @@
required: false,
default: '',
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ updateUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -40,19 +57,34 @@
this.updateTaskStatusText();
},
},
+ mounted() {
+ this.renderGFM();
+ this.updateTaskStatusText();
+ },
methods: {
renderGFM() {
$(this.$refs['gfm-content']).renderGFM();
if (this.canUpdate) {
// eslint-disable-next-line no-new
- new gl.TaskList({
- dataType: 'issue',
+ new TaskList({
+ dataType: this.issuableType,
fieldName: 'description',
selector: '.detail-page-description',
+ onSuccess: this.taskListUpdateSuccess.bind(this),
});
}
},
+
+ taskListUpdateSuccess(data) {
+ try {
+ this.checkForSpam(data);
+ this.closeRecaptcha();
+ } catch (error) {
+ if (error && error.name === 'SpamError') this.openRecaptcha();
+ }
+ },
+
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
@@ -61,17 +93,17 @@
if (taskRegexMatches) {
$tasks.text(this.taskStatus);
- $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`);
+ $tasksShort.text(
+ `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ?
+ 's' :
+ ''}`,
+ );
} else {
$tasks.text('');
$tasksShort.text('');
}
},
},
- mounted() {
- this.renderGFM();
- this.updateTaskStatusText();
- },
};
</script>
@@ -81,7 +113,8 @@
class="description"
:class="{
'js-task-list-container': canUpdate
- }">
+ }"
+ >
<div
class="wiki"
:class="{
@@ -94,7 +127,15 @@
<textarea
class="hidden js-task-list-field"
v-if="descriptionText"
- v-model="descriptionText">
+ v-model="descriptionText"
+ :data-update-url="updateUrl"
+ >
</textarea>
+
+ <recaptcha-modal
+ v-show="showRecaptcha"
+ :html="recaptchaHTML"
+ @close="closeRecaptcha"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index 8c81575fe6f..a539506bce2 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -13,6 +13,11 @@
type: Object,
required: true,
},
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -23,6 +28,9 @@
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
+ shouldShowDeleteButton() {
+ return this.canDestroy && this.showDeleteButton;
+ },
},
methods: {
closeForm() {
@@ -62,7 +70,7 @@
Cancel
</button>
<button
- v-if="canDestroy"
+ v-if="shouldShowDeleteButton"
class="btn btn-danger pull-right append-right-default"
:class="{ disabled: deleteLoading }"
type="button"
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
index d59e6d11032..01097b5b35e 100644
--- a/app/assets/javascripts/issue_show/components/edited.vue
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -1,33 +1,33 @@
<script>
-import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+ import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
-export default {
- props: {
- updatedAt: {
- type: String,
- required: false,
- default: '',
+ export default {
+ components: {
+ timeAgoTooltip,
},
- updatedByName: {
- type: String,
- required: false,
- default: '',
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
- updatedByPath: {
- type: String,
- required: false,
- default: '',
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
},
- },
- components: {
- timeAgoTooltip,
- },
- computed: {
- hasUpdatedBy() {
- return this.updatedByName && this.updatedByPath;
- },
- },
-};
+ };
</script>
<template>
@@ -37,7 +37,7 @@ export default {
Edited
<time-ago-tooltip
v-if="updatedAt"
- placement="bottom"
+ tooltip-placement="bottom"
:time="updatedAt"
/>
<span
@@ -48,7 +48,7 @@ export default {
class="author_link"
:href="updatedByPath"
>
- <span>{{updatedByName}}</span>
+ <span>{{ updatedByName }}</span>
</a>
</span>
</small>
diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
deleted file mode 100644
index a0ff08e9111..00000000000
--- a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-<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
index 27b1b814f9a..d9fa2764d65 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -1,26 +1,35 @@
<script>
- /* global Flash */
import updateMixin from '../../mixins/update';
import markdownField from '../../../vue_shared/components/markdown/field.vue';
export default {
+ components: {
+ markdownField,
+ },
mixins: [updateMixin],
props: {
formState: {
type: Object,
required: true,
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
- },
- components: {
- markdownField,
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
mounted() {
this.$refs.textarea.focus();
@@ -36,12 +45,15 @@
Description
</label>
<markdown-field
- :markdown-preview-url="markdownPreviewUrl"
- :markdown-docs="markdownDocs">
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ >
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
- data-supports-quick-actionss="false"
+ data-supports-quick-actions="false"
aria-label="Description"
v-model="formState.description"
ref="textarea"
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index 1c40b286513..7db0488e306 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -1,4 +1,7 @@
<script>
+ import $ from 'jquery';
+ import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
+
export default {
props: {
formState: {
@@ -32,7 +35,7 @@
};
editor.getValue = () => this.formState.description;
- this.issuableTemplate = new gl.IssuableTemplateSelectors({
+ this.issuableTemplate = new IssuableTemplateSelectors({
$dropdowns: $(this.$refs.toggle),
editor,
});
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
deleted file mode 100644
index 7bf2be8b28a..00000000000
--- a/app/assets/javascripts/issue_show/components/fields/project_move.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-<script>
- import tooltip from '../../../vue_shared/directives/tooltip';
-
- export default {
- directives: {
- tooltip,
- },
- 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
- v-tooltip
- 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.">
- <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
index 83af8e1e245..c3abb9fd9d5 100644
--- a/app/assets/javascripts/issue_show/components/fields/title.vue
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -16,15 +16,15 @@
<fieldset>
<label
class="sr-only"
- for="issue-title">
+ for="issuable-title">
Title
</label>
<input
- id="issue-title"
+ id="issuable-title"
class="form-control"
type="text"
- placeholder="Issue title"
- aria-label="Issue title"
+ placeholder="Title"
+ aria-label="Title"
v-model="formState.title"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable" />
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 76ec3dc9a5d..779705e19ac 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -4,15 +4,16 @@
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 {
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ },
props: {
- canMove: {
- type: Boolean,
- required: true,
- },
canDestroy: {
type: Boolean,
required: true,
@@ -26,11 +27,11 @@
required: false,
default: () => [],
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -42,19 +43,21 @@
type: String,
required: true,
},
- projectsAutocompleteUrl: {
- type: String,
- required: true,
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
},
- },
- components: {
- lockedWarning,
- titleField,
- descriptionField,
- descriptionTemplate,
- editActions,
- projectMove,
- confidentialCheckbox,
},
computed: {
hasIssuableTemplates() {
@@ -75,30 +78,32 @@
:form-state="formState"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
- :project-namespace="projectNamespace" />
+ :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" />
+ :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" />
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ />
<edit-actions
:form-state="formState"
- :can-destroy="canDestroy" />
+ :can-destroy="canDestroy"
+ :show-delete-button="showDeleteButton"
+ />
</form>
</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index a9dabd4cff1..aec890a2ff6 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -1,20 +1,24 @@
<script>
import animateMixin from '../mixins/animate';
+ import eventHub from '../event_hub';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { spriteIcon } from '../../lib/utils/common_utils';
export default {
- mixins: [animateMixin],
- data() {
- return {
- preAnimation: false,
- pulseAnimation: false,
- titleEl: document.querySelector('title'),
- };
+ directives: {
+ tooltip,
},
+ mixins: [animateMixin],
props: {
issuableRef: {
type: String,
required: true,
},
+ canUpdate: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
titleHtml: {
type: String,
required: true,
@@ -23,6 +27,23 @@
type: String,
required: true,
},
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ titleEl: document.querySelector('title'),
+ };
+ },
+ computed: {
+ pencilIcon() {
+ return spriteIcon('pencil', 'link-highlight');
+ },
},
watch: {
titleHtml() {
@@ -36,18 +57,35 @@
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
+ edit() {
+ eventHub.$emit('open.form');
+ },
},
};
</script>
<template>
- <h2
- class="title"
- :class="{
- 'issue-realtime-pre-pulse': preAnimation,
- 'issue-realtime-trigger-pulse': pulseAnimation
- }"
- v-html="titleHtml"
- >
- </h2>
+ <div class="title-container">
+ <h2
+ class="title"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="titleHtml"
+ >
+ </h2>
+ <button
+ v-tooltip
+ v-if="showInlineEditButton && canUpdate"
+ type="button"
+ class="btn btn-default btn-edit btn-svg js-issuable-edit"
+ v-html="pencilIcon"
+ title="Edit title and description"
+ data-placement="bottom"
+ data-container="body"
+ @click="edit"
+ >
+ </button>
+ </div>
</template>
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index ad8cb6465e2..75dfdedcf1b 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,52 +1,19 @@
import Vue from 'vue';
-import eventHub from './event_hub';
import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
document.addEventListener('DOMContentLoaded', () => {
const initialDataEl = document.getElementById('js-issuable-app-initial-data');
- const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
-
- $('.issuable-edit').on('click', (e) => {
- e.preventDefault();
-
- eventHub.$emit('open.form');
- });
+ const props = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
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,
- initialTaskStatus: this.initialTaskStatus,
- },
+ props,
});
},
});
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 6f0fd0b1768..9546eb22c27 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,29 +1,20 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class Service {
constructor(endpoint) {
- this.endpoint = endpoint;
-
- this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
- realtimeChanges: {
- method: 'GET',
- url: `${this.endpoint}/realtime_changes`,
- },
- });
+ this.endpoint = `${endpoint}.json`;
+ this.realtimeEndpoint = `${endpoint}/realtime_changes`;
}
getData() {
- return this.resource.realtimeChanges();
+ return axios.get(this.realtimeEndpoint);
}
deleteIssuable() {
- return this.resource.delete();
+ return axios.delete(this.endpoint);
}
updateIssuable(data) {
- return this.resource.update(data);
+ return axios.put(this.endpoint, data);
}
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 0c8bd6f1cc3..af8b0414266 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -3,10 +3,8 @@ export default class Store {
this.state = initialState;
this.formState = {
title: '',
- confidential: false,
description: '',
lockedWarningVisible: false,
- move_to_project_id: 0,
updateLoading: false,
};
}
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index 56cb536dcde..c14803c80e7 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -1,34 +1,25 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, object-shorthand, no-unused-vars, no-shadow, one-var, one-var-declaration-per-line, comma-dangle, max-len */
-(function() {
- this.IssueStatusSelect = (function() {
- function IssueStatusSelect() {
- $('.js-issue-status').each(function(i, el) {
- var fieldName;
- fieldName = $(el).data("field-name");
- return $(el).glDropdown({
- selectable: true,
- fieldName: fieldName,
- toggleLabel: (function(_this) {
- return function(selected, el, instance) {
- var $item, label;
- label = 'Author';
- $item = instance.dropdown.find('.is-active');
- if ($item.length) {
- label = $item.text();
- }
- return label;
- };
- })(this),
- clicked: function(options) {
- return options.e.preventDefault();
- },
- id: function(obj, el) {
- return $(el).data("id");
- }
- });
- });
- }
+import $ from 'jquery';
- return IssueStatusSelect;
- })();
-}).call(window);
+export default function issueStatusSelect() {
+ $('.js-issue-status').each((i, el) => {
+ const fieldName = $(el).data('fieldName');
+ return $(el).glDropdown({
+ selectable: true,
+ fieldName,
+ toggleLabel(selected, element, instance) {
+ let label = 'Author';
+ const $item = instance.dropdown.find('.is-active');
+ if ($item.length) {
+ label = $item.text();
+ }
+ return label;
+ },
+ clicked(options) {
+ return options.e.preventDefault();
+ },
+ id(obj, element) {
+ return $(element).data('id');
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js
new file mode 100644
index 00000000000..ace45e9dd29
--- /dev/null
+++ b/app/assets/javascripts/job.js
@@ -0,0 +1,293 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import { visitUrl } from './lib/utils/url_utility';
+import bp from './breakpoints';
+import { numberToHumanSize } from './lib/utils/number_utils';
+import { setCiStatusFavicon } from './lib/utils/common_utils';
+
+export default class Job {
+ constructor(options) {
+ this.timeout = null;
+ this.state = null;
+ this.fetchingStatusFavicon = false;
+ this.options = options || $('.js-build-options').data();
+
+ this.pagePath = this.options.pagePath;
+ this.buildStatus = this.options.buildStatus;
+ this.state = this.options.logState;
+ this.buildStage = this.options.buildStage;
+ this.$document = $(document);
+ this.$window = $(window);
+ this.logBytes = 0;
+ this.updateDropdown = this.updateDropdown.bind(this);
+
+ this.$buildTrace = $('#build-trace');
+ this.$buildRefreshAnimation = $('.js-build-refresh');
+ this.$truncatedInfo = $('.js-truncated-info');
+ this.$buildTraceOutput = $('.js-build-output');
+ this.$topBar = $('.js-top-bar');
+
+ // Scroll controllers
+ this.$scrollTopBtn = $('.js-scroll-up');
+ this.$scrollBottomBtn = $('.js-scroll-down');
+
+ clearTimeout(this.timeout);
+
+ this.initSidebar();
+ this.populateJobs(this.buildStage);
+ this.updateStageDropdownText(this.buildStage);
+ this.sidebarOnResize();
+
+ this.$document
+ .off('click', '.js-sidebar-build-toggle')
+ .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
+
+ this.$document
+ .off('click', '.stage-item')
+ .on('click', '.stage-item', this.updateDropdown);
+
+ // 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));
+
+ this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
+
+ this.$window
+ .off('scroll')
+ .on('scroll', () => {
+ if (!this.isScrolledToBottom()) {
+ this.toggleScrollAnimation(false);
+ } else if (this.isScrolledToBottom() && !this.isLogComplete) {
+ this.toggleScrollAnimation(true);
+ }
+ this.scrollThrottled();
+ });
+
+ this.$window
+ .off('resize.build')
+ .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
+
+ this.initAffixTopArea();
+
+ this.getBuildTrace();
+ }
+
+ initAffixTopArea() {
+ /**
+ 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 (this.$topBar.css('position') !== 'static') return;
+
+ const offsetTop = this.$buildTrace.offset().top;
+
+ this.$topBar.affix({
+ offset: {
+ top: offsetTop,
+ },
+ });
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ canScroll() {
+ return $(document).height() > $(window).height();
+ }
+
+ toggleScroll() {
+ const $document = $(document);
+ const currentPosition = $document.scrollTop();
+ const scrollHeight = $document.height();
+
+ const windowHeight = $(window).height();
+ if (this.canScroll()) {
+ if (currentPosition > 0 &&
+ (scrollHeight - currentPosition !== windowHeight)) {
+ // User is in the middle of the log
+
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (currentPosition === 0) {
+ // User is at Top of Log
+
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (this.isScrolledToBottom()) {
+ // User is at the bottom of the build log.
+
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ }
+ } else {
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ }
+ }
+ // eslint-disable-next-line class-methods-use-this
+ isScrolledToBottom() {
+ const $document = $(document);
+
+ const currentPosition = $document.scrollTop();
+ const scrollHeight = $document.height();
+
+ const windowHeight = $(window).height();
+
+ return scrollHeight - currentPosition === windowHeight;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ scrollDown() {
+ const $document = $(document);
+ $document.scrollTop($document.height());
+ }
+
+ scrollToBottom() {
+ this.scrollDown();
+ this.hasBeenScrolled = true;
+ this.toggleScroll();
+ }
+
+ scrollToTop() {
+ $(document).scrollTop(0);
+ this.hasBeenScrolled = true;
+ this.toggleScroll();
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ toggleDisableButton($button, disable) {
+ if (disable && $button.prop('disabled')) return;
+ $button.prop('disabled', disable);
+ }
+
+ toggleScrollAnimation(toggle) {
+ this.$scrollBottomBtn.toggleClass('animate', toggle);
+ }
+
+ initSidebar() {
+ this.$sidebar = $('.js-build-sidebar');
+ }
+
+ getBuildTrace() {
+ return axios.get(`${this.pagePath}/trace.json`, {
+ params: { state: this.state },
+ })
+ .then((res) => {
+ const log = res.data;
+
+ if (!this.fetchingStatusFavicon) {
+ this.fetchingStatusFavicon = true;
+
+ setCiStatusFavicon(`${this.pagePath}/status.json`)
+ .then(() => {
+ this.fetchingStatusFavicon = false;
+ })
+ .catch(() => {
+ this.fetchingStatusFavicon = false;
+ });
+ }
+
+ if (log.state) {
+ this.state = log.state;
+ }
+
+ this.isScrollInBottom = this.isScrolledToBottom();
+
+ if (log.append) {
+ this.$buildTraceOutput.append(log.html);
+ this.logBytes += log.size;
+ } else {
+ this.$buildTraceOutput.html(log.html);
+ this.logBytes = log.size;
+ }
+
+ // if the incremental sum of logBytes we received is less than the total
+ // we need to show a message warning the user about that.
+ if (this.logBytes < log.total) {
+ // size is in bytes, we need to calculate KiB
+ const size = numberToHumanSize(this.logBytes);
+ $('.js-truncated-info-size').html(`${size}`);
+ this.$truncatedInfo.removeClass('hidden');
+ } else {
+ this.$truncatedInfo.addClass('hidden');
+ }
+ this.isLogComplete = log.complete;
+
+ if (log.complete === false) {
+ this.timeout = setTimeout(() => {
+ this.getBuildTrace();
+ }, 4000);
+ } else {
+ this.$buildRefreshAnimation.remove();
+ this.toggleScrollAnimation(false);
+ }
+
+ if (log.status !== this.buildStatus) {
+ visitUrl(this.pagePath);
+ }
+ })
+ .catch(() => {
+ this.$buildRefreshAnimation.remove();
+ })
+ .then(() => {
+ if (this.isScrollInBottom) {
+ this.scrollDown();
+ }
+ })
+ .then(() => this.toggleScroll());
+ }
+ // eslint-disable-next-line class-methods-use-this
+ shouldHideSidebarForViewport() {
+ const bootstrapBreakpoint = bp.getBreakpointSize();
+ return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
+ }
+
+ toggleSidebar(shouldHide) {
+ const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ const $toggleButton = $('.js-sidebar-build-toggle-header');
+
+ this.$sidebar
+ .toggleClass('right-sidebar-expanded', shouldShow)
+ .toggleClass('right-sidebar-collapsed', shouldHide);
+
+ this.$topBar
+ .toggleClass('sidebar-expanded', shouldShow)
+ .toggleClass('sidebar-collapsed', shouldHide);
+
+ if (this.$sidebar.hasClass('right-sidebar-expanded')) {
+ $toggleButton.addClass('hidden');
+ } else {
+ $toggleButton.removeClass('hidden');
+ }
+ }
+
+ sidebarOnResize() {
+ this.toggleSidebar(this.shouldHideSidebarForViewport());
+ }
+
+ sidebarOnClick() {
+ if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ populateJobs(stage) {
+ $('.build-job').hide();
+ $(`.build-job[data-stage="${stage}"]`).show();
+ }
+ // eslint-disable-next-line class-methods-use-this
+ updateStageDropdownText(stage) {
+ $('.stage-selection').text(stage);
+ }
+
+ updateDropdown(e) {
+ e.preventDefault();
+ const stage = e.currentTarget.text;
+ this.updateStageDropdownText(stage);
+ this.populateJobs(stage);
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
index 5b9cf577189..357bc9aab17 100644
--- a/app/assets/javascripts/jobs/components/header.vue
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -3,7 +3,11 @@
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
- name: 'jobHeaderSection',
+ name: 'JobHeaderSection',
+ components: {
+ ciHeader,
+ loadingIcon,
+ },
props: {
job: {
type: Object,
@@ -14,10 +18,6 @@
required: true,
},
},
- components: {
- ciHeader,
- loadingIcon,
- },
data() {
return {
actions: this.getActions(),
@@ -30,6 +30,18 @@
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length;
},
+ /**
+ * When job has not started the key will be `false`
+ * When job started the key will be a string with a date.
+ */
+ jobStarted() {
+ return !this.job.started === false;
+ },
+ },
+ watch: {
+ job() {
+ this.actions = this.getActions();
+ },
},
methods: {
getActions() {
@@ -40,27 +52,12 @@
label: 'New issue',
path: this.job.new_issue_path,
cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
- type: 'ujs-link',
- });
- }
-
- if (this.job.retry_path) {
- actions.push({
- label: 'Retry',
- path: this.job.retry_path,
- cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block',
- type: 'ujs-link',
+ type: 'link',
});
}
-
return actions;
},
},
- watch: {
- job() {
- this.actions = this.getActions();
- },
- },
};
</script>
<template>
@@ -73,11 +70,13 @@
:time="job.created_at"
:user="job.user"
:actions="actions"
- :hasSidebarButton="true"
- />
+ :has-sidebar-button="true"
+ :should-render-triggered-label="jobStarted"
+ />
<loading-icon
v-if="isLoading"
size="2"
- />
+ class="prepend-top-default append-bottom-default"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
index ab2bcd728a8..a6819aaeb12 100644
--- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
@@ -23,9 +23,10 @@
<p class="build-detail-row">
<span
v-if="hasTitle"
- class="build-light-text">
- {{title}}:
+ class="build-light-text"
+ >
+ {{ title }}:
</span>
- {{value}}
+ {{ value }}
</p>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
index 4223a8fea49..56814a52525 100644
--- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
@@ -6,6 +6,13 @@
export default {
name: 'SidebarDetailsBlock',
+ components: {
+ detailRow,
+ loadingIcon,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
props: {
job: {
type: Object,
@@ -16,13 +23,6 @@
required: true,
},
},
- mixins: [
- timeagoMixin,
- ],
- components: {
- detailRow,
- loadingIcon,
- },
computed: {
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length > 0;
@@ -39,6 +39,17 @@
runnerId() {
return `#${this.job.runner.id}`;
},
+ renderBlock() {
+ return this.job.merge_request ||
+ this.job.duration ||
+ this.job.finished_data ||
+ this.job.erased_at ||
+ this.job.queued ||
+ this.job.runner ||
+ this.job.coverage ||
+ this.job.tags.length ||
+ this.job.cancel_path;
+ },
},
};
</script>
@@ -47,11 +58,13 @@
<template v-if="shouldRenderContent">
<div
class="block retry-link"
- v-if="job.retry_path || job.new_issue_path">
+ v-if="job.retry_path || job.new_issue_path"
+ >
<a
v-if="job.new_issue_path"
class="js-new-issue btn btn-new btn-inverted"
- :href="job.new_issue_path">
+ :href="job.new_issue_path"
+ >
New issue
</a>
<a
@@ -59,20 +72,21 @@
class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path"
data-method="post"
- rel="nofollow">
+ rel="nofollow"
+ >
Retry
</a>
</div>
- <div class="block">
+ <div :class="{block : renderBlock }">
<p
class="build-detail-row js-job-mr"
- v-if="job.merge_request">
- <span
- class="build-light-text">
+ v-if="job.merge_request"
+ >
+ <span class="build-light-text">
Merge Request:
</span>
<a :href="job.merge_request.path">
- !{{job.merge_request.iid}}
+ !{{ job.merge_request.iid }}
</a>
</p>
@@ -81,49 +95,49 @@
v-if="job.duration"
title="Duration"
:value="duration"
- />
+ />
<detail-row
class="js-job-finished"
v-if="job.finished_at"
title="Finished"
:value="timeFormated(job.finished_at)"
- />
+ />
<detail-row
class="js-job-erased"
v-if="job.erased_at"
title="Erased"
:value="timeFormated(job.erased_at)"
- />
+ />
<detail-row
class="js-job-queued"
v-if="job.queued"
title="Queued"
:value="queued"
- />
+ />
<detail-row
class="js-job-runner"
v-if="job.runner"
title="Runner"
:value="runnerId"
- />
+ />
<detail-row
class="js-job-coverage"
v-if="job.coverage"
title="Coverage"
:value="coverage"
- />
+ />
<p
class="build-detail-row js-job-tags"
- v-if="job.tags.length">
- <span
- class="build-light-text">
+ v-if="job.tags.length"
+ >
+ <span class="build-light-text">
Tags:
</span>
<span
- v-for="tag in job.tags"
- key="tag"
+ v-for="(tag, i) in job.tags"
+ :key="i"
class="label label-primary">
- {{tag}}
+ {{ tag }}
</span>
</p>
@@ -135,7 +149,8 @@
class="js-cancel-job btn btn-sm btn-default"
:href="job.cancel_path"
data-method="post"
- rel="nofollow">
+ rel="nofollow"
+ >
Cancel
</a>
</div>
@@ -145,6 +160,6 @@
class="prepend-top-10"
v-if="isLoading"
size="2"
- />
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js
index 939d17129de..85a88ae409b 100644
--- a/app/assets/javascripts/jobs/job_details_bundle.js
+++ b/app/assets/javascripts/jobs/job_details_bundle.js
@@ -1,11 +1,9 @@
-/* global Flash */
-
import Vue from 'vue';
import JobMediator from './job_details_mediator';
import jobHeader from './components/header.vue';
import detailsBlock from './components/sidebar_details_block.vue';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const dataset = document.getElementById('js-job-details-vue').dataset;
const mediator = new JobMediator({ endpoint: dataset.endpoint });
@@ -15,25 +13,17 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-build-header-vue',
+ components: {
+ jobHeader,
+ },
data() {
return {
mediator,
};
},
- components: {
- jobHeader,
- },
mounted() {
this.mediator.initBuildClass();
},
- updated() {
- // Wait for flash message to be appended
- Vue.nextTick(() => {
- if (this.mediator.build) {
- this.mediator.build.verifyTopPosition();
- }
- });
- },
render(createElement) {
return createElement('job-header', {
props: {
@@ -48,14 +38,14 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line
new Vue({
el: '#js-details-block-vue',
+ components: {
+ detailsBlock,
+ },
data() {
return {
mediator,
};
},
- components: {
- detailsBlock,
- },
render(createElement) {
return createElement('details-block', {
props: {
@@ -65,4 +55,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+};
diff --git a/app/assets/javascripts/jobs/job_details_mediator.js b/app/assets/javascripts/jobs/job_details_mediator.js
index 063c52fac74..5a216f8fae2 100644
--- a/app/assets/javascripts/jobs/job_details_mediator.js
+++ b/app/assets/javascripts/jobs/job_details_mediator.js
@@ -1,11 +1,12 @@
-/* global Flash */
/* global Build */
import Visibility from 'visibilityjs';
+import Flash from '../flash';
import Poll from '../lib/utils/poll';
import JobStore from './stores/job_store';
import JobService from './services/job_service';
-import '../build';
+import Job from '../job';
+import handleRevealVariables from '../build_variables';
export default class JobMediator {
constructor(options = {}) {
@@ -20,15 +21,16 @@ export default class JobMediator {
}
initBuildClass() {
- this.build = new Build();
+ this.build = new Job();
+ handleRevealVariables();
}
fetchJob() {
this.poll = new Poll({
resource: this.service,
method: 'getJob',
- successCallback: this.successCallback.bind(this),
- errorCallback: this.errorCallback.bind(this),
+ successCallback: response => this.successCallback(response),
+ errorCallback: () => this.errorCallback(),
});
if (!Visibility.hidden()) {
@@ -54,9 +56,8 @@ export default class JobMediator {
}
successCallback(response) {
- const data = response.json();
this.state.isLoading = false;
- this.store.storeJob(data);
+ return this.store.storeJob(response.data);
}
errorCallback() {
diff --git a/app/assets/javascripts/jobs/services/job_service.js b/app/assets/javascripts/jobs/services/job_service.js
index eaf1c6e500a..b746489c45c 100644
--- a/app/assets/javascripts/jobs/services/job_service.js
+++ b/app/assets/javascripts/jobs/services/job_service.js
@@ -1,14 +1,11 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class JobService {
constructor(endpoint) {
- this.job = Vue.resource(endpoint);
+ this.job = endpoint;
}
getJob() {
- return this.job.get();
+ return axios.get(this.job);
}
}
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index d8814802d9e..e230dbbd4ac 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -1,124 +1,119 @@
/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */
-/* global Flash */
-/* global Sortable */
-((global) => {
- class LabelManager {
- constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
- this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority');
- this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels');
- this.otherLabels = otherLabels || $('.js-other-labels');
- this.errorMessage = 'Unable to update label prioritization at this time';
- this.emptyState = document.querySelector('#js-priority-labels-empty-state');
- this.sortable = Sortable.create(this.prioritizedLabels.get(0), {
- filter: '.empty-message',
- forceFallback: true,
- fallbackClass: 'is-dragging',
- dataIdAttr: 'data-id',
- onUpdate: this.onPrioritySortUpdate.bind(this),
- });
- this.bindEvents();
- }
+import $ from 'jquery';
+import Sortable from 'vendor/Sortable';
- bindEvents() {
- this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick);
- return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
- }
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
- onTogglePriorityClick(e) {
- e.preventDefault();
- const _this = e.data;
- const $btn = $(e.currentTarget);
- const $label = $(`#${$btn.data('domId')}`);
- const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add';
- const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`);
- $tooltip.tooltip('destroy');
- _this.toggleLabelPriority($label, action);
- _this.toggleEmptyState($label, $btn, action);
- }
+export default class LabelManager {
+ constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
+ this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority');
+ this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels');
+ this.otherLabels = otherLabels || $('.js-other-labels');
+ this.errorMessage = 'Unable to update label prioritization at this time';
+ this.emptyState = document.querySelector('#js-priority-labels-empty-state');
+ this.sortable = Sortable.create(this.prioritizedLabels.get(0), {
+ filter: '.empty-message',
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ dataIdAttr: 'data-id',
+ onUpdate: this.onPrioritySortUpdate.bind(this),
+ });
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick);
+ return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
+ }
+
+ onTogglePriorityClick(e) {
+ e.preventDefault();
+ const _this = e.data;
+ const $btn = $(e.currentTarget);
+ const $label = $(`#${$btn.data('domId')}`);
+ const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add';
+ const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`);
+ $tooltip.tooltip('destroy');
+ _this.toggleLabelPriority($label, action);
+ _this.toggleEmptyState($label, $btn, action);
+ }
- onButtonActionClick(e) {
- e.stopPropagation();
- $(e.currentTarget).tooltip('hide');
+ onButtonActionClick(e) {
+ e.stopPropagation();
+ $(e.currentTarget).tooltip('hide');
+ }
+
+ toggleEmptyState($label, $btn, action) {
+ this.emptyState.classList.toggle('hidden', !!this.prioritizedLabels[0].querySelector(':scope > li'));
+ }
+
+ toggleLabelPriority($label, action, persistState) {
+ if (persistState == null) {
+ persistState = true;
}
+ const _this = this;
+ const url = $label.find('.js-toggle-priority').data('url');
+ let $target = this.prioritizedLabels;
+ let $from = this.otherLabels;
+ const rollbackLabelPosition = this.rollbackLabelPosition.bind(this, $label, action);
- toggleEmptyState($label, $btn, action) {
- this.emptyState.classList.toggle('hidden', !!this.prioritizedLabels[0].querySelector(':scope > li'));
+ if (action === 'remove') {
+ $target = this.otherLabels;
+ $from = this.prioritizedLabels;
+ }
+ $label.detach().appendTo($target);
+ if ($from.find('li').length) {
+ $from.find('.empty-message').removeClass('hidden');
+ }
+ if ($target.find('> li:not(.empty-message)').length) {
+ $target.find('.empty-message').addClass('hidden');
+ }
+ // Return if we are not persisting state
+ if (!persistState) {
+ return;
}
+ if (action === 'remove') {
+ axios.delete(url)
+ .catch(rollbackLabelPosition);
- toggleLabelPriority($label, action, persistState) {
- if (persistState == null) {
- persistState = true;
- }
- let xhr;
- const _this = this;
- const url = $label.find('.js-toggle-priority').data('url');
- let $target = this.prioritizedLabels;
- let $from = this.otherLabels;
- if (action === 'remove') {
- $target = this.otherLabels;
- $from = this.prioritizedLabels;
- }
- $label.detach().appendTo($target);
- if ($from.find('li').length) {
+ // Restore empty message
+ if (!$from.find('li').length) {
$from.find('.empty-message').removeClass('hidden');
}
- if ($target.find('> li:not(.empty-message)').length) {
- $target.find('.empty-message').addClass('hidden');
- }
- // Return if we are not persisting state
- if (!persistState) {
- return;
- }
- if (action === 'remove') {
- xhr = $.ajax({
- url,
- type: 'DELETE'
- });
- // Restore empty message
- if (!$from.find('li').length) {
- $from.find('.empty-message').removeClass('hidden');
- }
- } else {
- xhr = this.savePrioritySort($label, action);
- }
- return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action));
+ } else {
+ this.savePrioritySort($label, action)
+ .catch(rollbackLabelPosition);
}
+ }
- onPrioritySortUpdate() {
- const xhr = this.savePrioritySort();
- return xhr.fail(function() {
- return new Flash(this.errorMessage, 'alert');
- });
- }
+ onPrioritySortUpdate() {
+ this.savePrioritySort()
+ .catch(() => flash(this.errorMessage));
+ }
- savePrioritySort() {
- return $.post({
- url: this.prioritizedLabels.data('url'),
- data: {
- label_ids: this.getSortedLabelsIds()
- }
- });
- }
+ savePrioritySort() {
+ return axios.post(this.prioritizedLabels.data('url'), {
+ label_ids: this.getSortedLabelsIds(),
+ });
+ }
- rollbackLabelPosition($label, originalAction) {
- const action = originalAction === 'remove' ? 'add' : 'remove';
- this.toggleLabelPriority($label, action, false);
- return new Flash(this.errorMessage, 'alert');
- }
+ rollbackLabelPosition($label, originalAction) {
+ const action = originalAction === 'remove' ? 'add' : 'remove';
+ this.toggleLabelPriority($label, action, false);
+ flash(this.errorMessage);
+ }
- getSortedLabelsIds() {
- const sortedIds = [];
- this.prioritizedLabels.find('> li').each(function() {
- const id = $(this).data('id');
+ getSortedLabelsIds() {
+ const sortedIds = [];
+ this.prioritizedLabels.find('> li').each(function() {
+ const id = $(this).data('id');
- if (id) {
- sortedIds.push(id);
- }
- });
- return sortedIds;
- }
+ if (id) {
+ sortedIds.push(id);
+ }
+ });
+ return sortedIds;
}
-
- gl.LabelManager = LabelManager;
-})(window.gl || (window.gl = {}));
+}
diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js
index 03dd61b4263..d85ae851706 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels.js
@@ -1,44 +1,37 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */
-(function() {
- this.Labels = (function() {
- function Labels() {
- this.setSuggestedColor = this.setSuggestedColor.bind(this);
- this.updateColorPreview = this.updateColorPreview.bind(this);
- var form;
- form = $('.label-form');
- this.cleanBinding();
- this.addBinding();
- this.updateColorPreview();
- }
+import $ from 'jquery';
- Labels.prototype.addBinding = function() {
- $(document).on('click', '.suggest-colors a', this.setSuggestedColor);
- return $(document).on('input', 'input#label_color', this.updateColorPreview);
- };
+export default class Labels {
+ constructor() {
+ this.setSuggestedColor = this.setSuggestedColor.bind(this);
+ this.updateColorPreview = this.updateColorPreview.bind(this);
+ this.cleanBinding();
+ this.addBinding();
+ this.updateColorPreview();
+ }
- Labels.prototype.cleanBinding = function() {
- $(document).off('click', '.suggest-colors a');
- return $(document).off('input', 'input#label_color');
- };
+ addBinding() {
+ $(document).on('click', '.suggest-colors a', this.setSuggestedColor);
+ return $(document).on('input', 'input#label_color', this.updateColorPreview);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ cleanBinding() {
+ $(document).off('click', '.suggest-colors a');
+ return $(document).off('input', 'input#label_color');
+ }
+ // eslint-disable-next-line class-methods-use-this
+ updateColorPreview() {
+ const previewColor = $('input#label_color').val();
+ return $('div.label-color-preview').css('background-color', previewColor);
+ // Updates the the preview color with the hex-color input
+ }
- Labels.prototype.updateColorPreview = function() {
- var previewColor;
- previewColor = $('input#label_color').val();
- return $('div.label-color-preview').css('background-color', previewColor);
- // Updates the the preview color with the hex-color input
- };
-
- // Updates the preview color with a click on a suggested color
- Labels.prototype.setSuggestedColor = function(e) {
- var color;
- color = $(e.currentTarget).data('color');
- $('input#label_color').val(color);
- this.updateColorPreview();
- // Notify the form, that color has changed
- $('.label-form').trigger('keyup');
- return e.preventDefault();
- };
-
- return Labels;
- })();
-}).call(window);
+ // Updates the preview color with a click on a suggested color
+ setSuggestedColor(e) {
+ const color = $(e.currentTarget).data('color');
+ $('input#label_color').val(color);
+ this.updateColorPreview();
+ // Notify the form, that color has changed
+ $('.label-form').trigger('keyup');
+ return e.preventDefault();
+ }
+}
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 8d7d3d73571..824d3f7ca09 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -2,102 +2,101 @@
/* global Issuable */
/* global ListLabel */
+import $ from 'jquery';
+import _ from 'underscore';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+import DropdownUtils from './filtered_search/dropdown_utils';
+import CreateLabelDropdown from './create_label';
+import flash from './flash';
-(function() {
- this.LabelsSelect = (function() {
- function LabelsSelect(els) {
- var _this, $els;
- _this = this;
+export default class LabelsSelect {
+ constructor(els, options = {}) {
+ var _this, $els;
+ _this = this;
- $els = $(els);
+ $els = $(els);
- if (!els) {
- $els = $('.js-label-select');
+ if (!els) {
+ $els = $('.js-label-select');
+ }
+
+ $els.each(function(i, dropdown) {
+ var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
+ $dropdown = $(dropdown);
+ $dropdownContainer = $dropdown.closest('.labels-filter');
+ $toggleText = $dropdown.find('.dropdown-toggle-text');
+ namespacePath = $dropdown.data('namespacePath');
+ projectPath = $dropdown.data('projectPath');
+ labelUrl = $dropdown.data('labels');
+ issueUpdateURL = $dropdown.data('issueUpdate');
+ selectedLabel = $dropdown.data('selected');
+ if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
+ selectedLabel = selectedLabel.split(',');
+ }
+ showNo = $dropdown.data('showNo');
+ showAny = $dropdown.data('showAny');
+ showMenuAbove = $dropdown.data('showMenuAbove');
+ defaultLabel = $dropdown.data('defaultLabel');
+ abilityName = $dropdown.data('abilityName');
+ $selectbox = $dropdown.closest('.selectbox');
+ $block = $selectbox.closest('.block');
+ $form = $dropdown.closest('form, .js-issuable-update');
+ $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
+ $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
+ $value = $block.find('.value');
+ $loading = $block.find('.block-loading').fadeOut();
+ fieldName = $dropdown.data('fieldName');
+ useId = $dropdown.is('.js-issuable-form-dropdown, .js-filter-bulk-update, .js-label-sidebar-dropdown');
+ propertyName = useId ? 'id' : 'title';
+ initialSelected = $selectbox
+ .find('input[name="' + $dropdown.data('fieldName') + '"]')
+ .map(function () {
+ return this.value;
+ }).get();
+ const handleClick = options.handleClick;
+
+ $sidebarLabelTooltip.tooltip();
+
+ if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
+ new CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), namespacePath, projectPath);
}
- $els.each(function(i, dropdown) {
- var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
- $dropdown = $(dropdown);
- $dropdownContainer = $dropdown.closest('.labels-filter');
- $toggleText = $dropdown.find('.dropdown-toggle-text');
- namespacePath = $dropdown.data('namespace-path');
- projectPath = $dropdown.data('project-path');
- labelUrl = $dropdown.data('labels');
- issueUpdateURL = $dropdown.data('issueUpdate');
- selectedLabel = $dropdown.data('selected');
- if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
- selectedLabel = selectedLabel.split(',');
- }
- showNo = $dropdown.data('show-no');
- showAny = $dropdown.data('show-any');
- showMenuAbove = $dropdown.data('showMenuAbove');
- defaultLabel = $dropdown.data('default-label');
- abilityName = $dropdown.data('ability-name');
- $selectbox = $dropdown.closest('.selectbox');
- $block = $selectbox.closest('.block');
- $form = $dropdown.closest('form, .js-issuable-update');
- $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
- $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
- $value = $block.find('.value');
- $loading = $block.find('.block-loading').fadeOut();
- fieldName = $dropdown.data('field-name');
- useId = $dropdown.is('.js-issuable-form-dropdown, .js-filter-bulk-update, .js-label-sidebar-dropdown');
- propertyName = useId ? 'id' : 'title';
- initialSelected = $selectbox
- .find('input[name="' + $dropdown.data('field-name') + '"]')
- .map(function () {
- return this.value;
- }).get();
- if (issueUpdateURL != null) {
- issueURLSplit = issueUpdateURL.split('/');
- }
- if (issueUpdateURL) {
- labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
- labelNoneHTMLTemplate = '<span class="no-value">None</span>';
- }
+ saveLabelData = function() {
+ var data, selected;
+ selected = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "']").map(function() {
+ return this.value;
+ }).get();
- $sidebarLabelTooltip.tooltip();
+ if (_.isEqual(initialSelected, selected)) return;
+ initialSelected = selected;
- if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
- new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), namespacePath, projectPath);
+ data = {};
+ data[abilityName] = {};
+ data[abilityName].label_ids = selected;
+ if (!selected.length) {
+ data[abilityName].label_ids = [''];
}
-
- saveLabelData = function() {
- var data, selected;
- selected = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "']").map(function() {
- return this.value;
- }).get();
-
- if (_.isEqual(initialSelected, selected)) return;
- initialSelected = selected;
-
- data = {};
- data[abilityName] = {};
- data[abilityName].label_ids = selected;
- if (!selected.length) {
- data[abilityName].label_ids = [''];
- }
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
- type: 'PUT',
- url: issueUpdateURL,
- dataType: 'JSON',
- data: data
- }).done(function(data) {
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+ axios.put(issueUpdateURL, data)
+ .then(({ data }) => {
var labelCount, template, labelTooltipTitle, labelTitles;
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
- data.issueURLSplit = issueURLSplit;
+ data.issueUpdateURL = issueUpdateURL;
labelCount = 0;
- if (data.labels.length) {
- template = labelHTMLTemplate(data);
+ if (data.labels.length && issueUpdateURL) {
+ template = LabelsSelect.getLabelTemplate({
+ labels: data.labels,
+ issueUpdateURL,
+ });
labelCount = data.labels.length;
}
else {
- template = labelNoneHTMLTemplate;
+ template = '<span class="no-value">None</span>';
}
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
@@ -126,22 +125,15 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
$('.has-tooltip', $value).tooltip({
container: 'body'
});
- return $value.find('a').each(function(i) {
- return setTimeout((function(_this) {
- return function() {
- return gl.animate.animate($(_this), 'pulse');
- };
- })(this), 200 * i);
- });
- });
- };
- $dropdown.glDropdown({
- showMenuAbove: showMenuAbove,
- data: function(term, callback) {
- return $.ajax({
- url: labelUrl
- }).done(function(data) {
- data = _.chain(data).groupBy(function(label) {
+ })
+ .catch(() => flash(__('Error saving label update.')));
+ };
+ $dropdown.glDropdown({
+ showMenuAbove: showMenuAbove,
+ data: function(term, callback) {
+ axios.get(labelUrl)
+ .then((res) => {
+ let data = _.chain(res.data).groupBy(function(label) {
return label.title;
}).map(function(label) {
var color;
@@ -179,317 +171,334 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
- });
- },
- renderRow: function(label, instance) {
- var $a, $li, color, colorEl, indeterminate, removesAll, selectedClass, spacing, i, marked, dropdownName, dropdownValue;
- $li = $('<li>');
- $a = $('<a href="#">');
- selectedClass = [];
- removesAll = label.id <= 0 || (label.id == null);
- if ($dropdown.hasClass('js-filter-bulk-update')) {
- indeterminate = $dropdown.data('indeterminate') || [];
- marked = $dropdown.data('marked') || [];
-
- if (indeterminate.indexOf(label.id) !== -1) {
- selectedClass.push('is-indeterminate');
- }
-
- if (marked.indexOf(label.id) !== -1) {
- // Remove is-indeterminate class if the item will be marked as active
- i = selectedClass.indexOf('is-indeterminate');
- if (i !== -1) {
- selectedClass.splice(i, 1);
- }
- selectedClass.push('is-active');
- }
- } else {
- if (this.id(label)) {
- dropdownName = $dropdown.data('fieldName');
- dropdownValue = this.id(label).toString().replace(/'/g, '\\\'');
-
- if ($form.find("input[type='hidden'][name='" + dropdownName + "'][value='" + dropdownValue + "']").length) {
- selectedClass.push('is-active');
- }
- }
+ })
+ .catch(() => flash(__('Error fetching labels.')));
+ },
+ renderRow: function(label, instance) {
+ var $a, $li, color, colorEl, indeterminate, removesAll, selectedClass, spacing, i, marked, dropdownName, dropdownValue;
+ $li = $('<li>');
+ $a = $('<a href="#">');
+ selectedClass = [];
+ removesAll = label.id <= 0 || (label.id == null);
+ if ($dropdown.hasClass('js-filter-bulk-update')) {
+ indeterminate = $dropdown.data('indeterminate') || [];
+ marked = $dropdown.data('marked') || [];
+
+ if (indeterminate.indexOf(label.id) !== -1) {
+ selectedClass.push('is-indeterminate');
+ }
- if ($dropdown.hasClass('js-multiselect') && removesAll) {
- selectedClass.push('dropdown-clear-active');
+ if (marked.indexOf(label.id) !== -1) {
+ // Remove is-indeterminate class if the item will be marked as active
+ i = selectedClass.indexOf('is-indeterminate');
+ if (i !== -1) {
+ selectedClass.splice(i, 1);
}
+ selectedClass.push('is-active');
}
- if (label.duplicate) {
- spacing = 100 / label.color.length;
- // Reduce the colors to 4
- label.color = label.color.filter(function(color, i) {
- return i < 4;
- });
- color = _.map(label.color, function(color, i) {
- var percentFirst, percentSecond;
- percentFirst = Math.floor(spacing * i);
- percentSecond = Math.floor(spacing * (i + 1));
- return color + " " + percentFirst + "%," + color + " " + percentSecond + "% ";
- }).join(',');
- color = "linear-gradient(" + color + ")";
- }
- else {
- if (label.color != null) {
- color = label.color[0];
+ } else {
+ if (this.id(label)) {
+ dropdownName = $dropdown.data('fieldName');
+ dropdownValue = this.id(label).toString().replace(/'/g, '\\\'');
+
+ if ($form.find("input[type='hidden'][name='" + dropdownName + "'][value='" + dropdownValue + "']").length) {
+ selectedClass.push('is-active');
}
}
- if (color) {
- colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>";
- }
- else {
- colorEl = '';
- }
- // We need to identify which items are actually labels
- if (label.id) {
- selectedClass.push('label-item');
- $a.attr('data-label-id', label.id);
- }
- $a.addClass(selectedClass.join(' ')).html(colorEl + " " + label.title);
- // Return generated html
- return $li.html($a).prop('outerHTML');
- },
- search: {
- fields: ['title']
- },
- selectable: true,
- filterable: true,
- selected: $dropdown.data('selected') || [],
- toggleLabel: function(selected, el) {
- var isSelected = el !== null ? el.hasClass('is-active') : false;
- var title = selected.title;
- var selectedLabels = this.selected;
-
- if (selected.id === 0) {
- this.selected = [];
- return 'No Label';
- }
- else if (isSelected) {
- this.selected.push(title);
- }
- else {
- var index = this.selected.indexOf(title);
- this.selected.splice(index, 1);
- }
- if (selectedLabels.length === 1) {
- return selectedLabels;
+ if ($dropdown.hasClass('js-multiselect') && removesAll) {
+ selectedClass.push('dropdown-clear-active');
}
- else if (selectedLabels.length) {
- return selectedLabels[0] + " +" + (selectedLabels.length - 1) + " more";
- }
- else {
- return defaultLabel;
+ }
+ if (label.duplicate) {
+ color = DropdownUtils.duplicateLabelColor(label.color);
+ }
+ else {
+ if (label.color != null) {
+ color = label.color[0];
}
- },
- fieldName: $dropdown.data('field-name'),
- id: function(label) {
- if (label.id <= 0) return label.title;
+ }
+ if (color) {
+ colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>";
+ }
+ else {
+ colorEl = '';
+ }
+ // We need to identify which items are actually labels
+ if (label.id) {
+ selectedClass.push('label-item');
+ $a.attr('data-label-id', label.id);
+ }
+ $a.addClass(selectedClass.join(' ')).html(`${colorEl} ${_.escape(label.title)}`);
+ // Return generated html
+ return $li.html($a).prop('outerHTML');
+ },
+ search: {
+ fields: ['title']
+ },
+ selectable: true,
+ filterable: true,
+ selected: $dropdown.data('selected') || [],
+ toggleLabel: function(selected, el) {
+ var $dropdownParent = $dropdown.parent();
+ var $dropdownInputField = $dropdownParent.find('.dropdown-input-field');
+ var isSelected = el !== null ? el.hasClass('is-active') : false;
+ var title = selected.title;
+ var selectedLabels = this.selected;
+
+ if ($dropdownInputField.length && $dropdownInputField.val().length) {
+ $dropdownParent.find('.dropdown-input-clear').trigger('click');
+ }
- if ($dropdown.hasClass('js-issuable-form-dropdown')) {
- return label.id;
- }
+ if (selected.id === 0) {
+ this.selected = [];
+ return 'No Label';
+ }
+ else if (isSelected) {
+ this.selected.push(title);
+ }
+ else {
+ var index = this.selected.indexOf(title);
+ this.selected.splice(index, 1);
+ }
- if ($dropdown.hasClass("js-filter-submit") && (label.isAny == null)) {
- return label.title;
- }
- else {
- return label.id;
- }
- },
- hidden: function() {
- var isIssueIndex, isMRIndex, page, selectedLabels;
- page = $('body').data('page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = page === 'projects:merge_requests:index';
- $selectbox.hide();
- // display:block overrides the hide-collapse rule
- $value.removeAttr('style');
+ if (selectedLabels.length === 1) {
+ return selectedLabels;
+ }
+ else if (selectedLabels.length) {
+ return selectedLabels[0] + " +" + (selectedLabels.length - 1) + " more";
+ }
+ else {
+ return defaultLabel;
+ }
+ },
+ fieldName: $dropdown.data('fieldName'),
+ id: function(label) {
+ if (label.id <= 0) return label.title;
- if ($dropdown.hasClass('js-issuable-form-dropdown')) {
- return;
- }
+ if ($dropdown.hasClass('js-issuable-form-dropdown')) {
+ return label.id;
+ }
+
+ if ($dropdown.hasClass("js-filter-submit") && (label.isAny == null)) {
+ return label.title;
+ }
+ else {
+ return label.id;
+ }
+ },
+ hidden: function() {
+ var isIssueIndex, isMRIndex, page, selectedLabels;
+ page = $('body').attr('data-page');
+ isIssueIndex = page === 'projects:issues:index';
+ isMRIndex = page === 'projects:merge_requests:index';
+ $selectbox.hide();
+ // display:block overrides the hide-collapse rule
+ $value.removeAttr('style');
+
+ if ($dropdown.hasClass('js-issuable-form-dropdown')) {
+ return;
+ }
- if ($('html').hasClass('issue-boards-page')) {
- return;
+ if ($('html').hasClass('issue-boards-page')) {
+ return;
+ }
+ if ($dropdown.hasClass('js-multiselect')) {
+ if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
+ Issuable.filterResults($dropdown.closest('form'));
}
- if ($dropdown.hasClass('js-multiselect')) {
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
- Issuable.filterResults($dropdown.closest('form'));
- }
- else if ($dropdown.hasClass('js-filter-submit')) {
- $dropdown.closest('form').submit();
- }
- else {
- if (!$dropdown.hasClass('js-filter-bulk-update')) {
- saveLabelData();
- }
- }
+ else if ($dropdown.hasClass('js-filter-submit')) {
+ $dropdown.closest('form').submit();
}
- },
- multiSelect: $dropdown.hasClass('js-multiselect'),
- vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(options) {
- const { $el, e, isMarking } = options;
- const label = options.selectedObj;
-
- var isIssueIndex, isMRIndex, page, boardsModel;
- var fadeOutLoader = () => {
- $loading.fadeOut();
- };
-
- page = $('body').data('page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = page === 'projects:merge_requests:index';
-
- if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) {
- $dropdown.parent()
- .find('.dropdown-clear-active')
- .removeClass('is-active');
+ else {
+ if (!$dropdown.hasClass('js-filter-bulk-update')) {
+ saveLabelData();
+ }
}
+ }
+ },
+ multiSelect: $dropdown.hasClass('js-multiselect'),
+ vue: $dropdown.hasClass('js-issue-board-sidebar'),
+ clicked: function (clickEvent) {
+ const { $el, e, isMarking } = clickEvent;
+ const label = clickEvent.selectedObj;
+
+ var isIssueIndex, isMRIndex, page, boardsModel;
+ var fadeOutLoader = () => {
+ $loading.fadeOut();
+ };
- if ($dropdown.hasClass('js-issuable-form-dropdown')) {
- return;
- }
+ page = $('body').attr('data-page');
+ isIssueIndex = page === 'projects:issues:index';
+ isMRIndex = page === 'projects:merge_requests:index';
- if ($dropdown.hasClass('js-filter-bulk-update')) {
- _this.enableBulkLabelDropdown();
- _this.setDropdownData($dropdown, isMarking, label.id);
- return;
- }
+ if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) {
+ $dropdown.parent()
+ .find('.dropdown-clear-active')
+ .removeClass('is-active');
+ }
- if ($dropdown.closest('.add-issues-modal').length) {
- boardsModel = gl.issueBoards.ModalStore.store.filter;
- }
+ if ($dropdown.hasClass('js-issuable-form-dropdown')) {
+ return;
+ }
- if (boardsModel) {
- if (label.isAny) {
- boardsModel['label_name'] = [];
- } else if ($el.hasClass('is-active')) {
- boardsModel['label_name'].push(label.title);
- }
+ if ($dropdown.hasClass('js-filter-bulk-update')) {
+ _this.enableBulkLabelDropdown();
+ _this.setDropdownData($dropdown, isMarking, label.id);
+ return;
+ }
+
+ if ($dropdown.closest('.add-issues-modal').length) {
+ boardsModel = gl.issueBoards.ModalStore.store.filter;
+ }
- e.preventDefault();
- return;
+ if (boardsModel) {
+ if (label.isAny) {
+ boardsModel['label_name'] = [];
+ } else if ($el.hasClass('is-active')) {
+ boardsModel['label_name'].push(label.title);
}
- else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- if (!$dropdown.hasClass('js-multiselect')) {
- selectedLabel = label.title;
- return Issuable.filterResults($dropdown.closest('form'));
- }
+
+ e.preventDefault();
+ return;
+ }
+ else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (!$dropdown.hasClass('js-multiselect')) {
+ selectedLabel = label.title;
+ return Issuable.filterResults($dropdown.closest('form'));
}
- else if ($dropdown.hasClass('js-filter-submit')) {
- return $dropdown.closest('form').submit();
+ }
+ else if ($dropdown.hasClass('js-filter-submit')) {
+ return $dropdown.closest('form').submit();
+ }
+ else if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ if ($el.hasClass('is-active')) {
+ gl.issueBoards.BoardsStore.detail.issue.labels.push(new ListLabel({
+ id: label.id,
+ title: label.title,
+ color: label.color[0],
+ textColor: '#fff'
+ }));
+ }
+ else {
+ var labels = gl.issueBoards.BoardsStore.detail.issue.labels;
+ labels = labels.filter(function (selectedLabel) {
+ return selectedLabel.id !== label.id;
+ });
+ gl.issueBoards.BoardsStore.detail.issue.labels = labels;
}
- else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if ($el.hasClass('is-active')) {
- gl.issueBoards.BoardsStore.detail.issue.labels.push(new ListLabel({
- id: label.id,
- title: label.title,
- color: label.color[0],
- textColor: '#fff'
- }));
- }
- else {
- var labels = gl.issueBoards.BoardsStore.detail.issue.labels;
- labels = labels.filter(function (selectedLabel) {
- return selectedLabel.id !== label.id;
- });
- gl.issueBoards.BoardsStore.detail.issue.labels = labels;
- }
- $loading.fadeIn();
+ $loading.fadeIn();
+
+ gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
+ .then(fadeOutLoader)
+ .catch(fadeOutLoader);
+ }
+ else if (handleClick) {
+ e.preventDefault();
+ handleClick(label);
+ }
+ else {
+ if ($dropdown.hasClass('js-multiselect')) {
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(fadeOutLoader)
- .catch(fadeOutLoader);
}
else {
- if ($dropdown.hasClass('js-multiselect')) {
-
- }
- else {
- return saveLabelData();
- }
+ return saveLabelData();
}
- },
- });
-
- // Set dropdown data
- _this.setOriginalDropdownData($dropdownContainer, $dropdown);
+ }
+ },
});
- this.bindEvents();
- }
- LabelsSelect.prototype.bindEvents = function() {
- return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue);
- };
-
- LabelsSelect.prototype.onSelectCheckboxIssue = function() {
- if ($('.selected_issue:checked').length) {
- return;
+ // Set dropdown data
+ _this.setOriginalDropdownData($dropdownContainer, $dropdown);
+ });
+ this.bindEvents();
+ }
+
+ static getLabelTemplate(tplData) {
+ // We could use ES6 template string here
+ // and properly indent markup for readability
+ // but that also introduces unintended white-space
+ // so best approach is to use traditional way of
+ // concatenation
+ // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
+ const tpl = _.template([
+ '<% _.each(labels, function(label){ %>',
+ '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
+ '<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
+ '<%- label.title %>',
+ '</span>',
+ '</a>',
+ '<% }); %>',
+ ].join(''));
+
+ return tpl(tplData);
+ }
+
+ bindEvents() {
+ return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ onSelectCheckboxIssue() {
+ if ($('.selected_issue:checked').length) {
+ return;
+ }
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
+ }
+ // eslint-disable-next-line class-methods-use-this
+ enableBulkLabelDropdown() {
+ IssuableBulkUpdateActions.willUpdateLabels = true;
+ }
+ // eslint-disable-next-line class-methods-use-this
+ setDropdownData($dropdown, isMarking, value) {
+ var i, markedIds, unmarkedIds, indeterminateIds;
+
+ markedIds = $dropdown.data('marked') || [];
+ unmarkedIds = $dropdown.data('unmarked') || [];
+ indeterminateIds = $dropdown.data('indeterminate') || [];
+
+ if (isMarking) {
+ markedIds.push(value);
+
+ i = indeterminateIds.indexOf(value);
+ if (i > -1) {
+ indeterminateIds.splice(i, 1);
}
- return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
- };
-
- LabelsSelect.prototype.enableBulkLabelDropdown = function() {
- IssuableBulkUpdateActions.willUpdateLabels = true;
- };
-
- LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) {
- var i, markedIds, unmarkedIds, indeterminateIds;
- markedIds = $dropdown.data('marked') || [];
- unmarkedIds = $dropdown.data('unmarked') || [];
- indeterminateIds = $dropdown.data('indeterminate') || [];
-
- if (isMarking) {
- markedIds.push(value);
-
- i = indeterminateIds.indexOf(value);
- if (i > -1) {
- indeterminateIds.splice(i, 1);
- }
-
- i = unmarkedIds.indexOf(value);
- if (i > -1) {
- unmarkedIds.splice(i, 1);
- }
- } else {
- // If marked item (not common) is unmarked
- i = markedIds.indexOf(value);
- if (i > -1) {
- markedIds.splice(i, 1);
- }
-
- // If an indeterminate item is being unmarked
- 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 (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
- unmarkedIds.push(value);
- }
+ i = unmarkedIds.indexOf(value);
+ if (i > -1) {
+ unmarkedIds.splice(i, 1);
+ }
+ } else {
+ // If marked item (not common) is unmarked
+ i = markedIds.indexOf(value);
+ if (i > -1) {
+ markedIds.splice(i, 1);
}
- $dropdown.data('marked', markedIds);
- $dropdown.data('unmarked', unmarkedIds);
- $dropdown.data('indeterminate', indeterminateIds);
- };
+ // If an indeterminate item is being unmarked
+ if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
+ unmarkedIds.push(value);
+ }
- LabelsSelect.prototype.setOriginalDropdownData = function($container, $dropdown) {
- var labels = [];
- $container.find('[name="label_name[]"]').map(function() {
- return labels.push(this.value);
- });
- $dropdown.data('marked', labels);
- };
+ // If a marked item is being unmarked
+ // (a marked item could also be a label that is present in all selection)
+ if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
+ unmarkedIds.push(value);
+ }
+ }
- return LabelsSelect;
- })();
-}).call(window);
+ $dropdown.data('marked', markedIds);
+ $dropdown.data('unmarked', unmarkedIds);
+ $dropdown.data('indeterminate', indeterminateIds);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ setOriginalDropdownData($container, $dropdown) {
+ const labels = [];
+ $container.find('[name="label_name[]"]').map(function() {
+ return labels.push(this.value);
+ });
+ $dropdown.data('marked', labels);
+ }
+}
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index 71064ccc539..e3177188772 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,58 +1,52 @@
-/* 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';
+import $ from 'jquery';
+import ContextualSidebar from './contextual_sidebar';
+import initFlyOutNav from './fly_out_nav';
+
+function hideEndFade($scrollingTabs) {
+ $scrollingTabs.each(function scrollTabsLoop() {
+ const $this = $(this);
+ $this.siblings('.fade-right').toggleClass('scrolling', Math.round($this.width()) < $this.prop('scrollWidth'));
+ });
+}
-(function() {
- var hideEndFade;
+export default function initLayoutNav() {
+ const contextualSidebar = new ContextualSidebar();
+ contextualSidebar.bindEvents();
- hideEndFade = function($scrollingTabs) {
- return $scrollingTabs.each(function() {
- var $this;
- $this = $(this);
- return $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth'));
- });
- };
+ initFlyOutNav();
$(document).on('init.scrolling-tabs', () => {
const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
$scrollingTabs.addClass('is-initialized');
- hideEndFade($scrollingTabs);
- $(window).off('resize.nav').on('resize.nav', function() {
- return hideEndFade($scrollingTabs);
- });
- $scrollingTabs.off('scroll').on('scroll', function(event) {
- var $this, currentPosition, maxPosition;
- $this = $(this);
- currentPosition = $this.scrollLeft();
- maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
+ $(window).on('resize.nav', () => {
+ hideEndFade($scrollingTabs);
+ }).trigger('resize.nav');
+
+ $scrollingTabs.on('scroll', function tabsScrollEvent() {
+ const $this = $(this);
+ const currentPosition = $this.scrollLeft();
+ const maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
+
$this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0);
- return $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
+ $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
});
- $scrollingTabs.each(function () {
- var $this = $(this);
- var scrollingTabWidth = $this.width();
- var $active = $this.find('.active');
- var activeWidth = $active.width();
+ $scrollingTabs.each(function scrollTabsEachLoop() {
+ const $this = $(this);
+ const scrollingTabWidth = $this.width();
+ const $active = $this.find('.active');
+ const activeWidth = $active.width();
if ($active.length) {
- var offset = $active.offset().left + activeWidth;
+ const offset = $active.offset().left + activeWidth;
if (offset > scrollingTabWidth - 30) {
- var scrollLeft = scrollingTabWidth / 2;
- scrollLeft = (offset - scrollLeft) - (activeWidth / 2);
+ const scrollLeft = (offset - (scrollingTabWidth / 2)) - (activeWidth / 2);
+
$this.scrollLeft(scrollLeft);
}
}
});
- });
-
- function applyScrollNavClass() {
- const scrollOpacityHeight = 40;
- $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1));
- }
-
- $(() => {
- $(window).on('scroll', _.throttle(applyScrollNavClass, 100));
- });
-}).call(window);
+ }).trigger('init.scrolling-tabs');
+}
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
new file mode 100644
index 00000000000..dbbf1637a47
--- /dev/null
+++ b/app/assets/javascripts/lazy_loader.js
@@ -0,0 +1,75 @@
+import _ from 'underscore';
+
+export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
+const SCROLL_THRESHOLD = 300;
+
+export default class LazyLoader {
+ constructor(options = {}) {
+ this.lazyImages = [];
+ this.observerNode = options.observerNode || '#content-body';
+
+ const throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300);
+ const debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300);
+
+ window.addEventListener('scroll', throttledScrollCheck);
+ window.addEventListener('resize', debouncedElementsInView);
+
+ const scrollContainer = options.scrollContainer || window;
+ scrollContainer.addEventListener('load', () => this.loadCheck());
+ }
+ searchLazyImages() {
+ this.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
+
+ if (this.lazyImages.length) {
+ this.checkElementsInView();
+ }
+ }
+ startContentObserver() {
+ const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
+
+ if (contentNode) {
+ const observer = new MutationObserver(() => this.searchLazyImages());
+
+ observer.observe(contentNode, {
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+ loadCheck() {
+ this.searchLazyImages();
+ this.startContentObserver();
+ }
+ scrollCheck() {
+ requestAnimationFrame(() => this.checkElementsInView());
+ }
+ checkElementsInView() {
+ const scrollTop = pageYOffset;
+ const visHeight = scrollTop + innerHeight + SCROLL_THRESHOLD;
+
+ // Loading Images which are in the current viewport or close to them
+ this.lazyImages = this.lazyImages.filter((selectedImage) => {
+ if (selectedImage.getAttribute('data-src')) {
+ const imgBoundRect = selectedImage.getBoundingClientRect();
+ const imgTop = scrollTop + imgBoundRect.top;
+ const imgBound = imgTop + imgBoundRect.height;
+
+ if (scrollTop < imgBound && visHeight > imgTop) {
+ LazyLoader.loadImage(selectedImage);
+ return false;
+ }
+
+ return true;
+ }
+ return false;
+ });
+ }
+ static loadImage(img) {
+ if (img.getAttribute('data-src')) {
+ img.setAttribute('src', img.getAttribute('data-src'));
+ img.removeAttribute('data-src');
+ img.classList.remove('lazy');
+ img.classList.add('js-lazy-loaded');
+ }
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index 7477b5a5214..616d8952ada 100644
--- a/app/assets/javascripts/lib/utils/ajax_cache.js
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -1,3 +1,4 @@
+import axios from './axios_utils';
import Cache from './cache';
class AjaxCache extends Cache {
@@ -6,6 +7,10 @@ class AjaxCache extends Cache {
this.pendingRequests = { };
}
+ override(endpoint, data) {
+ this.internalStorage[endpoint] = data;
+ }
+
retrieve(endpoint, forceRetrieve) {
if (this.hasData(endpoint) && !forceRetrieve) {
return Promise.resolve(this.get(endpoint));
@@ -14,25 +19,18 @@ class AjaxCache extends Cache {
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;
- });
+ pendingRequest = axios.get(endpoint)
+ .then(({ data }) => {
+ this.internalStorage[endpoint] = data;
+ delete this.pendingRequests[endpoint];
+ })
+ .catch((e) => {
+ const error = new Error(`${endpoint}: ${e.message}`);
+ error.textStatus = e.message;
+
+ delete this.pendingRequests[endpoint];
+ throw error;
+ });
this.pendingRequests[endpoint] = pendingRequest;
}
diff --git a/app/assets/javascripts/lib/utils/animate.js b/app/assets/javascripts/lib/utils/animate.js
deleted file mode 100644
index d93c1d0da59..00000000000
--- a/app/assets/javascripts/lib/utils/animate.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, no-void, prefer-template, no-var, new-cap, prefer-arrow-callback, consistent-return, max-len */
-(function() {
- (function(w) {
- if (w.gl == null) {
- w.gl = {};
- }
- if (gl.animate == null) {
- gl.animate = {};
- }
- gl.animate.animate = function($el, animation, options, done) {
- if ((options != null ? options.cssStart : void 0) != null) {
- $el.css(options.cssStart);
- }
- $el.removeClass(animation + ' animated').addClass(animation + ' animated').one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function() {
- $(this).removeClass(animation + ' animated');
- if (done != null) {
- done();
- }
- if ((options != null ? options.cssEnd : void 0) != null) {
- $el.css(options.cssEnd);
- }
- });
- };
- gl.animate.animateEach = function($els, animation, time, options, done) {
- var dfd;
- dfd = $.Deferred();
- if (!$els.length) {
- dfd.resolve();
- }
- $els.each(function(i) {
- setTimeout((function(_this) {
- return function() {
- var $this;
- $this = $(_this);
- return gl.animate.animate($this, animation, options, function() {
- if (i === $els.length - 1) {
- dfd.resolve();
- if (done != null) {
- return done();
- }
- }
- });
- };
- })(this), time * i);
- });
- return dfd.promise();
- };
- })(window);
-}).call(window);
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
new file mode 100644
index 00000000000..792871e2ecf
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -0,0 +1,36 @@
+import axios from 'axios';
+import csrf from './csrf';
+
+axios.defaults.headers.common[csrf.headerKey] = csrf.token;
+// Used by Rails to check if it is a valid XHR request
+axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+
+// Maintain a global counter for active requests
+// see: spec/support/wait_for_requests.rb
+axios.interceptors.request.use((config) => {
+ window.activeVueResources = window.activeVueResources || 0;
+ window.activeVueResources += 1;
+
+ return config;
+});
+
+// Remove the global counter
+axios.interceptors.response.use((config) => {
+ window.activeVueResources -= 1;
+
+ return config;
+}, (e) => {
+ window.activeVueResources -= 1;
+
+ return Promise.reject(e);
+});
+
+export default axios;
+
+/**
+ * @return The adapter that axios uses for dispatching requests. This may be overwritten in tests.
+ *
+ * @see https://github.com/axios/axios/tree/master/lib/adapters
+ * @see https://github.com/ctimmerm/axios-mock-adapter/blob/v1.12.0/src/index.js#L39
+ */
+export const getDefaultAdapter = () => axios.defaults.adapter;
diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
index 0bf2ba6acc2..3873f4528ce 100644
--- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
+++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/**
* Linked Tabs
*
diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js
index 3141f1eeafc..596bd1e388a 100644
--- a/app/assets/javascripts/lib/utils/cache.js
+++ b/app/assets/javascripts/lib/utils/cache.js
@@ -1,4 +1,4 @@
-class Cache {
+export default class Cache {
constructor() {
this.internalStorage = { };
}
@@ -15,5 +15,3 @@ class Cache {
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 122ec138c59..0830ebe9e4e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -1,423 +1,477 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-unused-expressions, no-param-reassign, no-else-return, quotes, object-shorthand, comma-dangle, camelcase, one-var, vars-on-top, one-var-declaration-per-line, no-return-assign, consistent-return, max-len, prefer-template */
-(function() {
- (function(w) {
- var base;
- const faviconEl = document.getElementById('favicon');
- const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null;
- w.gl || (w.gl = {});
- (base = w.gl).utils || (base.utils = {});
- w.gl.utils.isInGroupsPage = function() {
- return gl.utils.getPagePath() === 'groups';
- };
- w.gl.utils.isInProjectPage = function() {
- return gl.utils.getPagePath() === 'projects';
- };
- w.gl.utils.getProjectSlug = function() {
- if (this.isInProjectPage()) {
- return $('body').data('project');
- } else {
- return null;
- }
- };
- w.gl.utils.getGroupSlug = function() {
- if (this.isInGroupsPage()) {
- return $('body').data('group');
- } else {
- return null;
- }
- };
-
- w.gl.utils.ajaxGet = function(url) {
- return $.ajax({
- type: "GET",
- url: url,
- dataType: "script"
- });
- };
-
- w.gl.utils.ajaxPost = function(url, data) {
- return $.ajax({
- type: 'POST',
- url: url,
- data: data,
+import $ from 'jquery';
+import Cookies from 'js-cookie';
+import axios from './axios_utils';
+import { getLocationHash } from './url_utility';
+import { convertToCamelCase } from './text_utility';
+
+export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index];
+
+export const isInGroupsPage = () => getPagePath() === 'groups';
+
+export const isInProjectPage = () => getPagePath() === 'projects';
+
+export const getProjectSlug = () => {
+ if (isInProjectPage()) {
+ return $('body').data('project');
+ }
+ return null;
+};
+
+export const getGroupSlug = () => {
+ if (isInGroupsPage()) {
+ return $('body').data('group');
+ }
+ return null;
+};
+
+export const checkPageAndAction = (page, action) => {
+ const pagePath = getPagePath(1);
+ const actionPath = getPagePath(2);
+
+ return pagePath === page && actionPath === action;
+};
+
+export const isInIssuePage = () => checkPageAndAction('issues', 'show');
+export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
+export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
+export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
+
+export const ajaxGet = url => axios.get(url, {
+ params: { format: 'js' },
+ responseType: 'text',
+}).then(({ data }) => {
+ $.globalEval(data);
+});
+
+export const rstrip = (val) => {
+ if (val) {
+ return val.replace(/\s+$/, '');
+ }
+ return val;
+};
+
+export const updateTooltipTitle = ($tooltipEl, newTitle) => $tooltipEl.attr('title', newTitle).tooltip('fixTitle');
+
+export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventName = 'input') => {
+ const field = $(fieldSelector);
+ const closestSubmit = field.closest('form').find(buttonSelector);
+ if (rstrip(field.val()) === '') {
+ closestSubmit.disable();
+ }
+ // eslint-disable-next-line func-names
+ return field.on(eventName, function () {
+ if (rstrip($(this).val()) === '') {
+ return closestSubmit.disable();
+ }
+ return closestSubmit.enable();
+ });
+};
+
+// automatically adjust scroll position for hash urls taking the height of the navbar into account
+// https://github.com/twitter/bootstrap/issues/1768
+export const handleLocationHash = () => {
+ let hash = getLocationHash();
+ if (!hash) return;
+
+ // This is required to handle non-unicode characters in hash
+ hash = decodeURIComponent(hash);
+
+ const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`);
+ const fixedTabs = document.querySelector('.js-tabs-affix');
+ const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck');
+ const fixedNav = document.querySelector('.navbar-gitlab');
+
+ let adjustment = 0;
+ if (fixedNav) adjustment -= fixedNav.offsetHeight;
+
+ if (target && target.scrollIntoView) {
+ target.scrollIntoView(true);
+ }
+
+ if (fixedTabs) {
+ adjustment -= fixedTabs.offsetHeight;
+ }
+
+ if (fixedDiffStats) {
+ adjustment -= fixedDiffStats.offsetHeight;
+ }
+
+ window.scrollBy(0, adjustment);
+};
+
+// Check if element scrolled into viewport from above or below
+// Courtesy http://stackoverflow.com/a/7557433/414749
+export const isInViewport = (el) => {
+ const rect = el.getBoundingClientRect();
+
+ return (
+ rect.top >= 0 &&
+ rect.left >= 0 &&
+ rect.bottom <= window.innerHeight &&
+ rect.right <= window.innerWidth
+ );
+};
+
+export const parseUrl = (url) => {
+ const parser = document.createElement('a');
+ parser.href = url;
+ return parser;
+};
+
+export const parseUrlPathname = (url) => {
+ const parsedUrl = parseUrl(url);
+ // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11
+ // We have to make sure we always have an absolute path.
+ return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`;
+};
+
+// We can trust that each param has one & since values containing & will be encoded
+// Remove the first character of search as it is always ?
+export const getUrlParamsArray = () => window.location.search.slice(1).split('&').map((param) => {
+ const split = param.split('=');
+ return [decodeURI(split[0]), split[1]].join('=');
+});
+
+export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
+
+// Identify following special clicks
+// 1) Cmd + Click on Mac (e.metaKey)
+// 2) Ctrl + Click on PC (e.ctrlKey)
+// 3) Middle-click or Mouse Wheel Click (e.which is 2)
+export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
+
+export const scrollToElement = (element) => {
+ let $el = element;
+ if (!(element instanceof $)) {
+ $el = $(element);
+ }
+ const top = $el.offset().top;
+ const mrTabsHeight = $('.merge-request-tabs').height() || 0;
+ const headerHeight = $('.navbar-gitlab').height() || 0;
+
+ return $('body, html').animate({
+ scrollTop: top - mrTabsHeight - headerHeight,
+ }, 200);
+};
+
+/**
+ this will take in the `name` of the param you want to parse in the url
+ if the name does not exist this function will return `null`
+ otherwise it will return the value of the param key provided
+*/
+export const getParameterByName = (name, urlToParse) => {
+ const url = urlToParse || window.location.href;
+ const parsedName = name.replace(/[[\]]/g, '\\$&');
+ const regex = new RegExp(`[?&]${parsedName}(=([^&#]*)|&|#|$)`);
+ const results = regex.exec(url);
+ if (!results) return null;
+ if (!results[2]) return '';
+ return decodeURIComponent(results[2].replace(/\+/g, ' '));
+};
+
+export const getSelectedFragment = () => {
+ const selection = window.getSelection();
+ if (selection.rangeCount === 0) return null;
+ const documentFragment = document.createDocumentFragment();
+ for (let i = 0; i < selection.rangeCount; i += 1) {
+ documentFragment.appendChild(selection.getRangeAt(i).cloneContents());
+ }
+ if (documentFragment.textContent.length === 0) return null;
+
+ return documentFragment;
+};
+
+export const insertText = (target, text) => {
+ // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
+ const selectionStart = target.selectionStart;
+ const selectionEnd = target.selectionEnd;
+ const value = target.value;
+
+ const textBefore = value.substring(0, selectionStart);
+ const textAfter = value.substring(selectionEnd, value.length);
+
+ const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
+ const newText = textBefore + insertedText + textAfter;
+
+ // eslint-disable-next-line no-param-reassign
+ target.value = newText;
+ // eslint-disable-next-line no-param-reassign
+ target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
+
+ // Trigger autosave
+ target.dispatchEvent(new Event('input'));
+
+ // Trigger autosize
+ const event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ target.dispatchEvent(event);
+};
+
+export const nodeMatchesSelector = (node, selector) => {
+ const matches = Element.prototype.matches ||
+ Element.prototype.matchesSelector ||
+ Element.prototype.mozMatchesSelector ||
+ Element.prototype.msMatchesSelector ||
+ Element.prototype.oMatchesSelector ||
+ Element.prototype.webkitMatchesSelector;
+
+ if (matches) {
+ return matches.call(node, selector);
+ }
+
+ // IE11 doesn't support `node.matches(selector)`
+
+ let parentNode = node.parentNode;
+ if (!parentNode) {
+ parentNode = document.createElement('div');
+ // eslint-disable-next-line no-param-reassign
+ node = node.cloneNode(true);
+ parentNode.appendChild(node);
+ }
+
+ const matchingNodes = parentNode.querySelectorAll(selector);
+ return Array.prototype.indexOf.call(matchingNodes, node) !== -1;
+};
+
+/**
+ this will take in the headers from an API response and normalize them
+ this way we don't run into production issues when nginx gives us lowercased header keys
+*/
+export const normalizeHeaders = (headers) => {
+ const upperCaseHeaders = {};
+
+ Object.keys(headers || {}).forEach((e) => {
+ upperCaseHeaders[e.toUpperCase()] = headers[e];
+ });
+
+ return upperCaseHeaders;
+};
+
+/**
+ this will take in the getAllResponseHeaders result and normalize them
+ this way we don't run into production issues when nginx gives us lowercased header keys
+*/
+export const normalizeCRLFHeaders = (headers) => {
+ const headersObject = {};
+ const headersArray = headers.split('\n');
+
+ headersArray.forEach((header) => {
+ const keyValue = header.split(': ');
+ headersObject[keyValue[0]] = keyValue[1];
+ });
+
+ return normalizeHeaders(headersObject);
+};
+
+/**
+ * Parses pagination object string values into numbers.
+ *
+ * @param {Object} paginationInformation
+ * @returns {Object}
+ */
+export const parseIntPagination = paginationInformation => ({
+ perPage: parseInt(paginationInformation['X-PER-PAGE'], 10),
+ page: parseInt(paginationInformation['X-PAGE'], 10),
+ total: parseInt(paginationInformation['X-TOTAL'], 10),
+ totalPages: parseInt(paginationInformation['X-TOTAL-PAGES'], 10),
+ nextPage: parseInt(paginationInformation['X-NEXT-PAGE'], 10),
+ previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10),
+});
+
+/**
+ * Given a string of query parameters creates an object.
+ *
+ * @example
+ * `scope=all&page=2` -> { scope: 'all', page: '2'}
+ * `scope=all` -> { scope: 'all' }
+ * ``-> {}
+ * @param {String} query
+ * @returns {Object}
+ */
+export const parseQueryStringIntoObject = (query = '') => {
+ if (query === '') return {};
+
+ return query
+ .split('&')
+ .reduce((acc, element) => {
+ const val = element.split('=');
+ Object.assign(acc, {
+ [val[0]]: decodeURIComponent(val[1]),
});
- };
-
- w.gl.utils.extractLast = function(term) {
- return this.split(term).pop();
- };
-
- w.gl.utils.rstrip = function rstrip(val) {
- if (val) {
- return val.replace(/\s+$/, '');
+ return acc;
+ }, {});
+};
+
+/**
+ * Converts object with key-value pairs
+ * into query-param string
+ *
+ * @param {Object} params
+ */
+export const objectToQueryString = (params = {}) => Object.keys(params).map(param => `${param}=${params[param]}`).join('&');
+
+export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname);
+
+/**
+ * Based on the current location and the string parameters provided
+ * creates a new entry in the history without reloading the page.
+ *
+ * @param {String} param
+ */
+export const historyPushState = (newUrl) => {
+ window.history.pushState({}, document.title, newUrl);
+};
+
+/**
+ * Converts permission provided as strings to booleans.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export const convertPermissionToBoolean = permission => permission === 'true';
+
+/**
+ * Back Off exponential algorithm
+ * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error>
+ *
+ * @param {Function<next, stop>} fn function to be called
+ * @param {Number} timeout
+ * @return {Promise<Any, Error>}
+ * @example
+ * ```
+ * backOff(function (next, stop) {
+ * // Let's perform this function repeatedly for 60s or for the timeout provided.
+ *
+ * ourFunction()
+ * .then(function (result) {
+ * // continue if result is not what we need
+ * next();
+ *
+ * // when result is what we need let's stop with the repetions and jump out of the cycle
+ * stop(result);
+ * })
+ * .catch(function (error) {
+ * // if there is an error, we need to stop this with an error.
+ * stop(error);
+ * })
+ * }, 60000)
+ * .then(function (result) {})
+ * .catch(function (error) {
+ * // deal with errors passed to stop()
+ * })
+ * ```
+ */
+export const backOff = (fn, timeout = 60000) => {
+ const maxInterval = 32000;
+ let nextInterval = 2000;
+ let timeElapsed = 0;
+
+ return new Promise((resolve, reject) => {
+ const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg));
+
+ const next = () => {
+ if (timeElapsed < timeout) {
+ setTimeout(() => fn(next, stop), nextInterval);
+ timeElapsed += nextInterval;
+ nextInterval = Math.min(nextInterval + nextInterval, maxInterval);
} else {
- return val;
- }
- };
-
- gl.utils.updateTooltipTitle = function($tooltipEl, newTitle) {
- return $tooltipEl.attr('title', newTitle).tooltip('fixTitle');
- };
-
- w.gl.utils.disableButtonIfEmptyField = function(field_selector, button_selector, event_name) {
- event_name = event_name || 'input';
- var closest_submit, field, that;
- that = this;
- field = $(field_selector);
- closest_submit = field.closest('form').find(button_selector);
- if (this.rstrip(field.val()) === "") {
- closest_submit.disable();
+ reject(new Error('BACKOFF_TIMEOUT'));
}
- return field.on(event_name, function() {
- if (that.rstrip($(this).val()) === "") {
- return closest_submit.disable();
- } else {
- return closest_submit.enable();
- }
- });
};
- // automatically adjust scroll position for hash urls taking the height of the navbar into account
- // https://github.com/twitter/bootstrap/issues/1768
- w.gl.utils.handleLocationHash = function() {
- var hash = w.gl.utils.getLocationHash();
- if (!hash) return;
-
- // This is required to handle non-unicode characters in hash
- hash = decodeURIComponent(hash);
-
- var fixedTabs = document.querySelector('.js-tabs-affix');
- var fixedNav = document.querySelector('.navbar-gitlab');
-
- var adjustment = 0;
- if (fixedNav) adjustment -= fixedNav.offsetHeight;
-
- // scroll to user-generated markdown anchor if we cannot find a match
- if (document.getElementById(hash) === null) {
- var target = document.getElementById('user-content-' + hash);
- if (target && target.scrollIntoView) {
- target.scrollIntoView(true);
- window.scrollBy(0, adjustment);
- }
+ fn(next, stop);
+ });
+};
+
+export const setFavicon = (faviconPath) => {
+ const faviconEl = document.getElementById('favicon');
+ if (faviconEl && faviconPath) {
+ faviconEl.setAttribute('href', faviconPath);
+ }
+};
+
+export const resetFavicon = () => {
+ const faviconEl = document.getElementById('favicon');
+ const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null;
+ if (faviconEl) {
+ faviconEl.setAttribute('href', originalFavicon);
+ }
+};
+
+export const setCiStatusFavicon = pageUrl =>
+ axios.get(pageUrl)
+ .then(({ data }) => {
+ if (data && data.favicon) {
+ setFavicon(data.favicon);
} else {
- // only adjust for fixedTabs when not targeting user-generated content
- if (fixedTabs) {
- adjustment -= fixedTabs.offsetHeight;
- }
- window.scrollBy(0, adjustment);
- }
- };
-
- // Check if element scrolled into viewport from above or below
- // Courtesy http://stackoverflow.com/a/7557433/414749
- w.gl.utils.isInViewport = function(el) {
- var rect = el.getBoundingClientRect();
-
- return (
- rect.top >= 0 &&
- rect.left >= 0 &&
- rect.bottom <= window.innerHeight &&
- rect.right <= window.innerWidth
- );
- };
-
- gl.utils.getPagePath = function(index) {
- index = index || 0;
- return $('body').data('page').split(':')[index];
- };
-
- gl.utils.parseUrl = function (url) {
- var parser = document.createElement('a');
- parser.href = url;
- return parser;
- };
-
- gl.utils.parseUrlPathname = function (url) {
- var parsedUrl = gl.utils.parseUrl(url);
- // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11
- // We have to make sure we always have an absolute path.
- return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : '/' + parsedUrl.pathname;
- };
-
- 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('&').map((param) => {
- const split = param.split('=');
- return [decodeURI(split[0]), split[1]].join('=');
- });
- };
-
- gl.utils.isMetaKey = function(e) {
- return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
- };
-
- gl.utils.isMetaClick = function(e) {
- // Identify following special clicks
- // 1) Cmd + Click on Mac (e.metaKey)
- // 2) Ctrl + Click on PC (e.ctrlKey)
- // 3) Middle-click or Mouse Wheel Click (e.which is 2)
- return e.metaKey || e.ctrlKey || e.which === 2;
- };
-
- gl.utils.scrollToElement = function($el) {
- var top = $el.offset().top;
- gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height();
-
- return $('body, html').animate({
- scrollTop: top - (gl.mrTabsHeight)
- }, 200);
- };
-
- /**
- this will take in the `name` of the param you want to parse in the url
- if the name does not exist this function will return `null`
- otherwise it will return the value of the param key provided
- */
- w.gl.utils.getParameterByName = (name, parseUrl) => {
- const url = parseUrl || window.location.href;
- name = name.replace(/[[\]]/g, '\\$&');
- const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
- const results = regex.exec(url);
- if (!results) return null;
- if (!results[2]) return '';
- return decodeURIComponent(results[2].replace(/\+/g, ' '));
- };
-
- w.gl.utils.getSelectedFragment = () => {
- const selection = window.getSelection();
- if (selection.rangeCount === 0) return null;
- const documentFragment = document.createDocumentFragment();
- for (let i = 0; i < selection.rangeCount; i += 1) {
- documentFragment.appendChild(selection.getRangeAt(i).cloneContents());
- }
- if (documentFragment.textContent.length === 0) return null;
-
- return documentFragment;
- };
-
- w.gl.utils.insertText = (target, text) => {
- // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
-
- const selectionStart = target.selectionStart;
- const selectionEnd = target.selectionEnd;
- const value = target.value;
-
- const textBefore = value.substring(0, selectionStart);
- const textAfter = value.substring(selectionEnd, value.length);
-
- const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
- const newText = textBefore + insertedText + textAfter;
-
- target.value = newText;
- target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
-
- // Trigger autosave
- $(target).trigger('input');
-
- // Trigger autosize
- var event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- target.dispatchEvent(event);
- };
-
- w.gl.utils.nodeMatchesSelector = (node, selector) => {
- const matches = Element.prototype.matches ||
- Element.prototype.matchesSelector ||
- Element.prototype.mozMatchesSelector ||
- Element.prototype.msMatchesSelector ||
- Element.prototype.oMatchesSelector ||
- Element.prototype.webkitMatchesSelector;
-
- if (matches) {
- return matches.call(node, selector);
- }
-
- // IE11 doesn't support `node.matches(selector)`
-
- let parentNode = node.parentNode;
- if (!parentNode) {
- parentNode = document.createElement('div');
- node = node.cloneNode(true);
- parentNode.appendChild(node);
+ resetFavicon();
}
-
- const matchingNodes = parentNode.querySelectorAll(selector);
- return Array.prototype.indexOf.call(matchingNodes, node) !== -1;
- };
-
- /**
- this will take in the headers from an API response and normalize them
- this way we don't run into production issues when nginx gives us lowercased header keys
- */
- w.gl.utils.normalizeHeaders = (headers) => {
- const upperCaseHeaders = {};
-
- Object.keys(headers).forEach((e) => {
- upperCaseHeaders[e.toUpperCase()] = headers[e];
- });
-
- return upperCaseHeaders;
- };
-
- /**
- this will take in the getAllResponseHeaders result and normalize them
- this way we don't run into production issues when nginx gives us lowercased header keys
- */
- w.gl.utils.normalizeCRLFHeaders = (headers) => {
- const headersObject = {};
- const headersArray = headers.split('\n');
-
- headersArray.forEach((header) => {
- const keyValue = header.split(': ');
- headersObject[keyValue[0]] = keyValue[1];
- });
-
- return w.gl.utils.normalizeHeaders(headersObject);
- };
-
- /**
- * Parses pagination object string values into numbers.
- *
- * @param {Object} paginationInformation
- * @returns {Object}
- */
- w.gl.utils.parseIntPagination = paginationInformation => ({
- perPage: parseInt(paginationInformation['X-PER-PAGE'], 10),
- page: parseInt(paginationInformation['X-PAGE'], 10),
- total: parseInt(paginationInformation['X-TOTAL'], 10),
- totalPages: parseInt(paginationInformation['X-TOTAL-PAGES'], 10),
- nextPage: parseInt(paginationInformation['X-NEXT-PAGE'], 10),
- previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10),
+ })
+ .catch(resetFavicon);
+
+export const spriteIcon = (icon, className = '') => {
+ const classAttribute = className.length > 0 ? `class="${className}"` : '';
+
+ return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+};
+
+/**
+ * This method takes in object with snake_case property names
+ * and returns new object with camelCase property names
+ *
+ * Reasoning for this method is to ensure consistent property
+ * naming conventions across JS code.
+ */
+export const convertObjectPropsToCamelCase = (obj = {}) => {
+ if (obj === null) {
+ return {};
+ }
+
+ return Object.keys(obj).reduce((acc, prop) => {
+ const result = acc;
+
+ result[convertToCamelCase(prop)] = obj[prop];
+ return acc;
+ }, {});
+};
+
+export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
+
+export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
+ // Click a .js-select-on-focus field, select the contents
+ // Prevent a mouseup event from deselecting the input
+ $(selector).on('focusin', function selectOnFocusCallback() {
+ $(this).select().one('mouseup', (e) => {
+ e.preventDefault();
});
-
- /**
- * Updates the search parameter of a URL given the parameter and value provided.
- *
- * If no search params are present we'll add it.
- * If param for page is already present, we'll update it
- * If there are params but not for the given one, we'll add it at the end.
- * Returns the new search parameters.
- *
- * @param {String} param
- * @param {Number|String|Undefined|Null} value
- * @return {String}
- */
- w.gl.utils.setParamInURL = (param, value) => {
- let search;
- const locationSearch = window.location.search;
-
- if (locationSearch.length) {
- const parameters = locationSearch.substring(1, locationSearch.length)
- .split('&')
- .reduce((acc, element) => {
- const val = element.split('=');
- acc[val[0]] = decodeURIComponent(val[1]);
- return acc;
- }, {});
-
- parameters[param] = value;
-
- const toString = Object.keys(parameters)
- .map(val => `${val}=${encodeURIComponent(parameters[val])}`)
- .join('&');
-
- search = `?${toString}`;
- } else {
- search = `?${param}=${value}`;
- }
-
- return search;
- };
-
- /**
- * Converts permission provided as strings to booleans.
- *
- * @param {String} string
- * @returns {Boolean}
- */
- w.gl.utils.convertPermissionToBoolean = permission => permission === 'true';
-
- /**
- * Back Off exponential algorithm
- * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error>
- *
- * @param {Function<next, stop>} fn function to be called
- * @param {Number} timeout
- * @return {Promise<Any, Error>}
- * @example
- * ```
- * backOff(function (next, stop) {
- * // Let's perform this function repeatedly for 60s or for the timeout provided.
- *
- * ourFunction()
- * .then(function (result) {
- * // continue if result is not what we need
- * next();
- *
- * // when result is what we need let's stop with the repetions and jump out of the cycle
- * stop(result);
- * })
- * .catch(function (error) {
- * // if there is an error, we need to stop this with an error.
- * stop(error);
- * })
- * }, 60000)
- * .then(function (result) {})
- * .catch(function (error) {
- * // deal with errors passed to stop()
- * })
- * ```
- */
- w.gl.utils.backOff = (fn, timeout = 60000) => {
- const maxInterval = 32000;
- let nextInterval = 2000;
-
- const startTime = Date.now();
-
- return new Promise((resolve, reject) => {
- const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg));
-
- const next = () => {
- if (Date.now() - startTime < timeout) {
- setTimeout(fn.bind(null, next, stop), nextInterval);
- nextInterval = Math.min(nextInterval + nextInterval, maxInterval);
- } else {
- reject(new Error('BACKOFF_TIMEOUT'));
- }
- };
-
- fn(next, stop);
- });
- };
-
- w.gl.utils.setFavicon = (faviconPath) => {
- if (faviconEl && faviconPath) {
- faviconEl.setAttribute('href', faviconPath);
- }
- };
-
- w.gl.utils.resetFavicon = () => {
- if (faviconEl) {
- faviconEl.setAttribute('href', originalFavicon);
- }
- };
-
- w.gl.utils.setCiStatusFavicon = (pageUrl) => {
- $.ajax({
- url: pageUrl,
- dataType: 'json',
- success: function(data) {
- if (data && data.favicon) {
- gl.utils.setFavicon(data.favicon);
- } else {
- gl.utils.resetFavicon();
- }
- },
- error: function() {
- gl.utils.resetFavicon();
- }
- });
- };
- })(window);
-}).call(window);
+ });
+};
+
+window.gl = window.gl || {};
+window.gl.utils = {
+ ...(window.gl.utils || {}),
+ getPagePath,
+ isInGroupsPage,
+ isInProjectPage,
+ getProjectSlug,
+ getGroupSlug,
+ isInIssuePage,
+ ajaxGet,
+ rstrip,
+ updateTooltipTitle,
+ disableButtonIfEmptyField,
+ handleLocationHash,
+ isInViewport,
+ parseUrl,
+ parseUrlPathname,
+ getUrlParamsArray,
+ isMetaKey,
+ isMetaClick,
+ scrollToElement,
+ getParameterByName,
+ getSelectedFragment,
+ insertText,
+ nodeMatchesSelector,
+ spriteIcon,
+ imagePath,
+};
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 1e96c7ab5cd..9a61003ef30 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,2 +1,2 @@
-/* eslint-disable import/prefer-default-export */
export const BYTES_IN_KIB = 1024;
+export const HIDDEN_CLASS = 'hidden';
diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js
new file mode 100644
index 00000000000..ca9828c4682
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/csrf.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+
+/*
+This module provides easy access to the CSRF token and caches
+it for re-use. It also exposes some values commonly used in relation
+to the CSRF token (header key and headers object).
+
+If you need to refresh the csrfToken for some reason, just call `init` and
+then use the accessors as you would normally.
+
+If you need to compose a headers object, use the spread operator:
+
+```
+ headers: {
+ ...csrf.headers,
+ someOtherHeader: '12345',
+ }
+```
+
+see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf
+and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62
+ */
+
+const csrf = {
+ init() {
+ const tokenEl = document.querySelector('meta[name=csrf-token]');
+
+ if (tokenEl !== null) {
+ this.csrfToken = tokenEl.getAttribute('content');
+ } else {
+ this.csrfToken = null;
+ }
+ },
+
+ get token() {
+ return this.csrfToken;
+ },
+
+ get headerKey() {
+ return 'X-CSRF-Token';
+ },
+
+ get headers() {
+ if (this.csrfToken !== null) {
+ return {
+ [this.headerKey]: this.token,
+ };
+ }
+ return {};
+ },
+};
+
+csrf.init();
+
+// use our cached token for any $.rails-generated AJAX requests
+if ($.rails) {
+ $.rails.csrfToken = () => csrf.token;
+}
+
+export default csrf;
diff --git a/app/assets/javascripts/lib/utils/datefix.js b/app/assets/javascripts/lib/utils/datefix.js
new file mode 100644
index 00000000000..e98c9068367
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datefix.js
@@ -0,0 +1,29 @@
+
+export const pad = (val, len = 2) => (`0${val}`).slice(-len);
+
+/**
+ * Formats dates in Pickaday
+ * @param {String} dateString Date in yyyy-mm-dd format
+ * @return {Date} UTC format
+ */
+export const parsePikadayDate = (dateString) => {
+ const parts = dateString.split('-');
+ const year = parseInt(parts[0], 10);
+ const month = parseInt(parts[1] - 1, 10);
+ const day = parseInt(parts[2], 10);
+
+ return new Date(year, month, day);
+};
+
+/**
+ * Used `onSelect` method in pickaday
+ * @param {Date} date UTC format
+ * @return {String} Date formated in yyyy-mm-dd
+ */
+export const pikadayToString = (date) => {
+ const day = pad(date.getDate());
+ const month = pad(date.getMonth() + 1);
+ const year = date.getFullYear();
+
+ return `${year}-${month}-${day}`;
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index bfcc50996cc..c3d94d63c13 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,151 +1,156 @@
-/* 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 */
-
+import $ from 'jquery';
import timeago from 'timeago.js';
import dateFormat from 'vendor/date.format';
-
+import { pluralize } from './text_utility';
import {
- lang,
+ languageCode,
s__,
} from '../../locale';
window.timeago = timeago;
window.dateFormat = dateFormat;
-(function() {
- (function(w) {
- var base;
- var timeagoInstance;
-
- if (w.gl == null) {
- w.gl = {};
- }
- if ((base = w.gl).utils == null) {
- base.utils = {};
- }
- w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
-
- w.gl.utils.formatDate = function(datetime) {
- return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
- };
+/**
+ * Returns i18n month names array.
+ * If `abbreviated` is provided, returns abbreviated
+ * name.
+ *
+ * @param {Boolean} abbreviated
+ */
+const getMonthNames = (abbreviated) => {
+ if (abbreviated) {
+ return [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')];
+ }
+ return [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')];
+};
- w.gl.utils.getDayName = function(date) {
- return this.days[date.getDay()];
- };
+/**
+ * Given a date object returns the day of the week in English
+ * @param {date} date
+ * @returns {String}
+ */
+export const getDayName = date => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()];
- w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago = true) {
- $timeagoEls.each((i, el) => {
- el.setAttribute('title', el.getAttribute('title'));
+/**
+ * @example
+ * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000"
+ * @param {date} datetime
+ * @returns {String}
+ */
+export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
- if (setTimeago) {
- // Recreate with custom template
- $(el).tooltip({
- template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>'
- });
- }
+/**
+ * Timeago uses underscores instead of dashes to separate language from country code.
+ *
+ * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales
+ */
+const timeagoLanguageCode = languageCode().replace(/-/g, '_');
- el.classList.add('js-timeago-render');
- });
+let timeagoInstance;
- gl.utils.renderTimeago($timeagoEls);
+/**
+ * Sets a timeago Instance
+ */
+export function getTimeago() {
+ if (!timeagoInstance) {
+ const localeRemaining = function getLocaleRemaining(number, index) {
+ return [
+ [s__('Timeago|less than a minute ago'), s__('Timeago|in 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];
};
-
- w.gl.utils.getTimeago = function() {
- 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 [
- [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(lang, locale);
- timeago.register(`${lang}-remaining`, localeRemaining);
- timeagoInstance = timeago();
- }
-
- return timeagoInstance;
+ const locale = function getLocale(number, index) {
+ return [
+ [s__('Timeago|less than a minute ago'), s__('Timeago|in 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];
};
- w.gl.utils.timeFor = function(time, suffix, expiredLabel) {
- var timefor;
- if (!time) {
- return '';
- }
- if (new Date(time) < new Date()) {
- expiredLabel || (expiredLabel = s__('Timeago|Past due'));
- timefor = expiredLabel;
- } else {
- timefor = gl.utils.getTimeago().format(time, `${lang}-remaining`).trim();
- }
- return timefor;
- };
+ timeago.register(timeagoLanguageCode, locale);
+ timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining);
+ timeagoInstance = timeago();
+ }
- w.gl.utils.cachedTimeagoElements = [];
- w.gl.utils.renderTimeago = function($els) {
- if (!$els && !w.gl.utils.cachedTimeagoElements.length) {
- w.gl.utils.cachedTimeagoElements = [].slice.call(document.querySelectorAll('.js-timeago-render'));
- } else if ($els) {
- w.gl.utils.cachedTimeagoElements = w.gl.utils.cachedTimeagoElements.concat($els.toArray());
- }
+ return timeagoInstance;
+}
- w.gl.utils.cachedTimeagoElements.forEach(gl.utils.updateTimeagoText);
- };
+/**
+ * For the given element, renders a timeago instance.
+ * @param {jQuery} $els
+ */
+export const renderTimeago = ($els) => {
+ const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
- w.gl.utils.updateTimeagoText = function(el) {
- const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), lang);
+ // timeago.js sets timeouts internally for each timeago value to be updated in real time
+ getTimeago().render(timeagoEls, timeagoLanguageCode);
+};
- if (el.textContent !== formattedDate) {
- el.textContent = formattedDate;
- }
- };
+/**
+ * For the given elements, sets a tooltip with a formatted date.
+ * @param {jQuery}
+ * @param {Boolean} setTimeago
+ */
+export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
+ $timeagoEls.each((i, el) => {
+ if (setTimeago) {
+ // Recreate with custom template
+ $(el).tooltip({
+ template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
+ });
+ }
- w.gl.utils.initTimeagoTimeout = function() {
- gl.utils.renderTimeago();
+ el.classList.add('js-timeago-render');
+ });
- gl.utils.timeagoTimeout = setTimeout(gl.utils.initTimeagoTimeout, 1000);
- };
+ renderTimeago($timeagoEls);
+};
- w.gl.utils.getDayDifference = function(a, b) {
- var millisecondsPerDay = 1000 * 60 * 60 * 24;
- var date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
- var date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
+/**
+ * Returns remaining or passed time over the given time.
+ * @param {*} time
+ * @param {*} expiredLabel
+ */
+export const timeFor = (time, expiredLabel) => {
+ if (!time) {
+ return '';
+ }
+ if (new Date(time) < new Date()) {
+ return expiredLabel || s__('Timeago|Past due');
+ }
+ return getTimeago().format(time, `${timeagoLanguageCode}-remaining`).trim();
+};
- return Math.floor((date2 - date1) / millisecondsPerDay);
- };
- })(window);
-}).call(window);
+export const getDayDifference = (a, b) => {
+ const millisecondsPerDay = 1000 * 60 * 60 * 24;
+ const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
+ const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
+
+ return Math.floor((date2 - date1) / millisecondsPerDay);
+};
/**
* Port of ruby helper time_interval_in_words.
@@ -153,7 +158,6 @@ window.dateFormat = dateFormat;
* @param {Number} seconds
* @return {String}
*/
-// eslint-disable-next-line import/prefer-default-export
export function timeIntervalInWords(intervalInSeconds) {
const secondsInteger = parseInt(intervalInSeconds, 10);
const minutes = Math.floor(secondsInteger / 60);
@@ -161,9 +165,136 @@ export function timeIntervalInWords(intervalInSeconds) {
let text = '';
if (minutes >= 1) {
- text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`;
+ text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`;
} else {
- text = `${seconds} ${gl.text.pluralize('second', seconds)}`;
+ text = `${seconds} ${pluralize('second', seconds)}`;
}
return text;
}
+
+export function dateInWords(date, abbreviated = false, hideYear = false) {
+ if (!date) return date;
+
+ const month = date.getMonth();
+ const year = date.getFullYear();
+
+ const monthNames = [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')];
+ const monthNamesAbbr = [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')];
+
+ const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month];
+
+ if (hideYear) {
+ return `${monthName} ${date.getDate()}`;
+ }
+
+ return `${monthName} ${date.getDate()}, ${year}`;
+}
+
+/**
+ * Returns month name based on provided date.
+ *
+ * @param {Date} date
+ * @param {Boolean} abbreviated
+ */
+export const monthInWords = (date, abbreviated = false) => {
+ if (!date) {
+ return '';
+ }
+
+ return getMonthNames(abbreviated)[date.getMonth()];
+};
+
+/**
+ * Returns number of days in a month for provided date.
+ * courtesy: https://stacko(verflow.com/a/1185804/414749
+ *
+ * @param {Date} date
+ */
+export const totalDaysInMonth = (date) => {
+ if (!date) {
+ return 0;
+ }
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
+};
+
+/**
+ * Returns list of Dates referring to Sundays of the month
+ * based on provided date
+ *
+ * @param {Date} date
+ */
+export const getSundays = (date) => {
+ if (!date) {
+ return [];
+ }
+
+ const daysToSunday = ['Saturday', 'Friday', 'Thursday', 'Wednesday', 'Tuesday', 'Monday', 'Sunday'];
+
+ const month = date.getMonth();
+ const year = date.getFullYear();
+ const sundays = [];
+ const dateOfMonth = new Date(year, month, 1);
+
+ while (dateOfMonth.getMonth() === month) {
+ const dayName = getDayName(dateOfMonth);
+ if (dayName === 'Sunday') {
+ sundays.push(new Date(dateOfMonth.getTime()));
+ }
+
+ const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1;
+ dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday);
+ }
+
+ return sundays;
+};
+
+/**
+ * Returns list of Dates representing a timeframe of Months from month of provided date (inclusive)
+ * up to provided length
+ *
+ * For eg;
+ * If current month is January 2018 and `length` provided is `6`
+ * Then this method will return list of Date objects as follows;
+ *
+ * [ October 2017, November 2017, December 2017, January 2018, February 2018, March 2018 ]
+ *
+ * If current month is March 2018 and `length` provided is `3`
+ * Then this method will return list of Date objects as follows;
+ *
+ * [ February 2018, March 2018, April 2018 ]
+ *
+ * @param {Number} length
+ * @param {Date} date
+ */
+export const getTimeframeWindow = (length, date) => {
+ if (!length) {
+ return [];
+ }
+
+ const currentDate = date instanceof Date ? date : new Date();
+ const currentMonthIndex = Math.floor(length / 2);
+ const timeframe = [];
+
+ // Move date object backward to the first month of timeframe
+ currentDate.setDate(1);
+ currentDate.setMonth(currentDate.getMonth() - currentMonthIndex);
+
+ // Iterate and update date for the size of length
+ // and push date reference to timeframe list
+ for (let i = 0; i < length; i += 1) {
+ timeframe.push(new Date(currentDate.getTime()));
+ currentDate.setMonth(currentDate.getMonth() + 1);
+ }
+
+ // Change date of last timeframe item to last date of the month
+ timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1]));
+
+ return timeframe;
+};
+
+window.gl = window.gl || {};
+window.gl.utils = {
+ ...(window.gl.utils || {}),
+ getTimeago,
+ localTimeAgo,
+};
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
new file mode 100644
index 00000000000..de65ea15a60
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -0,0 +1,7 @@
+/* eslint-disable import/prefer-default-export */
+
+export const addClassIfElementExists = (element, className) => {
+ if (element) {
+ element.classList.add(className);
+ }
+};
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 415e50f32ae..bb151929431 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -3,6 +3,9 @@
*/
export default {
+ ABORTED: 0,
NO_CONTENT: 204,
OK: 200,
+ MULTIPLE_CHOICES: 300,
+ BAD_REQUEST: 400,
};
diff --git a/app/assets/javascripts/lib/utils/image_utility.js b/app/assets/javascripts/lib/utils/image_utility.js
new file mode 100644
index 00000000000..2977ec821cb
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/image_utility.js
@@ -0,0 +1,5 @@
+/* eslint-disable import/prefer-default-export */
+
+export function isImageLoaded(element) {
+ return element.complete && element.naturalHeight !== 0;
+}
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 57394097944..a02c79b787e 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -13,7 +13,7 @@ export function formatRelevantDigits(number) {
let relevantDigits = 0;
let formattedNumber = '';
if (!isNaN(Number(number))) {
- digitsLeft = number.split('.')[0];
+ digitsLeft = number.toString().split('.')[0];
switch (digitsLeft.length) {
case 1:
relevantDigits = 3;
@@ -52,3 +52,31 @@ export function bytesToKiB(number) {
export function bytesToMiB(number) {
return number / (BYTES_IN_KIB * BYTES_IN_KIB);
}
+
+/**
+ * Utility function that calculates GiB of the given bytes.
+ * @param {Number} number
+ * @returns {Number}
+ */
+export function bytesToGiB(number) {
+ return number / (BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB);
+}
+
+/**
+ * Port of rails number_to_human_size
+ * Formats the bytes in number into a more understandable
+ * representation (e.g., giving it 1500 yields 1.5 KB).
+ *
+ * @param {Number} size
+ * @returns {String}
+ */
+export function numberToHumanSize(size) {
+ if (size < BYTES_IN_KIB) {
+ return `${size} bytes`;
+ } else if (size < BYTES_IN_KIB * BYTES_IN_KIB) {
+ return `${bytesToKiB(size).toFixed(2)} KiB`;
+ } else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) {
+ return `${bytesToMiB(size).toFixed(2)} MiB`;
+ }
+ return `${bytesToGiB(size).toFixed(2)} GiB`;
+}
diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js
index e31cc5fbabe..7fca80c2fdb 100644
--- a/app/assets/javascripts/lib/utils/poll.js
+++ b/app/assets/javascripts/lib/utils/poll.js
@@ -1,8 +1,11 @@
import httpStatusCodes from './http_status';
+import { normalizeHeaders } from './common_utils';
/**
* Polling utility for handling realtime updates.
- * Service for vue resouce and method need to be provided as props
+ * Requirements: Promise based HTTP client
+ *
+ * Service for promise based http client and method need to be provided as props
*
* @example
* new Poll({
@@ -57,9 +60,8 @@ export default class Poll {
}
checkConditions(response) {
- const headers = gl.utils.normalizeHeaders(response.headers);
+ const headers = normalizeHeaders(response.headers);
const pollInterval = parseInt(headers[this.intervalHeader], 10);
-
if (pollInterval > 0 && response.status === httpStatusCodes.OK && this.canPoll) {
this.timeoutID = setTimeout(() => {
this.makeRequest();
@@ -81,6 +83,9 @@ export default class Poll {
})
.catch((error) => {
notificationCallback(false);
+ if (error.status === httpStatusCodes.ABORTED) {
+ return;
+ }
errorCallback(error);
});
}
@@ -98,7 +103,12 @@ export default class Poll {
/**
* Restarts polling after it has been stoped
*/
- restart() {
+ restart(options) {
+ // update data
+ if (options && options.data) {
+ this.options.data = options.data;
+ }
+
this.canPoll = true;
this.makeRequest();
}
diff --git a/app/assets/javascripts/lib/utils/pretty_time.js b/app/assets/javascripts/lib/utils/pretty_time.js
index ae397212e55..b1ffd797f7e 100644
--- a/app/assets/javascripts/lib/utils/pretty_time.js
+++ b/app/assets/javascripts/lib/utils/pretty_time.js
@@ -1,65 +1,61 @@
-(() => {
- /*
- * TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints,
- * stringifyTime condensed or non-condensed, abbreviateTimelengths)
- * */
-
- const utils = window.gl.utils = gl.utils || {};
- const prettyTime = utils.prettyTime = {
- /*
- * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
- * Seconds can be negative or positive, zero or non-zero.
- */
- parseSeconds(seconds) {
- const DAYS_PER_WEEK = 5;
- const HOURS_PER_DAY = 8;
- const MINUTES_PER_HOUR = 60;
- const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
- const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
-
- const timePeriodConstraints = {
- weeks: MINUTES_PER_WEEK,
- days: MINUTES_PER_DAY,
- hours: MINUTES_PER_HOUR,
- minutes: 1,
- };
+import _ from 'underscore';
+
+/*
+ * TODO: Make these methods more configurable (e.g. stringifyTime condensed or
+ * non-condensed, abbreviateTimelengths)
+ * */
+
+/*
+ * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
+ * Seconds can be negative or positive, zero or non-zero. Can be configured for any day
+ * or week length.
+*/
+
+export function parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) {
+ const DAYS_PER_WEEK = daysPerWeek;
+ const HOURS_PER_DAY = hoursPerDay;
+ const MINUTES_PER_HOUR = 60;
+ const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
+
+ const timePeriodConstraints = {
+ weeks: MINUTES_PER_WEEK,
+ days: MINUTES_PER_DAY,
+ hours: MINUTES_PER_HOUR,
+ minutes: 1,
+ };
- let unorderedMinutes = prettyTime.secondsToMinutes(seconds);
+ let unorderedMinutes = Math.abs(seconds / MINUTES_PER_HOUR);
- return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
- const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
+ return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
+ const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
- unorderedMinutes -= (periodCount * minutesPerPeriod);
+ unorderedMinutes -= (periodCount * minutesPerPeriod);
- return periodCount;
- });
- },
+ return periodCount;
+ });
+}
- /*
- * Accepts a timeObject and returns a condensed string representation of it
- * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
- */
+/*
+* Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it
+* (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
+*/
- stringifyTime(timeObject) {
- const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
- const isNonZero = !!unitValue;
- return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
- }, '').trim();
- return reducedTime.length ? reducedTime : '0m';
- },
+export function stringifyTime(timeObject) {
+ const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
+ const isNonZero = !!unitValue;
+ return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
+ }, '').trim();
+ return reducedTime.length ? reducedTime : '0m';
+}
- /*
- * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
- * the first non-zero unit/value pair.
- */
+/*
+* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
+* the first non-zero unit/value pair.
+*/
- abbreviateTime(timeStr) {
- return timeStr.split(' ')
- .filter(unitStr => unitStr.charAt(0) !== '0')[0];
- },
+export function abbreviateTime(timeStr) {
+ return timeStr.split(' ')
+ .filter(unitStr => unitStr.charAt(0) !== '0')[0];
+}
- secondsToMinutes(seconds) {
- return Math.abs(seconds / 60);
- },
- };
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js
new file mode 100644
index 00000000000..098afcfa1b4
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/sticky.js
@@ -0,0 +1,39 @@
+export const createPlaceholder = () => {
+ const placeholder = document.createElement('div');
+ placeholder.classList.add('sticky-placeholder');
+
+ return placeholder;
+};
+
+export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => {
+ const top = Math.floor(el.offsetTop - scrollY);
+
+ if (top <= stickyTop && !el.classList.contains('is-stuck')) {
+ const placeholder = insertPlaceholder ? createPlaceholder() : null;
+ const heightBefore = el.offsetHeight;
+
+ el.classList.add('is-stuck');
+
+ if (insertPlaceholder) {
+ el.parentNode.insertBefore(placeholder, el.nextElementSibling);
+
+ placeholder.style.height = `${heightBefore - el.offsetHeight}px`;
+ }
+ } else if (top > stickyTop && el.classList.contains('is-stuck')) {
+ el.classList.remove('is-stuck');
+
+ if (insertPlaceholder && el.nextElementSibling && el.nextElementSibling.classList.contains('sticky-placeholder')) {
+ el.nextElementSibling.remove();
+ }
+ }
+};
+
+export default (el, stickyTop, insertPlaceholder = true) => {
+ if (!el) return;
+
+ if (typeof CSS === 'undefined' || !(CSS.supports('(position: -webkit-sticky) or (position: sticky)'))) return;
+
+ document.addEventListener('scroll', () => isSticky(el, window.scrollY, stickyTop, insertPlaceholder), {
+ passive: true,
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
new file mode 100644
index 00000000000..5a16adea4dc
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -0,0 +1,136 @@
+/* eslint-disable import/prefer-default-export, 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 */
+import $ from 'jquery';
+import { insertText } from '~/lib/utils/common_utils';
+
+function selectedText(text, textarea) {
+ return text.substring(textarea.selectionStart, textarea.selectionEnd);
+}
+
+function lineBefore(text, textarea) {
+ var split;
+ split = text.substring(0, textarea.selectionStart).trim().split('\n');
+ return split[split.length - 1];
+}
+
+function lineAfter(text, textarea) {
+ return text.substring(textarea.selectionEnd).trim().split('\n')[0];
+}
+
+function blockTagText(text, textArea, blockTag, selected) {
+ const before = lineBefore(text, textArea);
+ const after = lineAfter(text, textArea);
+ if (before === blockTag && after === blockTag) {
+ // To remove the block tag we have to select the line before & after
+ if (blockTag != null) {
+ textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
+ textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
+ }
+ return selected;
+ } else {
+ return blockTag + "\n" + selected + "\n" + blockTag;
+ }
+}
+
+function moveCursor(textArea, tag, wrapped, removedLastNewLine) {
+ var pos;
+ if (!textArea.setSelectionRange) {
+ return;
+ }
+ if (textArea.selectionStart === textArea.selectionEnd) {
+ if (wrapped) {
+ pos = textArea.selectionStart - tag.length;
+ } else {
+ pos = textArea.selectionStart;
+ }
+
+ if (removedLastNewLine) {
+ pos -= 1;
+ }
+
+ return textArea.setSelectionRange(pos, pos);
+ }
+}
+
+export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap) {
+ var textToInsert, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
+ removedLastNewLine = false;
+ removedFirstNewLine = false;
+ currentLineEmpty = false;
+
+ // Remove the first newline
+ if (selected.indexOf('\n') === 0) {
+ removedFirstNewLine = true;
+ selected = selected.replace(/\n+/, '');
+ }
+
+ // Remove the last newline
+ if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
+ removedLastNewLine = true;
+ selected = selected.replace(/\n$/, '');
+ }
+
+ selectedSplit = selected.split('\n');
+
+ if (!wrap) {
+ lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
+
+ // Check whether the current line is empty or consists only of spaces(=handle as empty)
+ if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
+ currentLineEmpty = true;
+ }
+ }
+
+ startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
+
+ if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
+ if (blockTag != null && blockTag !== '') {
+ textToInsert = blockTagText(text, textArea, blockTag, selected);
+ } else {
+ textToInsert = selectedSplit.map(function(val) {
+ if (val.indexOf(tag) === 0) {
+ return "" + (val.replace(tag, ''));
+ } else {
+ return "" + tag + val;
+ }
+ }).join('\n');
+ }
+ } else {
+ textToInsert = "" + startChar + tag + selected + (wrap ? tag : ' ');
+ }
+
+ if (removedFirstNewLine) {
+ textToInsert = '\n' + textToInsert;
+ }
+
+ if (removedLastNewLine) {
+ textToInsert += '\n';
+ }
+
+ insertText(textArea, textToInsert);
+ return moveCursor(textArea, tag, wrap, removedLastNewLine);
+}
+
+function updateText(textArea, tag, blockTag, wrap) {
+ var $textArea, selected, text;
+ $textArea = $(textArea);
+ textArea = $textArea.get(0);
+ text = $textArea.val();
+ selected = selectedText(text, textArea);
+ $textArea.focus();
+ return insertMarkdownText(textArea, text, tag, blockTag, selected, wrap);
+}
+
+function replaceRange(s, start, end, substitute) {
+ return s.substring(0, start) + substitute + s.substring(end);
+}
+
+export function addMarkdownListeners(form) {
+ return $('.js-md', form).off('click').on('click', function() {
+ const $this = $(this);
+ return updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend'));
+ });
+}
+
+export function removeMarkdownListeners(form) {
+ return $('.js-md', form).off('click');
+}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 601d01e1be1..94d03621bff 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,189 +1,82 @@
-/* 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 */
+/**
+ * Adds a , to a string composed by numbers, at every 3 chars.
+ *
+ * 2333 -> 2,333
+ * 232324 -> 232,324
+ *
+ * @param {String} text
+ * @returns {String}
+ */
+export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
-import 'vendor/latinise';
+/**
+ * Returns '99+' for numbers bigger than 99.
+ *
+ * @param {Number} count
+ * @return {Number|String}
+ */
+export const highCountTrim = count => (count > 99 ? '99+' : count);
-var base;
-var w = window;
-if (w.gl == null) {
- w.gl = {};
-}
-if ((base = w.gl).text == null) {
- base.text = {};
-}
-gl.text.addDelimiter = function(text) {
- return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text;
-};
-gl.text.highCountTrim = function(count) {
- return count > 99 ? '99+' : count;
-};
-gl.text.randomString = function() {
- return Math.random().toString(36).substring(7);
-};
-gl.text.replaceRange = function(s, start, end, substitute) {
- return s.substring(0, start) + substitute + s.substring(end);
-};
-gl.text.getTextWidth = function(text, font) {
- /**
- * Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
- *
- * @param {String} text The text to be rendered.
- * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
- *
- * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
- */
- // re-use canvas object for better performance
- var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas'));
- var context = canvas.getContext('2d');
- context.font = font;
- return context.measureText(text).width;
-};
-gl.text.selectedText = function(text, textarea) {
- return text.substring(textarea.selectionStart, textarea.selectionEnd);
-};
-gl.text.lineBefore = function(text, textarea) {
- var split;
- split = text.substring(0, textarea.selectionStart).trim().split('\n');
- return split[split.length - 1];
-};
-gl.text.lineAfter = function(text, textarea) {
- return text.substring(textarea.selectionEnd).trim().split('\n')[0];
-};
-gl.text.blockTagText = function(text, textArea, blockTag, selected) {
- var lineAfter, lineBefore;
- lineBefore = this.lineBefore(text, textArea);
- lineAfter = this.lineAfter(text, textArea);
- if (lineBefore === blockTag && lineAfter === blockTag) {
- // To remove the block tag we have to select the line before & after
- if (blockTag != null) {
- textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
- textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
- }
- return selected;
- } else {
- return blockTag + "\n" + selected + "\n" + blockTag;
- }
-};
-gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
- var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
- removedLastNewLine = false;
- removedFirstNewLine = false;
- currentLineEmpty = false;
-
- // Remove the first newline
- if (selected.indexOf('\n') === 0) {
- removedFirstNewLine = true;
- selected = selected.replace(/\n+/, '');
- }
-
- // Remove the last newline
- if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
- removedLastNewLine = true;
- selected = selected.replace(/\n$/, '');
- }
+/**
+ * Converts first char to uppercase and replaces undercores with spaces
+ * @param {String} string
+ * @requires {String}
+ */
+export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
- selectedSplit = selected.split('\n');
+/**
+ * Adds an 's' to the end of the string when count is bigger than 0
+ * @param {String} str
+ * @param {Number} count
+ * @returns {String}
+ */
+export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : '');
- if (!wrap) {
- lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
+/**
+ * Replaces underscores with dashes
+ * @param {*} str
+ * @returns {String}
+ */
+export const dasherize = str => str.replace(/[_\s]+/g, '-');
- // Check whether the current line is empty or consists only of spaces(=handle as empty)
- if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
- currentLineEmpty = true;
- }
- }
+/**
+ * Removes accents and converts to lower case
+ * @param {String} str
+ * @returns {String}
+ */
+export const slugify = str => str.trim().toLowerCase();
- startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
+/**
+ * Truncates given text
+ *
+ * @param {String} string
+ * @param {Number} maxLength
+ * @returns {String}
+ */
+export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
- if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) {
- if (blockTag != null) {
- insertText = this.blockTagText(text, textArea, blockTag, selected);
- } else {
- insertText = selectedSplit.map(function(val) {
- if (val.indexOf(tag) === 0) {
- return "" + (val.replace(tag, ''));
- } else {
- return "" + tag + val;
- }
- }).join('\n');
- }
- } else {
- insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
- }
-
- if (removedFirstNewLine) {
- insertText = '\n' + insertText;
- }
-
- if (removedLastNewLine) {
- insertText += '\n';
- }
-
- if (document.queryCommandSupported('insertText')) {
- inserted = document.execCommand('insertText', false, insertText);
- }
- if (!inserted) {
- try {
- document.execCommand("ms-beginUndoUnit");
- } catch (error) {}
- textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
- try {
- document.execCommand("ms-endUndoUnit");
- } catch (error) {}
- }
- return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
-};
-gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
- var pos;
- if (!textArea.setSelectionRange) {
- return;
- }
- if (textArea.selectionStart === textArea.selectionEnd) {
- if (wrapped) {
- pos = textArea.selectionStart - tag.length;
- } else {
- pos = textArea.selectionStart;
- }
+/**
+ * Capitalizes first character
+ *
+ * @param {String} text
+ * @return {String}
+ */
+export function capitalizeFirstCharacter(text) {
+ return `${text[0].toUpperCase()}${text.slice(1)}`;
+}
- if (removedLastNewLine) {
- pos -= 1;
- }
+/**
+ * Replaces all html tags from a string with the given replacement.
+ *
+ * @param {String} string
+ * @param {*} replace
+ * @returns {String}
+ */
+export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, replace);
- return textArea.setSelectionRange(pos, pos);
- }
-};
-gl.text.updateText = function(textArea, tag, blockTag, wrap) {
- var $textArea, selected, text;
- $textArea = $(textArea);
- textArea = $textArea.get(0);
- text = $textArea.val();
- selected = this.selectedText(text, textArea);
- $textArea.focus();
- return this.insertText(textArea, text, tag, blockTag, selected, wrap);
-};
-gl.text.init = function(form) {
- var self;
- self = this;
- return $('.js-md', form).off('click').on('click', function() {
- var $this;
- $this = $(this);
- return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
- });
-};
-gl.text.removeListeners = function(form) {
- return $('.js-md', form).off('click');
-};
-gl.text.humanize = function(string) {
- return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
-};
-gl.text.pluralize = function(str, count) {
- return str + (count > 1 || count === 0 ? 's' : '');
-};
-gl.text.truncate = function(string, maxLength) {
- return string.substr(0, (maxLength - 3)) + '...';
-};
-gl.text.dasherize = function(str) {
- return str.replace(/[_\s]+/g, '-');
-};
-gl.text.slugify = function(str) {
- return str.trim().toLowerCase().latinise();
-};
+/**
+ * Converts snake_case string to camelCase
+ *
+ * @param {*} string
+ */
+export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
diff --git a/app/assets/javascripts/lib/utils/tick_formats.js b/app/assets/javascripts/lib/utils/tick_formats.js
new file mode 100644
index 00000000000..0c10a85e336
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/tick_formats.js
@@ -0,0 +1,39 @@
+import { createDateTimeFormat } from '../../locale';
+
+let dateTimeFormats;
+
+export const initDateFormats = () => {
+ const dayFormat = createDateTimeFormat({ month: 'short', day: 'numeric' });
+ const monthFormat = createDateTimeFormat({ month: 'long' });
+ const yearFormat = createDateTimeFormat({ year: 'numeric' });
+
+ dateTimeFormats = {
+ dayFormat,
+ monthFormat,
+ yearFormat,
+ };
+};
+
+initDateFormats();
+
+/**
+ Formats a localized date in way that it can be used for d3.js axis.tickFormat().
+
+ That is, it displays
+ - 4-digit for first of January
+ - full month name for first of every month
+ - day and abbreviated month otherwise
+
+ see also https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Axes.md#tickFormat
+ */
+export const dateTickFormat = (date) => {
+ if (date.getDate() !== 1) {
+ return dateTimeFormats.dayFormat.format(date);
+ }
+
+ if (date.getMonth() > 0) {
+ return dateTimeFormats.monthFormat.format(date);
+ }
+
+ return dateTimeFormats.yearFormat.format(date);
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 3328ff9cc23..a266bb6771f 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,91 +1,85 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */
-var base;
-var w = window;
-if (w.gl == null) {
- w.gl = {};
-}
-if ((base = w.gl).utils == null) {
- base.utils = {};
-}
// Returns an array containing the value(s) of the
// of the key passed as an argument
-w.gl.utils.getParameterValues = function(sParam) {
- var i, sPageURL, sParameterName, sURLVariables, values;
- sPageURL = decodeURIComponent(window.location.search.substring(1));
- sURLVariables = sPageURL.split('&');
- sParameterName = void 0;
- values = [];
- i = 0;
- while (i < sURLVariables.length) {
- sParameterName = sURLVariables[i].split('=');
+export function getParameterValues(sParam) {
+ const sPageURL = decodeURIComponent(window.location.search.substring(1));
+
+ return sPageURL.split('&').reduce((acc, urlParam) => {
+ const sParameterName = urlParam.split('=');
+
if (sParameterName[0] === sParam) {
- values.push(sParameterName[1].replace(/\+/g, ' '));
+ acc.push(sParameterName[1].replace(/\+/g, ' '));
}
- i += 1;
- }
- return values;
-};
+
+ return acc;
+ }, []);
+}
+
// @param {Object} params - url keys and value to merge
// @param {String} url
-w.gl.utils.mergeUrlParams = function(params, url) {
- var lastChar, newUrl, paramName, paramValue, pattern;
- newUrl = decodeURIComponent(url);
- for (paramName in params) {
- paramValue = params[paramName];
- pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)");
- if (paramValue == null) {
- newUrl = newUrl.replace(pattern, '');
+export function mergeUrlParams(params, url) {
+ let newUrl = Object.keys(params).reduce((acc, paramName) => {
+ const paramValue = encodeURIComponent(params[paramName]);
+ const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`);
+
+ if (paramValue === null) {
+ return acc.replace(pattern, '');
} else if (url.search(pattern) !== -1) {
- newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2");
- } else {
- newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue;
+ return acc.replace(pattern, `$1${paramValue}$2`);
}
- }
+
+ return `${acc}${acc.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`;
+ }, decodeURIComponent(url));
+
// Remove a trailing ampersand
- lastChar = newUrl[newUrl.length - 1];
+ const lastChar = newUrl[newUrl.length - 1];
+
if (lastChar === '&') {
newUrl = newUrl.slice(0, -1);
}
+
return newUrl;
-};
-// removes parameter query string from url. returns the modified url
-w.gl.utils.removeParamQueryString = function(url, param) {
- var urlVariables, variables;
- url = decodeURIComponent(url);
- urlVariables = url.split('&');
- return ((function() {
- var j, len, results;
- results = [];
- for (j = 0, len = urlVariables.length; j < len; j += 1) {
- variables = urlVariables[j];
- if (variables.indexOf(param) === -1) {
- results.push(variables);
- }
- }
- return results;
- })()).join('&');
-};
-w.gl.utils.removeParams = (params) => {
+}
+
+export function removeParamQueryString(url, param) {
+ const decodedUrl = decodeURIComponent(url);
+ const urlVariables = decodedUrl.split('&');
+
+ return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&');
+}
+
+export function removeParams(params) {
const url = document.createElement('a');
url.href = window.location.href;
+
params.forEach((param) => {
- url.search = w.gl.utils.removeParamQueryString(url.search, param);
+ url.search = removeParamQueryString(url.search, param);
});
+
return url.href;
-};
-w.gl.utils.getLocationHash = function(url) {
- var hashIndex;
- if (typeof url === 'undefined') {
- // Note: We can't use window.location.hash here because it's
- // not consistent across browsers - Firefox will pre-decode it
- url = window.location.href;
- }
- hashIndex = url.indexOf('#');
+}
+
+export function getLocationHash(url = window.location.href) {
+ const hashIndex = url.indexOf('#');
+
return hashIndex === -1 ? null : url.substring(hashIndex + 1);
-};
+}
+
+export function visitUrl(url, external = false) {
+ if (external) {
+ // Simulate `target="blank" rel="noopener noreferrer"`
+ // See https://mathiasbynens.github.io/rel-noopener/
+ const otherWindow = window.open();
+ otherWindow.opener = null;
+ otherWindow.location = url;
+ } else {
+ window.location.href = url;
+ }
+}
-w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href);
+export function refreshCurrentPage() {
+ visitUrl(window.location.href);
+}
-w.gl.utils.visitUrl = (url) => {
- document.location.href = url;
-};
+export function redirectTo(url) {
+ return window.location.assign(url);
+}
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
index 88f8a622c00..b01ec6b81a3 100644
--- a/app/assets/javascripts/lib/utils/users_cache.js
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -8,16 +8,16 @@ class UsersCache extends Cache {
}
return Api.users('', { username })
- .then((users) => {
- if (!users.length) {
+ .then(({ data }) => {
+ if (!data.length) {
throw new Error(`User "${username}" could not be found!`);
}
- if (users.length > 1) {
+ if (data.length > 1) {
throw new Error(`Expected username "${username}" to be unique!`);
}
- const user = users[0];
+ const user = data[0];
this.internalStorage[username] = user;
return user;
});
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index 7400c22543f..f2323f57455 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,5 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-underscore-dangle, no-param-reassign, prefer-template, quotes, comma-dangle, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, no-else-return, max-len */
+import $ from 'jquery';
+
// LineHighlighter
//
// Handles single- and multi-line selection and highlight for blob views.
@@ -28,148 +30,151 @@
// </div>
// </div>
//
-(function() {
- this.LineHighlighter = (function() {
- // CSS class applied to highlighted lines
- LineHighlighter.prototype.highlightClass = 'hll';
-
- // Internal copy of location.hash so we're not dependent on `location` in tests
- LineHighlighter.prototype._hash = '';
-
- function LineHighlighter(hash) {
- if (hash == null) {
- // Initialize a LineHighlighter object
- //
- // hash - String URL hash for dependency injection in tests
- hash = location.hash;
- }
- 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();
- this.highlightHash();
- }
- LineHighlighter.prototype.bindEvents = function() {
- const $fileHolder = $('.file-holder');
- $fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
- $fileHolder.on('highlight:line', this.highlightHash);
- };
-
- LineHighlighter.prototype.highlightHash = function() {
- var range;
- if (this._hash !== '') {
- range = this.hashToRange(this._hash);
- if (range[0]) {
- this.highlightRange(range);
- $.scrollTo("#L" + range[0], {
- // Scroll to the first highlighted line on initial load
- // Offset -50 for the sticky top bar, and another -100 for some context
- offset: -150
- });
- }
- }
- };
-
- LineHighlighter.prototype.clickHandler = function(event) {
- var current, lineNumber, range;
- event.preventDefault();
- this.clearHighlight();
- lineNumber = $(event.target).closest('a').data('line-number');
- current = this.hashToRange(this._hash);
- if (!(current[0] && event.shiftKey)) {
- // If there's no current selection, or there is but Shift wasn't held,
- // treat this like a single-line selection.
- this.setHash(lineNumber);
- return this.highlightLine(lineNumber);
- } else if (event.shiftKey) {
- if (lineNumber < current[0]) {
- range = [lineNumber, current[0]];
- } else {
- range = [current[0], lineNumber];
- }
- this.setHash(range[0], range[1]);
- return this.highlightRange(range);
- }
- };
-
- LineHighlighter.prototype.clearHighlight = function() {
- return $("." + this.highlightClass).removeClass(this.highlightClass);
- // Unhighlight previously highlighted lines
- };
-
- // Convert a URL hash String into line numbers
- //
- // hash - Hash String
- //
- // Examples:
- //
- // hashToRange('#L5') # => [5, null]
- // hashToRange('#L5-15') # => [5, 15]
- // hashToRange('#foo') # => [null, null]
- //
- // Returns an Array
- LineHighlighter.prototype.hashToRange = function(hash) {
- var first, last, matches;
- // ?L(\d+)(?:-(\d+))?$/)
- matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
- if (matches && matches.length) {
- first = parseInt(matches[1], 10);
- last = matches[2] ? parseInt(matches[2], 10) : null;
- return [first, last];
- } else {
- return [null, null];
- }
- };
-
- // Highlight a single line
- //
- // lineNumber - Line number to highlight
- LineHighlighter.prototype.highlightLine = function(lineNumber) {
- return $("#LC" + lineNumber).addClass(this.highlightClass);
- };
-
- // Highlight all lines within a range
- //
- // range - Array containing the starting and ending line numbers
- LineHighlighter.prototype.highlightRange = function(range) {
- var i, lineNumber, ref, ref1, results;
- if (range[1]) {
- results = [];
- for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? (i += 1) : (i -= 1)) {
- results.push(this.highlightLine(lineNumber));
- }
- return results;
- } else {
- return this.highlightLine(range[0]);
- }
- };
+const LineHighlighter = function(options = {}) {
+ options.highlightLineClass = options.highlightLineClass || 'hll';
+ options.fileHolderSelector = options.fileHolderSelector || '.file-holder';
+ options.scrollFileHolder = options.scrollFileHolder || false;
+ options.hash = options.hash || location.hash;
- // Set the URL hash string
- LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) {
- var hash;
- if (lastLineNumber) {
- hash = "#L" + firstLineNumber + "-" + lastLineNumber;
+ this.options = options;
+ this._hash = options.hash;
+ this.highlightLineClass = options.highlightLineClass;
+ 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.bindEvents();
+ this.highlightHash();
+};
+
+LineHighlighter.prototype.bindEvents = function() {
+ const $fileHolder = $(this.options.fileHolderSelector);
+
+ $fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
+ $fileHolder.on('highlight:line', this.highlightHash);
+};
+
+LineHighlighter.prototype.highlightHash = function(newHash) {
+ let range;
+ if (newHash && typeof newHash === 'string') this._hash = newHash;
+
+ this.clearHighlight();
+
+ if (this._hash !== '') {
+ range = this.hashToRange(this._hash);
+ if (range[0]) {
+ this.highlightRange(range);
+ const lineSelector = `#L${range[0]}`;
+ const scrollOptions = {
+ // Scroll to the first highlighted line on initial load
+ // Offset -50 for the sticky top bar, and another -100 for some context
+ offset: -150
+ };
+ if (this.options.scrollFileHolder) {
+ $(this.options.fileHolderSelector).scrollTo(lineSelector, scrollOptions);
} else {
- hash = "#L" + firstLineNumber;
+ $.scrollTo(lineSelector, scrollOptions);
}
- this._hash = hash;
- return this.__setLocationHash__(hash);
- };
-
- // Make the actual hash change in the browser
- //
- // This method is stubbed in tests.
- LineHighlighter.prototype.__setLocationHash__ = function(value) {
- return history.pushState({
- url: value
- // We're using pushState instead of assigning location.hash directly to
- // prevent the page from scrolling on the hashchange event
- }, document.title, value);
- };
-
- return LineHighlighter;
- })();
-}).call(window);
+ }
+ }
+};
+
+LineHighlighter.prototype.clickHandler = function(event) {
+ var current, lineNumber, range;
+ event.preventDefault();
+ this.clearHighlight();
+ lineNumber = $(event.target).closest('a').data('lineNumber');
+ current = this.hashToRange(this._hash);
+ if (!(current[0] && event.shiftKey)) {
+ // If there's no current selection, or there is but Shift wasn't held,
+ // treat this like a single-line selection.
+ this.setHash(lineNumber);
+ return this.highlightLine(lineNumber);
+ } else if (event.shiftKey) {
+ if (lineNumber < current[0]) {
+ range = [lineNumber, current[0]];
+ } else {
+ range = [current[0], lineNumber];
+ }
+ this.setHash(range[0], range[1]);
+ return this.highlightRange(range);
+ }
+};
+
+LineHighlighter.prototype.clearHighlight = function() {
+ return $("." + this.highlightLineClass).removeClass(this.highlightLineClass);
+};
+
+// Convert a URL hash String into line numbers
+//
+// hash - Hash String
+//
+// Examples:
+//
+// hashToRange('#L5') # => [5, null]
+// hashToRange('#L5-15') # => [5, 15]
+// hashToRange('#foo') # => [null, null]
+//
+// Returns an Array
+LineHighlighter.prototype.hashToRange = function(hash) {
+ var first, last, matches;
+ // ?L(\d+)(?:-(\d+))?$/)
+ matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
+ if (matches && matches.length) {
+ first = parseInt(matches[1], 10);
+ last = matches[2] ? parseInt(matches[2], 10) : null;
+ return [first, last];
+ } else {
+ return [null, null];
+ }
+};
+
+// Highlight a single line
+//
+// lineNumber - Line number to highlight
+LineHighlighter.prototype.highlightLine = function(lineNumber) {
+ return $("#LC" + lineNumber).addClass(this.highlightLineClass);
+};
+
+// Highlight all lines within a range
+//
+// range - Array containing the starting and ending line numbers
+LineHighlighter.prototype.highlightRange = function(range) {
+ var i, lineNumber, ref, ref1, results;
+ if (range[1]) {
+ results = [];
+ for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? (i += 1) : (i -= 1)) {
+ results.push(this.highlightLine(lineNumber));
+ }
+ return results;
+ } else {
+ return this.highlightLine(range[0]);
+ }
+};
+
+// Set the URL hash string
+LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) {
+ var hash;
+ if (lastLineNumber) {
+ hash = "#L" + firstLineNumber + "-" + lastLineNumber;
+ } else {
+ hash = "#L" + firstLineNumber;
+ }
+ this._hash = hash;
+ return this.__setLocationHash__(hash);
+};
+
+// Make the actual hash change in the browser
+//
+// This method is stubbed in tests.
+LineHighlighter.prototype.__setLocationHash__ = function(value) {
+ return history.pushState({
+ url: value
+ // We're using pushState instead of assigning location.hash directly to
+ // prevent the page from scrolling on the hashchange event
+ }, document.title, value);
+};
+
+export default LineHighlighter;
diff --git a/app/assets/javascripts/locale/bg/app.js b/app/assets/javascripts/locale/bg/app.js
deleted file mode 100644
index ba56c0bea25..00000000000
--- a/app/assets/javascripts/locale/bg/app.js
+++ /dev/null
@@ -1 +0,0 @@
-var locales = locales || {}; locales['bg'] = {"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","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-05 09:40-0400","Last-Translator":"Lyubomir Vasilev <lyubomirv@abv.bg>","Language-Team":"Bulgarian","Language":"bg","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=2; plural=(n != 1)","lang":"bg","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":["Последния %d ден","Последните %d дни"],"Limited to showing %d event at most":["Ограничено до показване на последното %d събитие","Ограничено до показване на последните %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 събитие","Показване на %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/de/app.js b/app/assets/javascripts/locale/de/app.js
deleted file mode 100644
index e7d2b174405..00000000000
--- a/app/assets/javascripts/locale/de/app.js
+++ /dev/null
@@ -1 +0,0 @@
-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;"},"Are you sure you want to delete this pipeline schedule?":[""],"ByAuthor|by":["Von"],"Cancel":[""],"Commit":["Commit","Commits"],"Cron Timezone":[""],"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"],"Delete":[""],"Deploy":["Deployment","Deployments"],"Description":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Filter":[""],"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"],"Interval Pattern":[""],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Last Pipeline":[""],"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"],"New Pipeline Schedule":[""],"No schedules":[""],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Owner":[""],"Pipeline Health":["Pipeline Kennzahlen"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"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"],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Select a timezone":[""],"Select target branch":[""],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"Target Branch":[""],"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
diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js
deleted file mode 100644
index d634af959e5..00000000000
--- a/app/assets/javascripts/locale/en/app.js
+++ /dev/null
@@ -1 +0,0 @@
-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;"},"%{commit_author_link} committed %{commit_timeago}":[""],"About auto deploy":[""],"Active":[""],"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":[""],"Are you sure you want to delete this pipeline schedule?":[""],"Attach a file by drag &amp; drop or %{upload_link}":[""],"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":[""],"Browse files":[""],"ByAuthor|by":[""],"CI configuration":[""],"Cancel":[""],"ChangeTypeActionLabel|Pick into branch":[""],"ChangeTypeActionLabel|Revert in branch":[""],"ChangeTypeAction|Cherry-pick":[""],"Changelog":[""],"Charts":[""],"Cherry-pick this commit":[""],"Cherry-pick this merge request":[""],"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 message":[""],"CommitBoxTitle|Commit":[""],"CommitMessage|Add %{file_name}":[""],"Commits":[""],"Commits|History":[""],"Committed by":[""],"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":[""],"Create new...":[""],"CreateNewFork|Fork":[""],"CreateTag|Tag":[""],"Cron Timezone":[""],"Cron syntax":[""],"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":[""],"Define a custom pattern with cron syntax":[""],"Delete":[""],"Deploy":["",""],"Description":[""],"Directory name":[""],"Don't show again":[""],"Download":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadCommit|Email Patches":[""],"DownloadCommit|Plain Diff":[""],"DownloadSource|Download":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Every day (at 4:00am)":[""],"Every month (on the 1st at 4:00am)":[""],"Every week (Sundays at 4:00am)":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Files":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"Fork":["",""],"ForkedFromProjectPath|Forked from":[""],"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":[""],"Interval Pattern":[""],"Introducing Cycle Analytics":[""],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["",""],"Last Pipeline":[""],"Last Update":[""],"Last commit":[""],"Learn more in the":[""],"Learn more in the|pipeline schedules documentation":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["",""],"Median":[""],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["",""],"New Pipeline Schedule":[""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":[""],"New merge request":[""],"New schedule":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"No schedules":[""],"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":[""],"OfSearchInADropdown|Filter":[""],"OpenedNDaysAgo|Opened":[""],"Options":[""],"Owner":[""],"Pipeline":[""],"Pipeline Health":[""],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"PipelineSheduleIntervalPattern|Custom":[""],"Pipeline|with stage":[""],"Pipeline|with stages":[""],"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":[""],"Revert this commit":[""],"Revert this merge request":[""],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Scheduling Pipelines":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Select a timezone":[""],"Select target branch":[""],"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":[""],"Start a %{new_merge_request} with these changes":[""],"Start a <strong>new merge request</strong> with these changes":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"Target Branch":[""],"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 pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.":[""],"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":[""],"day":["",""],"new merge request":[""],"notification emails":[""],"parent":["",""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/eo/app.js b/app/assets/javascripts/locale/eo/app.js
deleted file mode 100644
index 55f000e9b88..00000000000
--- a/app/assets/javascripts/locale/eo/app.js
+++ /dev/null
@@ -1 +0,0 @@
-var locales = locales || {}; locales['eo'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-06-15 21:59-0500","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-20 06:24-0400","Last-Translator":"Lyubomir Vasilev <lyubomirv@abv.bg>","Language-Team":"Esperanto (https://translate.zanata.org/project/view/GitLab)","Language":"eo","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=2; plural=(n != 1)","lang":"eo","domain":"app","plural_forms":"nplurals=2; plural=(n != 1)"},"%{commit_author_link} committed %{commit_timeago}":["%{commit_author_link} enmetis %{commit_timeago}"],"About auto deploy":["Pri la aŭtomata disponigado"],"Active":["Aktiva"],"Activity":["Aktiveco"],"Add Changelog":["Aldoni liston de ŝanĝoj"],"Add Contribution guide":["Aldoni gvidliniojn por kontribuado"],"Add License":["Aldoni rajtigilon"],"Add an SSH key to your profile to pull or push via SSH.":["Aldonu SSH-ŝlosilon al via profilo por ebligi al vi eltiri kaj alpuŝi per SSH."],"Add new directory":["Aldoni novan dosierujon"],"Archived project! Repository is read-only":["Arkivita projekto! La deponejo permesas nur legadon"],"Are you sure you want to delete this pipeline schedule?":["Ĉu vi certe volas forigi ĉi tiun ĉenstablan planon?"],"Attach a file by drag &amp; drop or %{upload_link}":["Alkroĉu dosieron per ŝovmetado aŭ %{upload_link}"],"Branch":["Branĉo","Branĉoj"],"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 branĉo <strong>%{branch_name}</strong> estis kreita. Por agordi aŭtomatan disponigadon, bonvolu elekti Yaml-ŝablonon por GitLab CI kaj enmeti viajn ŝanĝojn. %{link_to_autodeploy_doc}"],"Branches":["Branĉoj"],"Browse files":["Elekti dosierojn"],"ByAuthor|by":["de"],"CI configuration":["Agordoj de seninterrompa integrado"],"Cancel":["Nuligi"],"ChangeTypeActionLabel|Pick into branch":["Elekti en branĉon"],"ChangeTypeActionLabel|Revert in branch":["Malfari en branĉo"],"ChangeTypeAction|Cherry-pick":["Precize elekti"],"ChangeTypeAction|Revert":["Malfari"],"Changelog":["Listo de ŝanĝoj"],"Charts":["Diagramoj"],"Cherry-pick this commit":["Precize elekti ĉi tiun kunmetadon"],"Cherry-pick this merge request":["Precize elekti ĉi tiun peton pri kunfando"],"CiStatusLabel|canceled":["nuligita"],"CiStatusLabel|created":["kreita"],"CiStatusLabel|failed":["malsukcesa"],"CiStatusLabel|manual action":["mana ago"],"CiStatusLabel|passed":["sukcesa"],"CiStatusLabel|passed with warnings":["sukcesa, kun avertoj"],"CiStatusLabel|pending":["okazonta"],"CiStatusLabel|skipped":["transsaltita"],"CiStatusLabel|waiting for manual action":["atendanta manan agon"],"CiStatusText|blocked":["blokita"],"CiStatusText|canceled":["nuligita"],"CiStatusText|created":["kreita"],"CiStatusText|failed":["malsukcesa"],"CiStatusText|manual":["mana"],"CiStatusText|passed":["sukcesa"],"CiStatusText|pending":["okazonta"],"CiStatusText|skipped":["transsaltita"],"CiStatus|running":["plenumiĝanta"],"Commit":["Enmetado","Enmetadoj"],"Commit message":["Mesaĝo pri la enmetado"],"CommitBoxTitle|Commit":["Enmeti"],"CommitMessage|Add %{file_name}":["Aldoni „%{file_name}“"],"Commits":["Enmetadoj"],"Commits|History":["Historio"],"Committed by":["Enmetita de"],"Compare":["Kompari"],"Contribution guide":["Gvidlinioj por kontribuado"],"Contributors":["Kontribuantoj"],"Copy URL to clipboard":["Kopii la adreson en la kopibufron"],"Copy commit SHA to clipboard":["Kopii la identigilon de la enmetado"],"Create New Directory":["Krei novan dosierujon"],"Create directory":["Krei dosierujon"],"Create empty bare repository":["Krei malplenan deponejon"],"Create merge request":["Krei peton pri kunfando"],"Create new...":["Krei novan…"],"CreateNewFork|Fork":["Disbranĉigi"],"CreateTag|Tag":["Etikedo"],"Cron Timezone":["Horzono por Cron"],"Cron syntax":["La sintakso de Cron"],"Custom notification events":["Propraj sciigaj eventoj"],"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}.":["La propraj sciigaj niveloj estas la samaj kiel la niveloj de partoprenado. Uzante la proprajn sciigajn nivelojn, vi ricevos ankaŭ sciigojn por elektitaj de vi eventoj. Por lerni pli, bonvolu vidi %{notification_link}."],"Cycle Analytics":["Cikla analizo"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["La cikla analizo esploras kiom da tempo necesas por disvolvi ideon ĝis ĝi fariĝos realaĵo."],"CycleAnalyticsStage|Code":["Programado"],"CycleAnalyticsStage|Issue":["Problemo"],"CycleAnalyticsStage|Plan":["Plano"],"CycleAnalyticsStage|Production":["Eldonado"],"CycleAnalyticsStage|Review":["Kontrolo"],"CycleAnalyticsStage|Staging":["Preparo por eldono"],"CycleAnalyticsStage|Test":["Testado"],"Define a custom pattern with cron syntax":["Difini propran ŝablonon, uzante la sintakson de Cron"],"Delete":["Forigi"],"Deploy":["Disponigado","Disponigadoj"],"Description":["Priskribo"],"Directory name":["Nomo de dosierujo"],"Don't show again":["Ne montru denove"],"Download":["Elŝuti"],"Download tar":["Elŝuti en formato „tar“"],"Download tar.bz2":["Elŝuti en formato „tar.bz2“"],"Download tar.gz":["Elŝuti en formato „tar.gz“"],"Download zip":["Elŝuti en formato „zip“"],"DownloadArtifacts|Download":["Elŝuti"],"DownloadCommit|Email Patches":["Sendi flikaĵojn per retpoŝto"],"DownloadCommit|Plain Diff":["Normala dosiero kun diferencoj"],"DownloadSource|Download":["Elŝuti"],"Edit":["Redakti"],"Edit Pipeline Schedule %{id}":["Redakti ĉenstablan planon %{id}"],"Every day (at 4:00am)":["Ĉiutage (je 4:00)"],"Every month (on the 1st at 4:00am)":["Ĉiumonate (en la 1a de la monato, je 4:00)"],"Every week (Sundays at 4:00am)":["Ĉiusemajne (en dimanĉo, je 4:00)"],"Failed to change the owner":["Ne eblas ŝanĝi la posedanton"],"Failed to remove the pipeline schedule":["Ne eblas forigi la ĉenstablan planon"],"Files":["Dosieroj"],"Find by path":["Trovi per dosierindiko"],"Find file":["Trovi dosieron"],"FirstPushedBy|First":["Unue"],"FirstPushedBy|pushed by":["alpuŝita de"],"Fork":["Disbranĉigo","Disbranĉigoj"],"ForkedFromProjectPath|Forked from":["Disbranĉigita el"],"From issue creation until deploy to production":["De la kreado de la problemo ĝis la disponigado en la publika versio"],"From merge request merge until deploy to production":["De la kunfandado de la peto pri kunfando ĝis la disponigado en la publika versio"],"Go to your fork":["Al via disbranĉigo"],"GoToYourFork|Fork":["Disbranĉigo"],"Home":["Hejmo"],"Housekeeping successfully started":["La refreŝigo komenciĝis sukcese"],"Import repository":["Enporti deponejon"],"Interval Pattern":["Intervala ŝablono"],"Introducing Cycle Analytics":["Ni prezentas al vi la ciklan analizon"],"LFSStatus|Disabled":["Malŝaltita"],"LFSStatus|Enabled":["Ŝaltita"],"Last %d day":["La lasta %d tago","La lastaj %d tagoj"],"Last Pipeline":["Lasta ĉenstablo"],"Last Update":["Lasta ĝisdatigo"],"Last commit":["Lasta enmetado"],"Learn more in the":["Lernu pli en la"],"Learn more in the|pipeline schedules documentation":["dokumentado pri ĉenstablaj planoj"],"Leave group":["Forlasi la grupon"],"Leave project":["Forlasi la projekton"],"Limited to showing %d event at most":["Limigita al montrado de ne pli ol %d evento","Limigita al montrado de ne pli ol %d eventoj"],"Median":["Mediano"],"MissingSSHKeyWarningLink|add an SSH key":["aldonos SSH-ŝlosilon"],"New Issue":["Nova problemo","Novaj problemoj"],"New Pipeline Schedule":["Nova ĉenstabla plano"],"New branch":["Nova branĉo"],"New directory":["Nova dosierujo"],"New file":["Nova dosiero"],"New issue":["Nova problemo"],"New merge request":["Nova peto pri kunfando"],"New schedule":["Nova plano"],"New snippet":["Nova kodaĵo"],"New tag":["Nova etikedo"],"No repository":["Ne estas deponejo"],"No schedules":["Ne estas planoj"],"Not available":["Ne disponebla"],"Not enough data":["Ne estas sufiĉe da datenoj"],"Notification events":["Sciigaj eventoj"],"NotificationEvent|Close issue":["Fermi problemon"],"NotificationEvent|Close merge request":["Fermi peton pri kunfando"],"NotificationEvent|Failed pipeline":["Malsukcesa ĉenstablo"],"NotificationEvent|Merge merge request":["Apliki peton pri kunfando"],"NotificationEvent|New issue":["Nova problemo"],"NotificationEvent|New merge request":["Nova peto pri kunfando"],"NotificationEvent|New note":["Nova noto"],"NotificationEvent|Reassign issue":["Reatribui problemon"],"NotificationEvent|Reassign merge request":["Reatribui peton pri kunfando"],"NotificationEvent|Reopen issue":["Remalfermi problemon"],"NotificationEvent|Successful pipeline":["Sukcesa ĉenstablo"],"NotificationLevel|Custom":["Propraj"],"NotificationLevel|Disabled":["Malŝaltitaj"],"NotificationLevel|Global":["Ĝeneralaj"],"NotificationLevel|On mention":["Ĉe mencio"],"NotificationLevel|Participate":["Partoprenado"],"NotificationLevel|Watch":["Rigardado"],"OfSearchInADropdown|Filter":["Filtrilo"],"OpenedNDaysAgo|Opened":["Malfermita"],"Options":["Opcioj"],"Owner":["Posedanto"],"Pipeline":["Ĉenstablo"],"Pipeline Health":["Stato"],"Pipeline Schedule":["Ĉenstabla plano"],"Pipeline Schedules":["Ĉenstablaj planoj"],"PipelineSchedules|Activated":["Ŝaltita"],"PipelineSchedules|Active":["Ŝaltitaj"],"PipelineSchedules|All":["Ĉiuj"],"PipelineSchedules|Inactive":["Malŝaltitaj"],"PipelineSchedules|Next Run":["Sekvanta plenumo"],"PipelineSchedules|None":["Nenio"],"PipelineSchedules|Provide a short description for this pipeline":["Entajpu mallongan priskribon pri ĉi tiu ĉenstablo"],"PipelineSchedules|Take ownership":["Akiri posedon"],"PipelineSchedules|Target":["Celo"],"PipelineSheduleIntervalPattern|Custom":["Propra"],"Pipeline|with stage":["kun etapo"],"Pipeline|with stages":["kun etapoj"],"Project '%{project_name}' queued for deletion.":["La projekto „%{project_name}“ estis alvicigita por forigado."],"Project '%{project_name}' was successfully created.":["La projekto „%{project_name}“ estis sukcese kreita."],"Project '%{project_name}' was successfully updated.":["La projekto „%{project_name}“ estis sukcese ĝisdatigita."],"Project '%{project_name}' will be deleted.":["La projekto „%{project_name}“ estos forigita."],"Project access must be granted explicitly to each user.":["Ĉiu uzanto devas akiri propran atingon al la projekto."],"Project export could not be deleted.":["Ne eblas forigi la projektan elporton."],"Project export has been deleted.":["La projekta elporto estis forigita."],"Project export link has expired. Please generate a new export from your project settings.":["La ligilo por la projekta elporto eksvalidiĝis. Bonvolu krei novan elporton en la agordoj de la projekto."],"Project export started. A download link will be sent by email.":["La elporto de la projekto komenciĝis. Vi ricevos ligilon per retpoŝto por elŝuti la datenoj."],"Project home":["Hejmo de la projekto"],"ProjectFeature|Disabled":["Malŝaltita"],"ProjectFeature|Everyone with access":["Ĉiu, kiu havas atingon"],"ProjectFeature|Only team members":["Nur skipanoj"],"ProjectFileTree|Name":["Nomo"],"ProjectLastActivity|Never":["Neniam"],"ProjectLifecycle|Stage":["Etapo"],"ProjectNetworkGraph|Graph":["Grafeo"],"Read more":["Legu pli"],"Readme":["LeguMin"],"RefSwitcher|Branches":["Branĉoj"],"RefSwitcher|Tags":["Etikedoj"],"Related Commits":["Rilataj enmetadoj"],"Related Deployed Jobs":["Rilataj disponigitaj taskoj"],"Related Issues":["Rilataj problemoj"],"Related Jobs":["Rilataj taskoj"],"Related Merge Requests":["Rilataj petoj pri kunfando"],"Related Merged Requests":["Rilataj aplikitaj petoj pri kunfando"],"Remind later":["Rememorigu denove"],"Remove project":["Forigi la projekton"],"Request Access":["Peti atingeblon"],"Revert this commit":["Malfari ĉi tiun enmetadon"],"Revert this merge request":["Malfari ĉi tiun peton pri kunfando"],"Save pipeline schedule":["Konservi ĉenstablan planon"],"Schedule a new pipeline":["Plani novan ĉenstablon"],"Scheduling Pipelines":["Planado de la ĉenstabloj"],"Search branches and tags":["Serĉu branĉon aŭ etikedon"],"Select Archive Format":["Elektu formaton de arkivo"],"Select a timezone":["Elektu horzonon"],"Select target branch":["Elektu celan branĉon"],"Set a password on your account to pull or push via %{protocol}":["Kreu pasvorton por via konto por ebligi al vi eltiri kaj alpuŝi per %{protocol}"],"Set up CI":["Agordi SI"],"Set up Koding":["Agordi „Koding“"],"Set up auto deploy":["Agordi aŭtomatan disponigadon"],"SetPasswordToCloneLink|set a password":["kreos pasvorton"],"Showing %d event":["Estas montrata %d evento","Estas montrataj %d eventoj"],"Source code":["Kodo"],"StarProject|Star":["Steligi"],"Start a %{new_merge_request} with these changes":["Kreu %{new_merge_request} kun ĉi tiuj ŝanĝoj"],"Switch branch/tag":["Iri al branĉo/etikedo"],"Tag":["Etikedo","Etikedoj"],"Tags":["Etikedoj"],"Target Branch":["Cela branĉo"],"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 etapo de programado montras la tempon de la unua enmetado ĝis la kreado de la peto pri kunfando. La datenoj aldoniĝos aŭtomate ĉi tie post kiam vi kreas la unuan peton pri kunfando."],"The collection of events added to the data gathered for that stage.":["La aro da eventoj, kiuj estas aldonitaj al la datenoj kolektitaj por la etapo."],"The fork relationship has been removed.":["La rilato de disbranĉigo estis forigita."],"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 etapo de la problemo montras kiom la tempo pasas de la kreado de problemo ĝis la atribuado de la problemo al cela etapo de la projekto, aŭ al listo sur la problemtabulo. Komencu krei problemojn por vidi la datenojn por ĉi tiu etapo."],"The phase of the development lifecycle.":["La etapo de la disvolva ciklo."],"The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.":["La ĉenstabla plano plenumas ĉenstablojn en la estonteco, ripete, por difinitaj branĉoj aŭ etikedoj. Tiuj planitaj ĉenstabloj heredos la limigitan atingon al la projekto de la rilata uzanto."],"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 etapo de la plano montras la tempon de la antaŭa ŝtupo ĝis la alpuŝado de via unua enmetado. Ĉi tiu tempo aldoniĝos aŭtomate post kiam vi alpuŝas la unuan enmetadon."],"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 etapo de eldonado montras la tutan tempon de la kreado de problemo ĝis la disponigado en la publika versio. La datenoj aldoniĝos aŭtomate post kiam vi kompletigos plenan ciklon de ideo ĝis realaĵo."],"The project can be accessed by any logged in user.":["Ĉiu ensalutita uzanto havas atingon al la projekto"],"The project can be accessed without any authentication.":["Ĉiu povas havi atingon al la projekto, sen ensaluti"],"The repository for this project does not exist.":["La deponejo por ĉi tiu projekto ne ekzistas."],"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 etapo de la kontrolo montras la tempon de la kreado de la peto pri kunfando ĝis ĝia aplikado. La datenoj aldoniĝos aŭtomate post kiam vi aplikos la unuan peton pri kunfando."],"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 etapo de preparo por eldono montras la tempon inter la aplikado de la peto pri kunfando kaj la disponigado de la kodo en la publika versio. La datenoj aldoniĝos aŭtomate post kiam vi faros la unuan disponigadon en la publika versio."],"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 etapo de testado montras kiom da tempo necesas al „GitLab CI“ por plenumi ĉiujn ĉenstablojn por la rilata peto pri kunfando. La datenoj aldoniĝos aŭtomate post kiam via unua ĉenstablo finiĝos."],"The time taken by each data entry gathered by that stage.":["La tempo, kiu estas necesa por ĉiu dateno kolektita de la etapo."],"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.":["La valoro, kiu troviĝas en la mezo de aro da rigardataj valoroj. Ekzemple: inter 3, 5 kaj 9, la mediano estas 5. Inter 3, 5, 7 kaj 8, la mediano estas (5+7)/2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":["Ĉi tiu signifas, ke vi ne povos alpuŝi kodon, antaŭ ol vi kreos malplenan deponejon aŭ enportos jam ekzistantan."],"Time before an issue gets scheduled":["Tempo antaŭ problemo estas planita por ellabori"],"Time before an issue starts implementation":["Tempo antaŭ la komenco de laboro super problemo"],"Time between merge request creation and merge/close":["Tempo inter la kreado de poeto pri kunfando kaj ĝia aplikado/fermado"],"Time until first merge request":["Tempo ĝis la unua peto pri kunfando"],"Timeago|%s days ago":["antaŭ %s tagoj"],"Timeago|%s days remaining":["restas %s tagoj"],"Timeago|%s hours remaining":["restas %s horoj"],"Timeago|%s minutes ago":["antaŭ %s minutoj"],"Timeago|%s minutes remaining":["restas %s minutoj"],"Timeago|%s months ago":["antaŭ %s monatoj"],"Timeago|%s months remaining":["restas %s monatoj"],"Timeago|%s seconds remaining":["restas %s sekundoj"],"Timeago|%s weeks ago":["antaŭ %s semajnoj"],"Timeago|%s weeks remaining":["restas %s semajnoj"],"Timeago|%s years ago":["antaŭ %s jaroj"],"Timeago|%s years remaining":["restas %s jaroj"],"Timeago|1 day remaining":["restas 1 tago"],"Timeago|1 hour remaining":["restas 1 horo"],"Timeago|1 minute remaining":["restas 1 minuto"],"Timeago|1 month remaining":["restas 1 monato"],"Timeago|1 week remaining":["restas 1 semajno"],"Timeago|1 year remaining":["restas 1 jaro"],"Timeago|Past due":["Malfruiĝis"],"Timeago|a day ago":["antaŭ unu tago"],"Timeago|a month ago":["antaŭ unu monato"],"Timeago|a week ago":["antaŭ unu semajno"],"Timeago|a while":["antaŭ iom da tempo"],"Timeago|a year ago":["antaŭ unu jaro"],"Timeago|about %s hours ago":["antaŭ ĉirkaŭ %s horoj"],"Timeago|about a minute ago":["antaŭ ĉirkaŭ unu minuto"],"Timeago|about an hour ago":["antaŭ ĉirkaŭ unu horo"],"Timeago|in %s days":["post %s tagoj"],"Timeago|in %s hours":["post %s horoj"],"Timeago|in %s minutes":["post %s minutoj"],"Timeago|in %s months":["post %s monatoj"],"Timeago|in %s seconds":["post %s sekundoj"],"Timeago|in %s weeks":["post %s semajnoj"],"Timeago|in %s years":["post %s jaroj"],"Timeago|in 1 day":["post 1 tago"],"Timeago|in 1 hour":["post 1 horo"],"Timeago|in 1 minute":["post 1 minuto"],"Timeago|in 1 month":["post 1 monato"],"Timeago|in 1 week":["post 1 semajno"],"Timeago|in 1 year":["post 1 jaro"],"Timeago|less than a minute ago":["antaŭ malpli ol minuto"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Totala tempo"],"Total test time for all commits/merges":["Totala tempo por la testado de ĉiuj enmetadoj/kunfandoj"],"Unstar":["Malsteligi"],"Upload New File":["Alŝuti novan dosieron"],"Upload file":["Alŝuti dosieron"],"Use your global notification setting":["Uzi vian ĝeneralan agordon pri la sciigoj"],"VisibilityLevel|Internal":["Interna"],"VisibilityLevel|Private":["Privata"],"VisibilityLevel|Public":["Publika"],"Want to see the data? Please ask an administrator for access.":["Ĉu vi volas vidi la datenojn? Bonvolu peti atingeblon de administranto."],"We don't have enough data to show this stage.":["Ne estas sufiĉe da datenoj por montri ĉi tiun etapon."],"Withdraw Access Request":["Nuligi la peton pri atingeblo"],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":["Vi forigos „%{project_name_with_namespace}“.\\nOni NE POVAS malfari la forigon de projekto!\\nĈu vi estas ABSOLUTE certa?"],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":["Vi forigos la rilaton de la disbranĉigo al la originala projekto, „%{forked_from_project}“. Ĉu vi estas ABSOLUTE certa?"],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":["Vi transigos „%{project_name_with_namespace}“ al alia posedanto. Ĉu vi estas ABSOLUTE certa?"],"You can only add files when you are on a branch":["Oni povas aldoni dosierojn nur kiam oni estas en branĉo"],"You have reached your project limit":["Vi ne povas krei pliajn projektojn"],"You must sign in to star a project":["Oni devas ensaluti por steligi projekton"],"You need permission.":["VI bezonas permeson."],"You will not get any notifications via email":["VI ne ricevos sciigojn per retpoŝto"],"You will only receive notifications for the events you choose":["Vi ricevos sciigojn nur por la eventoj elektitaj de vi"],"You will only receive notifications for threads you have participated in":["Vi ricevos sciigojn nur por la fadenoj, en kiuj vi partoprenis"],"You will receive notifications for any activity":["Vi ricevos sciigojn por ĉiu ago"],"You will receive notifications only for comments in which you were @mentioned":["Vi ricevos sciigojn nur por komentoj, en kiuj vi estas @menciita"],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":["Vi ne povos eltiri aŭ alpuŝi kodon per %{protocol} antaŭ ol vi %{set_password_link} por via konto"],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":["Vi ne povos eltiri aŭ alpuŝi kodon per SSH antaŭ ol vi %{add_ssh_key_link} al via profilo"],"Your name":["Via nomo"],"day":["tago","tagoj"],"new merge request":["novan peton pri kunfando"],"notification emails":["sciigoj per retpoŝto"],"parent":["patro","patroj"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js
deleted file mode 100644
index 8d951928849..00000000000
--- a/app/assets/javascripts/locale/es/app.js
+++ /dev/null
@@ -1 +0,0 @@
-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-21 12:09-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;"},"%d additional commit has been omitted to prevent performance issues.":["%d cambio adicional ha sido omitido para evitar problemas de rendimiento.","%d cambios adicionales han sido omitidos para evitar problemas de rendimiento."],"%d commit":["%d cambio","%d cambios"],"%{commit_author_link} committed %{commit_timeago}":["%{commit_author_link} cambió %{commit_timeago}"],"About auto deploy":["Acerca del auto despliegue"],"Active":["Activo"],"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 solo lectura"],"Are you sure you want to delete this pipeline schedule?":["¿Estás seguro que deseas eliminar esta programación del pipeline?"],"Attach a file by drag &amp; drop or %{upload_link}":["Adjunte un archivo arrastrando &amp; soltando o %{upload_link}"],"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}"],"BranchSwitcherPlaceholder|Search branches":["Buscar ramas"],"BranchSwitcherTitle|Switch branch":["Cambiar rama"],"Branches":["Ramas"],"Browse Directory":["Examinar directorio"],"Browse File":["Examinar archivo"],"Browse Files":["Examinar archivos"],"Browse files":["Examinar archivos"],"ByAuthor|by":["por"],"CI configuration":["Configuración de CI"],"Cancel":["Cancelar"],"ChangeTypeActionLabel|Pick into branch":["Escoger en la rama"],"ChangeTypeActionLabel|Revert in branch":["Revertir en la rama"],"ChangeTypeAction|Cherry-pick":["Cherry-pick"],"ChangeTypeAction|Revert":["Revertir"],"Changelog":["Changelog"],"Charts":["Gráficos"],"Cherry-pick this commit":["Escoger este cambio"],"Cherry-pick this merge request":["Escoger esta solicitud de fusión"],"CiStatusLabel|canceled":["cancelado"],"CiStatusLabel|created":["creado"],"CiStatusLabel|failed":["fallido"],"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"],"Commit message":["Mensaje del cambio"],"CommitBoxTitle|Commit":["Cambio"],"CommitMessage|Add %{file_name}":["Agregar %{file_name}"],"Commits":["Cambios"],"Commits feed":["Feed de cambios"],"Commits|History":["Historial"],"Committed by":["Enviado por"],"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"],"Create new...":["Crear nuevo..."],"CreateNewFork|Fork":["Bifurcar"],"CreateTag|Tag":["Etiqueta"],"Cron Timezone":["Zona horaria del Cron"],"Cron syntax":["Sintaxis de Cron"],"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"],"Define a custom pattern with cron syntax":["Definir un patrón personalizado con la sintaxis de cron"],"Delete":["Eliminar"],"Deploy":["Despliegue","Despliegues"],"Description":["Descripción"],"Directory name":["Nombre del directorio"],"Don't show again":["No mostrar de nuevo"],"Download":["Descargar"],"Download tar":["Descargar tar"],"Download tar.bz2":["Descargar tar.bz2"],"Download tar.gz":["Descargar tar.gz"],"Download zip":["Descargar zip"],"DownloadArtifacts|Download":["Descargar"],"DownloadCommit|Email Patches":["Parches por correo electrónico"],"DownloadCommit|Plain Diff":["Diferencias en texto plano"],"DownloadSource|Download":["Descargar"],"Edit":["Editar"],"Edit Pipeline Schedule %{id}":["Editar Programación del Pipeline %{id}"],"Every day (at 4:00am)":["Todos los días (a las 4:00 am)"],"Every month (on the 1st at 4:00am)":["Todos los meses (el día 1 a las 4:00 am)"],"Every week (Sundays at 4:00am)":["Todas las semanas (domingos a las 4:00 am)"],"Failed to change the owner":["Error al cambiar el propietario"],"Failed to remove the pipeline schedule":["Error al eliminar la programación del pipeline"],"Files":["Archivos"],"Filter by commit message":["Filtrar por mensaje del cambio"],"Find by path":["Buscar por ruta"],"Find file":["Buscar archivo"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"Fork":["Bifurcación","Bifurcaciones"],"ForkedFromProjectPath|Forked from":["Bifurcado de"],"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"],"Interval Pattern":["Patrón de intervalo"],"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 Pipeline":["Último Pipeline"],"Last Update":["Última actualización"],"Last commit":["Último cambio"],"Learn more in the":["Más información en la"],"Learn more in the|pipeline schedules documentation":["documentación sobre la programación de pipelines"],"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 Pipeline Schedule":["Nueva Programación del Pipeline"],"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 schedule":["Nueva programación"],"New snippet":["Nuevo fragmento de código"],"New tag":["Nueva etiqueta"],"No repository":["No hay repositorio"],"No schedules":["No hay programaciones"],"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"],"OfSearchInADropdown|Filter":["Filtrar"],"OpenedNDaysAgo|Opened":["Abierto"],"Options":["Opciones"],"Owner":["Propietario"],"Pipeline":["Pipeline"],"Pipeline Health":["Estado del Pipeline"],"Pipeline Schedule":["Programación del Pipeline"],"Pipeline Schedules":["Programaciones de los Pipelines"],"PipelineSchedules|Activated":["Activado"],"PipelineSchedules|Active":["Activos"],"PipelineSchedules|All":["Todos"],"PipelineSchedules|Inactive":["Inactivos"],"PipelineSchedules|Next Run":["Próxima Ejecución"],"PipelineSchedules|None":["Ninguno"],"PipelineSchedules|Provide a short description for this pipeline":["Proporcione una breve descripción para este pipeline"],"PipelineSchedules|Take ownership":["Tomar posesión"],"PipelineSchedules|Target":["Destino"],"PipelineSheduleIntervalPattern|Custom":["Personalizado"],"Pipeline|with stage":["con etapa"],"Pipeline|with stages":["con etapas"],"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":["Léeme"],"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"],"Revert this commit":["Revertir este cambio"],"Revert this merge request":["Revertir esta solicitud de fusión"],"Save pipeline schedule":["Guardar programación del pipeline"],"Schedule a new pipeline":["Programar un nuevo pipeline"],"Scheduling Pipelines":["Programación de Pipelines"],"Search branches and tags":["Buscar ramas y etiquetas"],"Select Archive Format":["Seleccionar formato de archivo"],"Select a timezone":["Selecciona una zona horaria"],"Select target branch":["Selecciona una rama de destino"],"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"],"Start a %{new_merge_request} with these changes":["Iniciar una %{new_merge_request} con estos cambios"],"Switch branch/tag":["Cambiar rama/etiqueta"],"Tag":["Etiqueta","Etiquetas"],"Tags":["Etiquetas"],"Target Branch":["Rama de destino"],"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 pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.":["La programación de pipelines ejecuta pipelines en el futuro, repetidamente, para ramas o etiquetas específicas. Los pipelines programados heredarán acceso limitado al proyecto basado en su usuario asociado."],"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 un mes"],"Timeago|a week ago":["hace una semana"],"Timeago|a while":["hace un momento"],"Timeago|a year ago":["hace un 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"],"UploadLink|click to upload":["Hacer clic para subir"],"Use your global notification setting":["Utiliza tu configuración de notificación global"],"View open merge request":["Ver solicitud de fusión abierta"],"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":["Solo puedes agregar archivos cuando estás en una rama"],"You have reached your project limit":["Has alcanzado el límite de tu proyecto"],"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 por cualquier actividad"],"You will receive notifications only for comments in which you were @mentioned":["Recibirás notificaciones solo 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"],"day":["día","días"],"new merge request":["nueva solicitud de fusión"],"notification emails":["correos electrónicos de notificación"],"parent":["padre","padres"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/fr/app.js b/app/assets/javascripts/locale/fr/app.js
deleted file mode 100644
index f9904ea61ea..00000000000
--- a/app/assets/javascripts/locale/fr/app.js
+++ /dev/null
@@ -1 +0,0 @@
-var locales = locales || {}; locales['fr'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-06-15 20:38+0000","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-14 04:21-0400","Last-Translator":"Dremor <egeorget@opmbx.org>","Language-Team":"French (https://www.transifex.com/gitlab-fr/teams/75145/fr/)","Language":"fr","Plural-Forms":"nplurals=2; plural=(n > 1);","X-Generator":"Zanata 3.9.6","lang":"fr","domain":"app","plural_forms":"nplurals=2; plural=(n > 1);"},"ByAuthor|by":["par"],"Commit":["Validation","Validations"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["L’analyseur de cycle permet d’avoir une vue d’ensemble du temps nécessaire pour aller d’une idée à sa mise en production pour votre projet."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Incident"],"CycleAnalyticsStage|Plan":["Planification"],"CycleAnalyticsStage|Production":["Production"],"CycleAnalyticsStage|Review":["Examen"],"CycleAnalyticsStage|Staging":["Pré-production"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Déploiement","Déploiements"],"FirstPushedBy|First":["En premier"],"FirstPushedBy|pushed by":["poussé par"],"From issue creation until deploy to production":["Depuis la création de l'incident jusqu'au déploiement en production"],"From merge request merge until deploy to production":["Depuis la fusion de la demande de fusion jusqu'au déploiement en production"],"Introducing Cycle Analytics":["Introduction à l'analyseur de cycle"],"Last %d day":["Le dernier %d jour","Les derniers %d jours"],"Limited to showing %d event at most":["Limiter l'affichage au plus à %d évènement","Limiter l'affichage au plus à %d évènements"],"Median":["Médian"],"New Issue":["Nouvel incident","Nouveaux incidents"],"Not available":["Indisponible"],"Not enough data":["Données insuffisantes"],"OpenedNDaysAgo|Opened":["Ouvert"],"Pipeline Health":["Santé du Pipeline"],"ProjectLifecycle|Stage":["Étape"],"Read more":["Lire plus"],"Related Commits":["Validations liés"],"Related Deployed Jobs":["Tâches de déploiement liés"],"Related Issues":["Incidents liés"],"Related Jobs":["Tâches liées"],"Related Merge Requests":["Demandes de fusion liées"],"Related Merged Requests":["Demandes fusionnées liées"],"Showing %d event":["Affichage de %d évènement","Affichage de %d évènements"],"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.":["L’étape de développement montre le temps entre la première validation et la création de la demande de fusion. Les données seront automatiquement ajoutées ici une fois que vous aurez créé votre première demande de fusion."],"The collection of events added to the data gathered for that stage.":["L’ensemble d’évènements ajoutés aux données récupérées pour cette étape."],"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.":["L'étape des incidents montre le temps nécessaire entre la création d'un incident et son assignation à un jalon, ou son ajout à une liste d'un tableau d'incident. Débutez à créer des incidents pour voir des données pour cette étape."],"The phase of the development lifecycle.":["Les étapes du cycle de développement."],"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.":["L’étape de planification montre le temps entre l’étape précédente et l’envoi de votre première validation. Ce temps sera automatiquement ajouté quand vous pousserez votre première validation."],"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.":["L’étape de mise en production montre le temps nécessaire entre la création d’un incident et le déploiement du code en production. Les données seront automatiquement ajoutées une fois que vous aurez complété le cycle complet, depuis l’idée jusqu’à la mise en production."],"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.":["L’étape d’évaluation montre le temps entre la création de la demande de fusion et la fusion effective de celle-ci. Ces données seront automatiquement ajoutées après que vous ayez fusionné votre première demande de fusion."],"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.":["L’étape de pré-production indique le temps entre la fusion de la RF et le déploiement du code dans l’environnent de production. Les données seront automatiquement ajoutées une fois que vous déploierez en production pour la première fois."],"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.":["L’étape de test montre le temps que le CI de GitLab met pour exécuter chaque pipeline liés à la demande de fusion. Les données seront automatiquement ajoutées après que votre premier pipeline s’achèvera."],"The time taken by each data entry gathered by that stage.":["Le temps pris par chaque entrée récoltée durant cette étape."],"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.":["La valeur située au point médian d’une série de valeur observée. C.à.d., entre 3, 5, 9, le médian est 5. Entre 3, 5, 7, 8, le médian est (5+7)/2 = 6."],"Time before an issue gets scheduled":["Temps avant qu’un incident ne soit planifié"],"Time before an issue starts implementation":["Temps avant que résolution ne débute"],"Time between merge request creation and merge/close":["Temps entre la création d'une demande de fusion et sa fusion/clôture"],"Time until first merge request":["Temps jusqu’à la première demande de fusion"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Temps total"],"Total test time for all commits/merges":["Temps total de test pour toutes les validations/fusions"],"Want to see the data? Please ask an administrator for access.":["Vous voulez voir les données ? Merci de contacter un administrateur pour en obtenir l’accès."],"We don't have enough data to show this stage.":["Nous n'avons pas suffisamment de données pour afficher cette étape."],"You need permission.":["Vous avez besoin d’une autorisation."],"day":["jour","jours"],"%{commit_author_link} committed %{commit_timeago}":["%{commit_author_link} a validé %{commit_timeago}"],"About auto deploy":["A propos de l'auto-déploiement"],"Active":["Actif"],"Activity":["Activité"],"Add Changelog":["Ajouter un journal des modifications"],"Add Contribution guide":["Ajouter un guide de contribution"],"Add License":["Ajouter une licence"],"Add an SSH key to your profile to pull or push via SSH.":["Ajoutez une clef SSH à votre profil pour pouvoir récupérer et pousser par SSH."],"Add new directory":["Ajouter un nouveau dossier"],"Archived project! Repository is read-only":["Projet archivé ! Le dépôt est en lecture seule"],"Are you sure you want to delete this pipeline schedule?":["Êtes-vous sûr de vouloir supprimer ce pipeline programmé"],"Attach a file by drag &amp; drop or %{upload_link}":["Attachez un fichier par glisser &amp; déposer ou %{upload_link}"],"Branch":["Branche","Branches"],"#~ \"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, cho\"#~ \"ose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_do\"#~ \"c}\"":["#~ \"La branche <strong>%{branch_name}</strong> a été crée. Pour mettre en place le\"#~ \" déploiement automatisé, sélectionnez un modèle de fichier Yaml pour Gitlab CI\"#~ \", et validez les modifications. %{link_to_autodeploy_doc}\""],"Branches":["Branches"],"Browse files":["Parcourir les fichiers"],"CI configuration":["Configuration du CI"],"Cancel":["Annuler"],"ChangeTypeActionLabel|Pick into branch":["Sélectionner dans la branche"],"ChangeTypeActionLabel|Revert in branch":["Annuler dans la branche"],"ChangeTypeAction|Cherry-pick":["Sélectionner"],"ChangeType|commit":["validation"],"ChangeType|merge request":["demande de fusion"],"Changelog":["Journal des modifications"],"Charts":["Graphiques"],"Cherry-pick this commit":["Sélectionner cette validation"],"Cherry-pick this merge-request":["Sélectionner cette demande de fusion"],"CiStatusLabel|canceled":["annulé"],"CiStatusLabel|created":["créé"],"CiStatusLabel|failed":["échoué"],"CiStatusLabel|manual action":["action manuelle"],"CiStatusLabel|passed":["passé"],"CiStatusLabel|passed with warnings":["passé avec des avertissements"],"CiStatusLabel|pending":["en attente"],"CiStatusLabel|skipped":["ignoré"],"CiStatusLabel|waiting for manual action":["en attente d'action manuelle"],"CiStatusText|blocked":["bloqué"],"CiStatusText|canceled":["annulé "],"CiStatusText|created":["créé"],"CiStatusText|failed":["échoué"],"CiStatusText|manual":["manuel"],"CiStatusText|passed":["passé"],"CiStatusText|pending":["en attente"],"CiStatusText|skipped":["ignoré"],"CiStatus|running":["en cours"],"Commit message":["Message de validation"],"CommitMessage|Add %{file_name}":["Ajout de %{file_name}"],"Commits":["Validations"],"Commits|History":["Historique"],"Committed by":["Validé par"],"Compare":["Comparer"],"Contribution guide":["Guilde de contribution"],"Contributors":["Contributeurs"],"Copy URL to clipboard":["Copier l'URL dans le presse-papier"],"Copy commit SHA to clipboard":["Copier le SAH de la validation"],"Create New Directory":["Créer un nouveau dossier"],"Create directory":["Créer un dossier"],"Create empty bare repository":["Créer un dépôt vide"],"Create merge request":["Créer une demande de fusion"],"Create new...":["Créer nouveau..."],"CreateNewFork|Fork":["Fork"],"CreateTag|Tag":["Étiquette"],"Cron Timezone":["Fuseau horaire de Cron"],"Cron syntax":["Syntaxe CRON"],"Custom":["Personnalisé"],"Custom notification events":["Événements de notification personnalisés"],"#~ \"Custom notification levels are the same as participating levels. With custom n\"#~ \"otification levels you will also receive notifications for select events. To f\"#~ \"ind out more, check out %{notification_link}.\"":["#~ \"Le niveau de notification Personnalisé est similaire au niveau Participation. \"#~ \"Il permet cependant également de recevoir des notifications pour des événement\"#~ \"s sélectionnés. Pour plus d’information, vous pouvez consulter %{notification_\"#~ \"link}.\""],"Cycle Analytics":["Analyseur de cycle"],"Define a custom pattern with cron syntax":["Définir un schéma personnalisé avec une syntaxe CRON"],"Delete":["Supprimer"],"Description":["Description"],"Directory name":["Nom du dossier"],"Don't show again":["Ne plus montrer"],"Download":["Télécharger"],"Download tar":["Télécharger tar"],"Download tar.bz2":["Télécharger tar.bz2"],"Download tar.gz":["Télécharger tar.gz"],"Download zip":["Télécharger zip"],"DownloadArtifacts|Download":["Télécharger"],"DownloadCommit|Email Patches":["Patch email"],"DownloadCommit|Plain Diff":["Diff simple"],"DownloadSource|Download":["Télécharger"],"Edit":["Éditer"],"Edit Pipeline Schedule %{id}":["Éditer le pipeline programmé %{id}"],"Every day (at 4:00am)":["Chaque jour (à 4:00 du matin)"],"Every month (on the 1st at 4:00am)":["Chaque mois (le 1er à 4:00 du matin)"],"Every week (Sundays at 4:00am)":["Chaque semaine (Dimanche à 4:00 du matin)"],"Failed to change the owner":["Échec du changement de propriétaire"],"Failed to remove the pipeline schedule":["Échec de la suppression du pipeline programmé"],"Files":["Fichiers"],"Find by path":["Rechercher par chemin"],"Find file":["Rechercher un fichier"],"Fork":["Fork","Forks"],"ForkedFromProjectPath|Forked from":["Forké depuis"],"Go to your fork":["Aller à votre fork"],"GoToYourFork|Fork":["Fork"],"Home":["Accueil"],"Housekeeping successfully started":["Maintenance démarrée avec succès"],"Import repository":["Importer un dépôt"],"Interval Pattern":["Schéma d’intervalle"],"LFSStatus|Disabled":["Désactivé"],"LFSStatus|Enabled":["Activé"],"Last Pipeline":["Dernier pipeline"],"Last Update":["Dernière mise à jour"],"Last commit":["Dernière validation"],"Learn more in the":["En apprendre plus dans le"],"Leave group":["Quitter le groupe"],"Leave project":["Quitter le projet"],"MissingSSHKeyWarningLink|add an SSH key":["ajouter un clef SSH"],"New Pipeline Schedule":["Nouveau pipeline programmé"],"New branch":["Nouvelle branche"],"New directory":["Nouveau dossier"],"New file":["Nouveau Fichier"],"New issue":["Nouvel incident"],"New merge request":["Nouvelle demande de fusion"],"New schedule":["Nouveau programme"],"New snippet":["Nouvel extrait de code"],"New tag":["Nouvelle étiquette"],"No repository":["Pas de dépôt"],"No schedules":["Aucun programme"],"Notification events":["Événement de notifications"],"NotificationEvent|Close issue":["Clore l'incident"],"NotificationEvent|Close merge request":["Clore la demande de fusion"],"NotificationEvent|Failed pipeline":["Pipeline échoué"],"NotificationEvent|Merge merge request":["Fusionner le demande de fusion"],"NotificationEvent|New issue":["Nouvel incident"],"NotificationEvent|New merge request":["Nouvelle demande de fusion"],"NotificationEvent|New note":["Nouvelle note"],"NotificationEvent|Reassign issue":["Réassigner l'incident"],"NotificationEvent|Reassign merge request":["Réassigner la demande de fusion"],"NotificationEvent|Reopen issue":["Ré-ouvrir l'incident"],"NotificationEvent|Successful pipeline":["Pipeline réussi"],"NotificationLevel|Custom":["Personnalisé"],"NotificationLevel|Disabled":["Désactivé"],"NotificationLevel|Global":["Global"],"NotificationLevel|On mention":["En cas de mention"],"NotificationLevel|Participate":["Participation"],"NotificationLevel|Watch":["Surveillé"],"OfSearchInADropdown|Filter":["Filtre"],"Options":["Options"],"Owner":["Propriétaire"],"Pipeline":["Pipeline"],"Pipeline Schedule":["Programmation de pipeline"],"Pipeline Schedules":["Programmations de pipeline"],"PipelineSchedules|Activated":["Activé"],"PipelineSchedules|Active":["Active"],"PipelineSchedules|All":["Tous"],"PipelineSchedules|Inactive":["Inactive"],"PipelineSchedules|Next Run":["Prochaine exécution"],"PipelineSchedules|None":["Aucune"],"PipelineSchedules|Provide a short description for this pipeline":["Indiquez une courte description"],"PipelineSchedules|Take ownership":["S’approprier"],"PipelineSchedules|Target":["Cible"],"Project '%{project_name}' queued for deletion.":["Projet '%{project_name}' en attente de suppression."],"Project '%{project_name}' was successfully created.":["Projet '%{project_name}' créé avec succès."],"Project '%{project_name}' was successfully updated.":["Projet '%{project_name}' mis à jour avec succès."],"Project '%{project_name}' will be deleted.":["Projet '%{project_name}' sera supprimé."],"Project access must be granted explicitly to each user.":["L’accès au projet doit être explicitement accordé à chaque utilisateur."],"Project export could not be deleted.":["L'export du projet n'a pas pu être supprimé."],"Project export has been deleted.":["L'export du projet a été supprimé."],"#~ \"Project export link has expired. Please generate a new export from your projec\"#~ \"t settings.\"":["#~ \"Le lien de l’export du projet a expiré. Merci de générer un nouvel export depu\"#~ \"is les paramètres du projet.\""],"Project export started. A download link will be sent by email.":["#~ \"L'export du projet a débuté. Un lien de téléchargement sera envoyé par courrie\"#~ \"l.\""],"Project home":["Accueil du projet"],"ProjectFeature|Disabled":["Désactivé"],"ProjectFeature|Everyone with access":["Toute personne ayant accès"],"ProjectFeature|Only team members":["Seulement les membres de l'équipe"],"ProjectFileTree|Name":["Nom"],"ProjectLastActivity|Never":["Jamais"],"ProjectNetworkGraph|Graph":["Graphique "],"Readme":["LisezMoi"],"RefSwitcher|Branches":["Branches"],"RefSwitcher|Tags":["Étiquettes"],"Remind later":["Me le rappeler ultérieurement"],"Remove project":["Supprimer le projet"],"Request Access":["Demander l'accès"],"Revert this commit":["Annuler cette validation"],"Revert this merge-request":["Annuler cette demande de fusion"],"Save pipeline schedule":["Sauvegarder le pipeline programmé"],"Schedule a new pipeline":["Programmer un nouveau pipeline"],"Scheduling Pipelines":["Programmer des pipelines"],"Search branches and tags":["Rechercher dans les branches et les étiquettes"],"Select Archive Format":["Sélectionnez le format de l'archive"],"Select a timezone":["Sélectionnez un fuseau horaire"],"Select target branch":["Sélectionnez une branche cible"],"Set a password on your account to pull or push via %{protocol}":["#~ \"Définissez un mot de passe pour votre compte pour pouvoir tirer ou pousser par\"#~ \" %{protocol}\""],"Set up CI":["Mettre en place le CI"],"Set up Koding":["Mettre en place Koding"],"Set up auto deploy":["Mettre en place l’auto-déploiement "],"SetPasswordToCloneLink|set a password":["définir un mot de passe"],"Source code":["Code source"],"StarProject|Star":["S'abonner"],"Start a <strong>new merge request</strong> with these changes":["Créer une <strong>nouvelle demande de fusion</strong> avec ces changements"],"Switch branch/tag":["Changer de branche / d'étiquette"],"Tag":["Étiquette","Étiquettes"],"Tags":["Étiquettes"],"Target Branch":["Branche cible"],"The fork relationship has been removed.":["La relation de fork a été supprimée."],"#~ \"The pipelines schedule runs pipelines in the future, repeatedly, for specific \"#~ \"branches or tags. Those scheduled pipelines will inherit limited project acces\"#~ \"s based on their associated user.\"":["#~ \"Les pipelines programmés exécutent des pipelines dans le futur, de façon répét\"#~ \"ée, pour les branches et étiquettes spécifiées. Ces pipelines programmés hérit\"#~ \"ent d’un accès partiel au projet basé sur l’utilisateur que leurs est associé.\""],"The project can be accessed by any logged in user.":["Votre projet peut être accédé par n’importe quel utilisateur authentifié"],"The project can be accessed without any authentication.":["Votre projet peut être accédé sans aucune authentification."],"The repository for this project does not exist.":["Le dépôt pour ce projet n'existe pas."],"#~ \"This means you can not push code until you create an empty repository or impor\"#~ \"t existing one.\"":["#~ \"Cela signifie que vous ne pouvez pas pousser du code tant que vous ne créez pa\"#~ \"s un dépôt vide, ou importez une dépôt existant.\""],"Timeago|%s days ago":["Il y a %s jours"],"Timeago|%s days remaining":["Il reste %s jours"],"Timeago|%s hours remaining":["Il reste %s heures"],"Timeago|%s minutes ago":["Il y a %s minutes"],"Timeago|%s minutes remaining":["Il reste %s minutes"],"Timeago|%s months ago":["Il y a %s mois"],"Timeago|%s months remaining":["Il reste %s mois"],"Timeago|%s seconds remaining":["Il reste %s secondes"],"Timeago|%s weeks ago":["Il y a %s semaines"],"Timeago|%s weeks remaining":["Il reste %s semaines"],"Timeago|%s years ago":["Il y a %s ans"],"Timeago|%s years remaining":["Il reste %s ans"],"Timeago|1 day remaining":["Il reste un jour"],"Timeago|1 hour remaining":["Il reste une heure"],"Timeago|1 minute remaining":["Il reste une minute"],"Timeago|1 month remaining":["Il reste un mois"],"Timeago|1 week remaining":["Il reste une semaine"],"Timeago|1 year remaining":["Il reste un an"],"Timeago|Past due":["En retard"],"Timeago|a day ago":["Il y a un jour"],"Timeago|a month ago":["Il y a un mois"],"Timeago|a week ago":["Il y a une semaine"],"Timeago|a while":["Il y a un moment"],"Timeago|a year ago":["Il y a un an"],"Timeago|about %s hours ago":["Il y a environ %s heures"],"Timeago|about a minute ago":["Il y a environ une minute"],"Timeago|about an hour ago":["Il y a environ une heure"],"Timeago|in %s days":["Dans %s jours"],"Timeago|in %s hours":["Dans %s heures"],"Timeago|in %s minutes":["Dans %s minutes"],"Timeago|in %s months":["Dans %s mois"],"Timeago|in %s seconds":["Dans %s secondes"],"Timeago|in %s weeks":["Dans %s semaines"],"Timeago|in %s years":["Dans %s années"],"Timeago|in 1 day":["Dans 1 jour"],"Timeago|in 1 hour":["Dans 1 heure"],"Timeago|in 1 minute":["Dans 1 minute"],"Timeago|in 1 month":["Dans 1 mois"],"Timeago|in 1 week":["Dans 1 semaine"],"Timeago|in 1 year":["Dans 1 an"],"Timeago|less than a minute ago":["il y a moins d'une minute"],"Unstar":["Se désabonner"],"Upload New File":["Téléverser un nouveau fichier"],"Upload file":["Téléverser un fichier"],"Use your global notification setting":["Utiliser vos paramètres de notification globaux"],"VisibilityLevel|Internal":["Interne"],"VisibilityLevel|Private":["Privé"],"VisibilityLevel|Public":["Publique"],"Withdraw Access Request":["Retirer la demande d'accès"],"#~ \"You are going to remove %{project_name_with_namespace}.\\n\"#~ \"Removed project CANNOT be restored!\\n\"#~ \"Are you ABSOLUTELY sure?\"":["#~ \"Vous êtes sur le point de supprimer %{project_name_with_namespace}.\\n\"#~ \"Les projets supprimés NE PEUVENT PAS être restaurés !\\n\"#~ \"Êtes vous ABSOLUMENT sûr ? \""],"#~ \"You are going to remove the fork relationship to source project %{forked_from_\"#~ \"project}. Are you ABSOLUTELY sure?\"":["#~ \"Vous allez supprimer la relation de fork avec le projet source %{forked_from_p\"#~ \"roject}. Êtes-vous VRAIMENT sûr.\""],"#~ \"You are going to transfer %{project_name_with_namespace} to another owner. Are\"#~ \" you ABSOLUTELY sure?\"":["#~ \"Vous allez transférer %{project_name_with_namespace} à un nouveau propriétaire\"#~ \". Êtes vous VRAIMENT sûr ?\""],"You can only add files when you are on a branch":["Vous ne pouvez ajouter de fichier que dans une branche"],"You must sign in to star a project":["Vous devez vous identifier pour vous abonner à un projet"],"You will not get any notifications via email":["Vous ne recevrez aucune notification par courriel"],"You will only receive notifications for the events you choose":["#~ \"Vous ne recevrez de notification que pour les évènements que vous aurez choisi\"#~ \"s\""],"You will only receive notifications for threads you have participated in":["#~ \"Vous ne recevrez de notification que pour les sujets auxquels vous avez partic\"#~ \"ipé\""],"You will receive notifications for any activity":["Vous recevrez des notifications pour n’importe quelles activités"],"You will receive notifications only for comments in which you were @mentioned":["#~ \"Vous ne recevrez de notifications que pour les commentaires où vous êtes @ment\"#~ \"ionné\""],"#~ \"You won't be able to pull or push project code via %{protocol} until you %{set\"#~ \"_password_link} on your account\"":["#~ \"Vous ne pourrez pas récupérer ou pousser de code par %{protocol} tant que vo\"#~ \"us n'aurez pas %{set_password_link} pour votre compte\""],"#~ \"You won't be able to pull or push project code via SSH until you %{add_ssh_key\"#~ \"_link} to your profile\"":["#~ \"Vous ne pourrez pas récupérer ou pousser de code par SSH tant que vous n’aur\"#~ \"ez pas %{add_ssh_key_link} dans votre profil\""],"Your name":["Votre nom"],"notification emails":["courriels de notification"],"parent":["parent","parents"],"pipeline schedules documentation":["documentation des pipeline programmés"],"with stage":["avec l'étape","avec les étapes"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 7ba676d6d20..2f4328b56e1 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -1,29 +1,12 @@
import Jed from 'jed';
+import sprintf from './sprintf';
-/**
- This is required to require all the translation folders in the current directory
- this saves us having to do this manually & keep up to date with new languages
-**/
-function requireAll(requireContext) { return requireContext.keys().map(requireContext); }
-
-const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/));
-const locales = allLocales.reduce((d, obj) => {
- const data = d;
- const localeKey = Object.keys(obj)[0];
-
- data[localeKey] = obj[localeKey];
-
- return data;
-}, {});
-
-let lang = document.querySelector('html').getAttribute('lang') || 'en';
-lang = lang.replace(/-/g, '_');
-
-const locale = new Jed(locales[lang]);
+const languageCode = () => document.querySelector('html').getAttribute('lang') || 'en';
+const locale = new Jed(window.translations || {});
+delete window.translations;
/**
Translates `text`
-
@param text The text to be translated
@returns {String} The translated text
**/
@@ -63,8 +46,19 @@ const pgettext = (keyOrContext, key) => {
return translated[translated.length - 1];
};
-export { lang };
+/**
+ Creates an instance of Intl.DateTimeFormat for the current locale.
+
+ @param formatOptions for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
+ @returns {Intl.DateTimeFormat}
+*/
+const createDateTimeFormat =
+ formatOptions => Intl.DateTimeFormat(languageCode(), formatOptions);
+
+export { languageCode };
export { gettext as __ };
export { ngettext as n__ };
export { pgettext as s__ };
+export { sprintf };
+export { createDateTimeFormat };
export default locale;
diff --git a/app/assets/javascripts/locale/pt_BR/app.js b/app/assets/javascripts/locale/pt_BR/app.js
deleted file mode 100644
index f2eed3da064..00000000000
--- a/app/assets/javascripts/locale/pt_BR/app.js
+++ /dev/null
@@ -1 +0,0 @@
-var locales = locales || {}; locales['pt_BR'] = {"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","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-05 03:29-0400","Last-Translator":"Alexandre Alencar <alexandre.alencar@gmail.com>","Language-Team":"Portuguese (Brazil)","Language":"pt-BR","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=2; plural=(n != 1)","lang":"pt_BR","domain":"app","plural_forms":"nplurals=2; plural=(n != 1)"},"ByAuthor|by":["por"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["A Análise de Ciclo fornece uma visão geral de quanto tempo uma ideia demora para ir para produção em seu projeto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Tarefa"],"CycleAnalyticsStage|Plan":["Plano"],"CycleAnalyticsStage|Production":["Produção"],"CycleAnalyticsStage|Review":["Revisão"],"CycleAnalyticsStage|Staging":["Homologação"],"CycleAnalyticsStage|Test":["Teste"],"Deploy":["Implantação","Implantações"],"FirstPushedBy|First":["Primeiro"],"FirstPushedBy|pushed by":["publicado por"],"From issue creation until deploy to production":["Da criação de tarefas até a implantação para a produção"],"From merge request merge until deploy to production":["Da incorporação do merge request até a implantação em produção"],"Introducing Cycle Analytics":["Apresentando a Análise de Ciclo"],"Last %d day":["Último %d dia","Últimos %d dias"],"Limited to showing %d event at most":["Limitado a mostrar %d evento no máximo","Limitado a mostrar %d eventos no máximo"],"Median":["Mediana"],"New Issue":["Nova Tarefa","Novas Tarefas"],"Not available":["Não disponível"],"Not enough data":["Dados insuficientes"],"OpenedNDaysAgo|Opened":["Aberto"],"Pipeline Health":["Saúde da Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Ler mais"],"Related Commits":["Commits Relacionados"],"Related Deployed Jobs":["Jobs Relacionados Incorporados"],"Related Issues":["Tarefas Relacionadas"],"Related Jobs":["Jobs Relacionados"],"Related Merge Requests":["Merge Requests Relacionados"],"Related Merged Requests":["Merge Requests Relacionados"],"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.":["O estágio de codificação mostra o tempo desde o primeiro commit até a criação do merge request. \\nOs dados serão automaticamente adicionados aqui uma vez que você tenha criado seu primeiro merge request."],"The collection of events added to the data gathered for that stage.":["A coleção de eventos adicionados aos dados coletados para esse estágio."],"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.":["O estágio em questão mostra o tempo que leva desde a criação de uma tarefa até a sua assinatura para um milestone, ou a sua adição para a lista no seu Painel de Tarefas. Comece a criar tarefas para ver dados para esta etapa."],"The phase of the development lifecycle.":["A fase do ciclo de vida do desenvolvimento."],"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.":["A fase de planejamento mostra o tempo do passo anterior até empurrar o seu primeiro commit. Este tempo será adicionado automaticamente assim que você realizar seu primeiro 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.":["O estágio de produção mostra o tempo total que leva entre criar uma tarefa e implantar o código na produção. Os dados serão adicionados automaticamente até que você complete todo o ciclo de produção."],"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.":["A etapa de revisão mostra o tempo de criação de um merge request até que o merge seja feito. Os dados serão automaticamente adicionados depois que você fizer seu primeiro 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.":["O estágio de estágio mostra o tempo entre a fusão do MR e o código de implantação para o ambiente de produção. Os dados serão automaticamente adicionados depois de implantar na produção pela primeira 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.":["A fase de teste mostra o tempo que o GitLab CI leva para executar cada pipeline para o merge request relacionado. Os dados serão automaticamente adicionados após a conclusão do primeiro pipeline."],"The time taken by each data entry gathered by that stage.":["O tempo necessário para cada entrada de dados reunida por essa 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.":["O valor situado no ponto médio de uma série de valores observados. Ex., entre 3, 5, 9, a mediana é 5. Entre 3, 5, 7, 8, a mediana é (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tempo até que uma tarefa seja planejada"],"Time before an issue starts implementation":["Tempo até que uma tarefa comece a ser implementada"],"Time between merge request creation and merge/close":["Tempo entre a criação do merge request e o merge/fechamento"],"Time until first merge request":["Tempo até o primeiro merge request"],"Time|hr":["h","hs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tempo Total"],"Total test time for all commits/merges":["Tempo de teste total para todos os commits/merges"],"Want to see the data? Please ask an administrator for access.":["Precisa visualizar os dados? Solicite acesso ao administrador."],"We don't have enough data to show this stage.":["Não temos dados suficientes para mostrar esta fase."],"You need permission.":["Você precisa de permissão."],"day":["dia","dias"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
new file mode 100644
index 00000000000..5f4a053f98e
--- /dev/null
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -0,0 +1,26 @@
+import _ from 'underscore';
+
+/**
+ Very limited implementation of sprintf supporting only named parameters.
+
+ @param input (translated) text with parameters (e.g. '%{num_users} users use us')
+ @param parameters object mapping parameter names to values (e.g. { num_users: 5 })
+ @param escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape)
+ @returns {String} the text with parameters replaces (e.g. '5 users use us')
+
+ @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf
+ @see https://gitlab.com/gitlab-org/gitlab-ce/issues/37992
+**/
+export default (input, parameters, escapeParameters = true) => {
+ let output = input;
+
+ if (parameters) {
+ Object.keys(parameters).forEach((parameterName) => {
+ const parameterValue = parameters[parameterName];
+ const escapedParameterValue = escapeParameters ? _.escape(parameterValue) : parameterValue;
+ output = output.replace(new RegExp(`%{${parameterName}}`, 'g'), escapedParameterValue);
+ });
+ }
+
+ return output;
+};
diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js
deleted file mode 100644
index 9c28e4e4627..00000000000
--- a/app/assets/javascripts/locale/zh_CN/app.js
+++ /dev/null
@@ -1 +0,0 @@
-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-06-15 21:59-0500","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-19 09:57-0400","Last-Translator":"Huang Tao <htve@outlook.com>","Language-Team":"Chinese (China) (https://translate.zanata.org/project/view/GitLab)","Language":"zh-CN","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=1; plural=0","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0"},"%{commit_author_link} committed %{commit_timeago}":["由 %{commit_author_link} 提交于 %{commit_timeago}"],"About auto deploy":["关于自动部署"],"Active":["启用"],"Activity":["活动"],"Add Changelog":["添加更新日志"],"Add Contribution guide":["添加贡献指南"],"Add License":["添加许可证"],"Add an SSH key to your profile to pull or push via SSH.":["新建一个用于推送或拉取的 SSH 秘钥到账号中。"],"Add new directory":["添加目录"],"Archived project! Repository is read-only":["项目已归档!存储库为只读状态"],"Are you sure you want to delete this pipeline schedule?":["确定要删除此流水线计划吗?"],"Attach a file by drag &amp; drop or %{upload_link}":["拖放文件到此处或者 %{upload_link}"],"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}":["已创建分支 <strong>%{branch_name}</strong> 。如需设置自动部署, 请选择合适的 GitLab CI Yaml 模板并提交更改。%{link_to_autodeploy_doc}"],"Branches":["分支"],"Browse files":["浏览文件"],"ByAuthor|by":["作者:"],"CI configuration":["CI 配置"],"Cancel":["取消"],"ChangeTypeActionLabel|Pick into branch":["选择分支"],"ChangeTypeActionLabel|Revert in branch":["还原分支"],"ChangeTypeAction|Cherry-pick":["优选"],"ChangeTypeAction|Revert":["还原"],"Changelog":["更新日志"],"Charts":["统计图"],"Cherry-pick this commit":["优选此提交"],"Cherry-pick this merge request":["优选此合并请求"],"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 message":["提交信息"],"CommitBoxTitle|Commit":["提交"],"CommitMessage|Add %{file_name}":["添加 %{file_name}"],"Commits":["提交"],"Commits|History":["历史"],"Committed by":["提交者:"],"Compare":["比较"],"Contribution guide":["贡献指南"],"Contributors":["贡献者"],"Copy URL to clipboard":["复制 URL 到剪贴板"],"Copy commit SHA to clipboard":["复制提交 SHA 的值到剪贴板"],"Create New Directory":["创建新目录"],"Create directory":["创建目录"],"Create empty bare repository":["创建空的存储库"],"Create merge request":["创建合并请求"],"Create new...":["创建..."],"CreateNewFork|Fork":["派生"],"CreateTag|Tag":["标签"],"Cron Timezone":["Cron 时区"],"Cron syntax":["Cron 语法"],"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}.":["自定义通知级别继承自参与级别。使用自定义通知级别,您会收到参与级别及选定事件的通知。想了解更多信息,请查看 %{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":["测试"],"Define a custom pattern with cron syntax":["使用 Cron 语法定义自定义模式"],"Delete":["删除"],"Deploy":["部署"],"Description":["描述"],"Directory name":["目录名称"],"Don't show again":["不再显示"],"Download":["下载"],"Download tar":["下载 tar"],"Download tar.bz2":["下载 tar.bz2"],"Download tar.gz":["下载 tar.gz"],"Download zip":["下载 zip"],"DownloadArtifacts|Download":["下载"],"DownloadCommit|Email Patches":["电子邮件补丁"],"DownloadCommit|Plain Diff":["差异文件"],"DownloadSource|Download":["下载"],"Edit":["编辑"],"Edit Pipeline Schedule %{id}":["编辑 %{id} 流水线计划"],"Every day (at 4:00am)":["每日执行(凌晨 4 点)"],"Every month (on the 1st at 4:00am)":["每月执行(每月 1 日凌晨 4 点)"],"Every week (Sundays at 4:00am)":["每周执行(周日凌晨 4 点)"],"Failed to change the owner":["无法变更所有者"],"Failed to remove the pipeline schedule":["无法删除流水线计划"],"Files":["文件"],"Find by path":["按路径查找"],"Find file":["查找文件"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"Fork":["派生"],"ForkedFromProjectPath|Forked from":["派生自"],"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":["导入存储库"],"Interval Pattern":["循环周期"],"Introducing Cycle Analytics":["周期分析简介"],"LFSStatus|Disabled":["停用"],"LFSStatus|Enabled":["启用"],"Last %d day":["最近 %d 天"],"Last Pipeline":["最新流水线"],"Last Update":["最后更新"],"Last commit":["最后提交"],"Learn more in the":["了解更多"],"Learn more in the|pipeline schedules documentation":["流水线计划文档"],"Leave group":["退出群组"],"Leave project":["退出项目"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"MissingSSHKeyWarningLink|add an SSH key":["新建 SSH 公钥"],"New Issue":["新建议题"],"New Pipeline Schedule":["创建流水线计划"],"New branch":["新建分支"],"New directory":["新建目录"],"New file":["新建文件"],"New issue":["新建议题"],"New merge request":["新建合并请求"],"New schedule":["新建计划"],"New snippet":["新建代码片段"],"New tag":["新建标签"],"No repository":["没有存储库"],"No schedules":["没有计划"],"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":["关注"],"OfSearchInADropdown|Filter":["筛选"],"OpenedNDaysAgo|Opened":["开始于"],"Options":["操作"],"Owner":["所有者"],"Pipeline":["流水线"],"Pipeline Health":["流水线健康指标"],"Pipeline Schedule":["流水线计划"],"Pipeline Schedules":["流水线计划"],"PipelineSchedules|Activated":["是否启用"],"PipelineSchedules|Active":["已启用"],"PipelineSchedules|All":["所有"],"PipelineSchedules|Inactive":["未启用"],"PipelineSchedules|Next Run":["下次运行时间"],"PipelineSchedules|None":["无"],"PipelineSchedules|Provide a short description for this pipeline":["为此流水线提供简短描述"],"PipelineSchedules|Take ownership":["取得所有者"],"PipelineSchedules|Target":["目标"],"PipelineSheduleIntervalPattern|Custom":["自定义"],"Pipeline|with stage":["于阶段"],"Pipeline|with stages":["于阶段"],"Project '%{project_name}' queued for deletion.":["项目 '%{project_name}' 已进入删除队列。"],"Project '%{project_name}' was successfully created.":["项目 '%{project_name}' 已创建成功。"],"Project '%{project_name}' was successfully updated.":["项目 '%{project_name}' 已更新完成。"],"Project '%{project_name}' will be deleted.":["项目 '%{project_name}' 将被删除。"],"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":["申请权限"],"Revert this commit":["还原此提交"],"Revert this merge request":["还原此合并请求"],"Save pipeline schedule":["保存流水线计划"],"Schedule a new pipeline":["新建流水线计划"],"Scheduling Pipelines":["流水线计划"],"Search branches and tags":["搜索分支和标签"],"Select Archive Format":["选择下载格式"],"Select a timezone":["选择时区"],"Select target branch":["选择目标分支"],"Set a password on your account to pull or push via %{protocol}":["为账号创建一个用于推送或拉取的 %{protocol} 密码。"],"Set up CI":["设置 CI"],"Set up Koding":["设置 Koding"],"Set up auto deploy":["设置自动部署"],"SetPasswordToCloneLink|set a password":["设置密码"],"Showing %d event":["显示 %d 个事件"],"Source code":["源代码"],"StarProject|Star":["星标"],"Start a %{new_merge_request} with these changes":["由此更改 %{new_merge_request}"],"Switch branch/tag":["切换分支/标签"],"Tag":["标签"],"Tags":["标签"],"Target Branch":["目标分支"],"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 pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.":["流水线计划会周期性重复运行指定分支或标签的流水线。这些流水线将根据其关联用户继承有限的项目访问权限。"],"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.":["测试阶段概述了 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。"],"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":[" %s 天前"],"Timeago|%s days remaining":["剩余 %s 天"],"Timeago|%s hours remaining":["剩余 %s 小时"],"Timeago|%s minutes ago":[" %s 分钟前"],"Timeago|%s minutes remaining":["剩余 %s 分钟"],"Timeago|%s months ago":[" %s 个月前"],"Timeago|%s months remaining":["剩余 %s 月"],"Timeago|%s seconds remaining":["剩余 %s 秒"],"Timeago|%s weeks ago":[" %s 星期前"],"Timeago|%s weeks remaining":["剩余 %s 星期"],"Timeago|%s years ago":[" %s 年前"],"Timeago|%s years remaining":["剩余 %s 年"],"Timeago|1 day remaining":["剩余 1 天"],"Timeago|1 hour remaining":["剩余 1 小时"],"Timeago|1 minute remaining":["剩余 1 分钟"],"Timeago|1 month remaining":["剩余 1 个月"],"Timeago|1 week remaining":["剩余 1 星期"],"Timeago|1 year remaining":["剩余 1 年"],"Timeago|Past due":["逾期"],"Timeago|a day ago":[" 1 天前"],"Timeago|a month ago":[" 1 个月前"],"Timeago|a week ago":[" 1 星期前"],"Timeago|a while":["刚刚"],"Timeago|a year ago":[" 1 年前"],"Timeago|about %s hours ago":["约 %s 小时前"],"Timeago|about a minute ago":["约 1 分钟前"],"Timeago|about an hour ago":["约 1 小时前"],"Timeago|in %s days":[" %s 天后"],"Timeago|in %s hours":[" %s 小时后"],"Timeago|in %s minutes":[" %s 分钟后"],"Timeago|in %s months":[" %s 个月后"],"Timeago|in %s seconds":[" %s 秒后"],"Timeago|in %s weeks":[" %s 星期后"],"Timeago|in %s years":[" %s 年后"],"Timeago|in 1 day":[" 1 天后"],"Timeago|in 1 hour":[" 1 小时后"],"Timeago|in 1 minute":[" 1 分钟后"],"Timeago|in 1 month":[" 1 月后"],"Timeago|in 1 week":[" 1 星期后"],"Timeago|in 1 year":[" 1 年后"],"Timeago|less than a minute ago":["不到 1 分钟前"],"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?":["即将要删除 %{project_name_with_namespace}。\\n已删除的项目无法恢复!\\n确定继续吗?"],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":["即将删除与源项目 %{forked_from_project} 的派生关系。确定继续吗?"],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":["即将 %{project_name_with_namespace} 转移给另一个所有者。确定继续吗?"],"You can only add files when you are on a branch":["只能在分支上添加文件"],"You have reached your project limit":["您已达到项目数量限制"],"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":["在账号中 %{set_password_link} 之前将无法通过 %{protocol} 拉取或推送代码。"],"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} 之前将无法通过 SSH 拉取或推送代码。"],"Your name":["您的名字"],"day":["天"],"new merge request":["新建合并请求"],"notification emails":["通知邮件"],"parent":["父级"]}}}; \ 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
deleted file mode 100644
index 30cb1e6b89e..00000000000
--- a/app/assets/javascripts/locale/zh_HK/app.js
+++ /dev/null
@@ -1 +0,0 @@
-var locales = locales || {}; locales['zh_HK'] = {"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","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;"},"Are you sure you want to delete this pipeline schedule?":[""],"ByAuthor|by":["作者:"],"Cancel":[""],"Commit":["提交"],"Cron Timezone":[""],"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":["測試"],"Delete":[""],"Deploy":["部署"],"Description":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Filter":[""],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Interval Pattern":[""],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Last Pipeline":[""],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"New Pipeline Schedule":[""],"No schedules":[""],"Not available":["不可用"],"Not enough data":["數據不足"],"OpenedNDaysAgo|Opened":["開始於"],"Owner":[""],"Pipeline Health":["流水線健康指標"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"ProjectLifecycle|Stage":["項目生命週期"],"Read more":["了解更多"],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Select a timezone":[""],"Select target branch":[""],"Showing %d event":["顯示 %d 個事件"],"Target Branch":[""],"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
deleted file mode 100644
index f0fe1e31f18..00000000000
--- a/app/assets/javascripts/locale/zh_TW/app.js
+++ /dev/null
@@ -1 +0,0 @@
-var locales = locales || {}; locales['zh_TW'] = {"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","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;"},"Are you sure you want to delete this pipeline schedule?":[""],"ByAuthor|by":["作者:"],"Cancel":[""],"Commit":["送交"],"Cron Timezone":[""],"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":["測試"],"Delete":[""],"Deploy":["部署"],"Description":[""],"Edit":[""],"Edit Pipeline Schedule %{id}":[""],"Failed to change the owner":[""],"Failed to remove the pipeline schedule":[""],"Filter":[""],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Interval Pattern":[""],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Last Pipeline":[""],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"New Pipeline Schedule":[""],"No schedules":[""],"Not available":["無法使用"],"Not enough data":["資料不足"],"OpenedNDaysAgo|Opened":["開始於"],"Owner":[""],"Pipeline Health":["流水線健康指標"],"Pipeline Schedule":[""],"Pipeline Schedules":[""],"PipelineSchedules|Activated":[""],"PipelineSchedules|Active":[""],"PipelineSchedules|All":[""],"PipelineSchedules|Inactive":[""],"PipelineSchedules|Next Run":[""],"PipelineSchedules|None":[""],"PipelineSchedules|Provide a short description for this pipeline":[""],"PipelineSchedules|Take ownership":[""],"PipelineSchedules|Target":[""],"ProjectLifecycle|Stage":["專案生命週期"],"Read more":["了解更多"],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Save pipeline schedule":[""],"Schedule a new pipeline":[""],"Select a timezone":[""],"Select target branch":[""],"Showing %d event":["顯示 %d 個事件"],"Target Branch":[""],"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/logo.js b/app/assets/javascripts/logo.js
index 729baa2e1a7..403e216e70f 100644
--- a/app/assets/javascripts/logo.js
+++ b/app/assets/javascripts/logo.js
@@ -1,7 +1,7 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback */
+import $ from 'jquery';
-(function() {
- window.addEventListener('beforeunload', function() {
+export default function initLogoAnimation() {
+ window.addEventListener('beforeunload', () => {
$('.tanuki-logo').addClass('animate');
});
-}).call(window);
+}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index d27b4ec78c6..2c80baba10b 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,207 +1,93 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, quotes, consistent-return, prefer-arrow-callback, comma-dangle, object-shorthand, no-new, max-len, no-multi-spaces, import/newline-after-import, import/first */
-/* global bp */
-/* global Flash */
-/* global ConfirmDangerModal */
-/* global Aside */
+/* eslint-disable import/first */
+/* global $ */
import jQuery from 'jquery';
-import _ from 'underscore';
import Cookies from 'js-cookie';
-import Pikaday from 'pikaday';
-import Dropzone from 'dropzone';
-import Sortable from 'vendor/Sortable';
-
-// libraries with import side-effects
-import 'mousetrap';
-import 'mousetrap/plugins/pause/mousetrap-pause';
-import 'vendor/fuzzaldrin-plus';
-
-// extensions
-import './extensions/array';
+import svg4everybody from 'svg4everybody';
// expose common libraries as globals (TODO: remove these)
window.jQuery = jQuery;
window.$ = jQuery;
-window._ = _;
-window.Pikaday = Pikaday;
-window.Dropzone = Dropzone;
-window.Sortable = Sortable;
-
-// shortcuts
-import './shortcuts';
-import './shortcuts_blob';
-import './shortcuts_dashboard_navigation';
-import './shortcuts_navigation';
-import './shortcuts_find_file';
-import './shortcuts_issuable';
-import './shortcuts_network';
+
+// lib/utils
+import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
+import { localTimeAgo } from './lib/utils/datetime_utility';
+import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// behaviors
import './behaviors/';
-// templates
-import './templates/issuable_template_selector';
-import './templates/issuable_template_selectors';
-
-// commit
-import './commit/file';
-import './commit/image_file';
-
-// lib/utils
-import './lib/utils/animate';
-import './lib/utils/bootstrap_linked_tabs';
-import './lib/utils/common_utils';
-import './lib/utils/datetime_utility';
-import './lib/utils/pretty_time';
-import './lib/utils/text_utility';
-import './lib/utils/url_utility';
-
-// u2f
-import './u2f/authenticate';
-import './u2f/error';
-import './u2f/register';
-import './u2f/util';
-
// everything else
-import './abuse_reports';
-import './activities';
-import './admin';
-import './ajax_loading_spinner';
-import './api';
-import './aside';
-import './autosave';
-import AwardsHandler from './awards_handler';
-import './breakpoints';
-import './broadcast_message';
-import './build';
-import './build_artifacts';
-import './build_variables';
-import './ci_lint_editor';
-import './commit';
-import './commits';
-import './compare';
-import './compare_autocomplete';
-import './confirm_danger_modal';
-import './copy_as_gfm';
-import './copy_to_clipboard';
-import './create_label';
-import './diff';
-import './dispatcher';
-import './dropzone_input';
-import './due_date_select';
-import './files_comment_button';
-import './flash';
+import loadAwardsHandler from './awards_handler';
+import bp from './breakpoints';
+import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
-import './gl_field_error';
-import './gl_field_errors';
-import './gl_form';
-import './group_avatar';
-import './group_label_subscription';
-import './groups_select';
-import './header';
-import './importer_status';
-import './issuable_index';
-import './issuable_context';
-import './issuable_form';
-import './issue';
-import './issue_status_select';
-import './label_manager';
-import './labels';
-import './labels_select';
-import './layout_nav';
-import './line_highlighter';
-import './logo';
-import './member_expiration_date';
-import './members';
-import './merge_request';
-import './merge_request_tabs';
-import './milestone';
+import initTodoToggle from './header';
+import initImporterStatus from './importer_status';
+import initLayoutNav from './layout_nav';
+import './feature_highlight/feature_highlight_options';
+import LazyLoader from './lazy_loader';
+import initLogoAnimation from './logo';
import './milestone_select';
-import './mini_pipeline_graph_dropdown';
-import './namespace_select';
-import './new_branch_form';
-import './new_commit_form';
-import './notes';
-import './notifications_dropdown';
-import './notifications_form';
-import './pager';
-import './pipelines';
-import './preview_markdown';
-import './project';
-import './project_avatar';
-import './project_find_file';
-import './project_fork';
-import './project_import';
-import './project_label_subscription';
-import './project_new';
-import './project_select';
-import './project_show';
-import './project_variables';
-import './projects_list';
-import './render_gfm';
-import './render_math';
-import './right_sidebar';
-import './search';
-import './search_autocomplete';
-import './signin_tabs_memoizer';
-import './single_file_diff';
-import './smart_interval';
-import './snippets_list';
-import './star';
-import './subscription';
-import './subscription_select';
-import './syntax_highlight';
-import './task_list';
-import './todos';
-import './tree';
-import './usage_ping';
-import './user';
-import './user_tabs';
-import './username_validator';
-import './users_select';
-import './version_check_image';
-import './visibility_select';
-import './wikis';
-import './zen_mode';
-
-// eslint-disable-next-line global-require, import/no-commonjs
-if (process.env.NODE_ENV !== 'production') require('./test_utils/');
-
-document.addEventListener('beforeunload', function () {
+import './projects_dropdown';
+import initBreadcrumbs from './breadcrumb';
+
+import initDispatcher from './dispatcher';
+
+// inject test utilities if necessary
+if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) {
+ $.fx.off = true;
+ import(/* webpackMode: "eager" */ './test_utils/');
+}
+
+svg4everybody();
+
+document.addEventListener('beforeunload', () => {
// Unbind scroll events
$(document).off('scroll');
// Close any open tooltips
$('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy');
+ // Close any open popover
+ $('[data-toggle="popover"]').popover('destroy');
});
-window.addEventListener('hashchange', gl.utils.handleLocationHash);
+window.addEventListener('hashchange', handleLocationHash);
window.addEventListener('load', function onLoad() {
window.removeEventListener('load', onLoad, false);
- gl.utils.handleLocationHash();
+ handleLocationHash();
}, false);
-$(function () {
- var $body = $('body');
- var $document = $(document);
- var $window = $(window);
- var $sidebarGutterToggle = $('.js-sidebar-toggle');
- var $flash = $('.flash-container');
- var bootstrapBreakpoint = bp.getBreakpointSize();
- var fitSidebarForSize;
+gl.lazyLoader = new LazyLoader({
+ scrollContainer: window,
+ observerNode: '#content-body',
+});
+
+document.addEventListener('DOMContentLoaded', () => {
+ const $body = $('body');
+ const $document = $(document);
+ const $window = $(window);
+ const $sidebarGutterToggle = $('.js-sidebar-toggle');
+ let bootstrapBreakpoint = bp.getBreakpointSize();
+
+ initBreadcrumbs();
+ initLayoutNav();
+ initImporterStatus();
+ initTodoToggle();
+ initLogoAnimation();
// Set the default path for all cookies to GitLab's root directory
Cookies.defaults.path = gon.relative_url_root || '/';
// `hashchange` is not triggered when link target is already in window.location
- $body.on('click', 'a[href^="#"]', function() {
- var href = this.getAttribute('href');
- if (href.substr(1) === gl.utils.getLocationHash()) {
- setTimeout(gl.utils.handleLocationHash, 1);
+ $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() {
+ const href = this.getAttribute('href');
+ if (href.substr(1) === getLocationHash()) {
+ setTimeout(handleLocationHash, 1);
}
});
if (bootstrapBreakpoint === 'xs') {
- const $rightSidebar = $('aside.right-sidebar, .page-with-sidebar');
+ const $rightSidebar = $('aside.right-sidebar, .layout-page');
$rightSidebar
.removeClass('right-sidebar-expanded')
@@ -209,156 +95,162 @@ $(function () {
}
// prevent default action for disabled buttons
- $('.btn').click(function(e) {
+ $('.btn').click(function clickDisabledButtonCallback(e) {
if ($(this).hasClass('disabled')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
- });
- $('.js-select-on-focus').on('focusin', function () {
- return $(this).select().one('mouseup', function (e) {
- return e.preventDefault();
- });
- // Click a .js-select-on-focus field, select the contents
- // Prevent a mouseup event from deselecting the input
+ return true;
});
- $('.remove-row').bind('ajax:success', function () {
+
+ addSelectOnFocusBehaviour('.js-select-on-focus');
+
+ $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
$(this).tooltip('destroy')
.closest('li')
.fadeOut();
});
- $('.js-remove-tr').bind('ajax:before', function () {
- return $(this).hide();
+
+ $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
+ $(this).hide();
});
- $('.js-remove-tr').bind('ajax:success', function () {
- return $(this).closest('tr').fadeOut();
+
+ $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
+ $(this).closest('tr').fadeOut();
});
+
+ // Initialize select2 selects
$('select.select2').select2({
width: 'resolve',
- // Initialize select2 selects
- dropdownAutoWidth: true
+ dropdownAutoWidth: true,
});
- $('.js-select2').bind('select2-close', function () {
- return setTimeout((function () {
- $('.select2-container-active').removeClass('select2-container-active');
- return $(':focus').blur();
- }), 1);
+
// Close select2 on escape
+ $('.js-select2').on('select2-close', () => {
+ setTimeout(() => {
+ $('.select2-container-active').removeClass('select2-container-active');
+ $(':focus').blur();
+ }, 1);
});
+
// Initialize tooltips
$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover';
$body.tooltip({
selector: '.has-tooltip, [data-toggle="tooltip"]',
- placement: function (tip, el) {
+ placement(tip, el) {
return $(el).data('placement') || 'bottom';
- }
+ },
+ });
+
+ // Initialize popovers
+ $body.popover({
+ selector: '[data-toggle="popover"]',
+ trigger: 'focus',
+ // set the viewport to the main content, excluding the navigation bar, so
+ // the navigation can't overlap the popover
+ viewport: '.layout-page',
});
- $('.trigger-submit').on('change', function () {
- return $(this).parents('form').submit();
+
// Form submitter
+ $('.trigger-submit').on('change', function triggerSubmitCallback() {
+ $(this).parents('form').submit();
});
- gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true);
- // Flash
- if ($flash.length > 0) {
- $flash.click(function () {
- return $(this).fadeOut();
- });
- $flash.show();
- }
+
+ localTimeAgo($('abbr.timeago, .js-timeago'), true);
+
// Disable form buttons while a form is submitting
- $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) {
- var buttons;
- buttons = $('[type="submit"], .js-disable-on-submit', this);
+ $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) {
+ const $buttons = $('[type="submit"], .js-disable-on-submit', this);
switch (e.type) {
case 'ajax:beforeSend':
case 'submit':
- return buttons.disable();
+ return $buttons.disable();
default:
- return buttons.enable();
+ return $buttons.enable();
}
});
- $(document).ajaxError(function (e, xhrObj) {
- var ref = xhrObj.status;
- if (xhrObj.status === 401) {
- return new Flash('You need to be logged in.', 'alert');
+
+ $(document).ajaxError((e, xhrObj) => {
+ const ref = xhrObj.status;
+
+ if (ref === 401) {
+ Flash('You need to be logged in.');
} else if (ref === 404 || ref === 500) {
- return new Flash('Something went wrong on our end.', 'alert');
+ Flash('Something went wrong on our end.');
}
});
- $('.account-box').hover(function () {
- // Show/Hide the profile menu when hovering the account box
- return $(this).toggleClass('hover');
- });
- $document.on('click', '.diff-content .js-show-suppressed-diff', function () {
- var $container;
- $container = $(this).parent();
- $container.next('table').show();
- return $container.remove();
+
// Commit show suppressed diff
+ $document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() {
+ const $container = $(this).parent();
+ $container.next('table').show();
+ $container.remove();
});
- $('.navbar-toggle').on('click', function () {
- $('.header-content .title, .header-content .navbar-sub-nav').toggle();
- $('.header-content .header-logo').toggle();
- $('.header-content .navbar-collapse').toggle();
- $('.js-navbar-toggle-left, .js-navbar-toggle-right, .title-container').toggle();
- return $('.navbar-toggle').toggleClass('active');
+
+ $('.navbar-toggle').on('click', () => {
+ $('.header-content').toggleClass('menu-expanded');
+ gl.lazyLoader.loadCheck();
});
+
// Show/hide comments on diff
- $body.on('click', '.js-toggle-diff-comments', function (e) {
- var $this = $(this);
- var notesHolders = $this.closest('.diff-file').find('.notes_holder');
+ $body.on('click', '.js-toggle-diff-comments', function toggleDiffCommentsCallback(e) {
+ const $this = $(this);
+ const notesHolders = $this.closest('.diff-file').find('.notes_holder');
+
+ e.preventDefault();
+
$this.toggleClass('active');
+
if ($this.hasClass('active')) {
notesHolders.show().find('.hide, .content').show();
} else {
notesHolders.hide().find('.content').hide();
}
+
$(document).trigger('toggle.comments');
- return e.preventDefault();
});
- $document.off('click', '.js-confirm-danger');
- $document.on('click', '.js-confirm-danger', function (e) {
- var btn = $(e.target);
- var form = btn.closest('form');
- var text = btn.data('confirm-danger-message');
- e.preventDefault();
- return new ConfirmDangerModal(form, text);
- });
- $('input[type="search"]').each(function () {
- var $this = $(this);
- $this.attr('value', $this.val());
- });
- $document.off('keyup', 'input[type="search"]').on('keyup', 'input[type="search"]', function () {
- var $this;
- $this = $(this);
- return $this.attr('value', $this.val());
- });
- $document.off('breakpoint:change').on('breakpoint:change', function (e, breakpoint) {
- var $gutterIcon;
+
+ $document.on('breakpoint:change', (e, breakpoint) => {
if (breakpoint === 'sm' || breakpoint === 'xs') {
- $gutterIcon = $sidebarGutterToggle.find('i');
+ const $gutterIcon = $sidebarGutterToggle.find('i');
if ($gutterIcon.hasClass('fa-angle-double-right')) {
- return $sidebarGutterToggle.trigger('click');
+ $sidebarGutterToggle.trigger('click');
}
}
});
- fitSidebarForSize = function () {
- var oldBootstrapBreakpoint;
- oldBootstrapBreakpoint = bootstrapBreakpoint;
+
+ function fitSidebarForSize() {
+ const oldBootstrapBreakpoint = bootstrapBreakpoint;
bootstrapBreakpoint = bp.getBreakpointSize();
+
if (bootstrapBreakpoint !== oldBootstrapBreakpoint) {
- return $document.trigger('breakpoint:change', [bootstrapBreakpoint]);
+ $document.trigger('breakpoint:change', [bootstrapBreakpoint]);
}
- };
- $window.off('resize.app').on('resize.app', function () {
- return fitSidebarForSize();
+ }
+
+ $window.on('resize.app', fitSidebarForSize);
+
+ loadAwardsHandler();
+
+ $('form.filter-form').on('submit', function filterFormSubmitCallback(event) {
+ const link = document.createElement('a');
+ link.href = this.action;
+
+ const action = `${this.action}${link.search === '' ? '?' : '&'}`;
+
+ event.preventDefault();
+ visitUrl(`${action}${$(this).serialize()}`);
});
- gl.awardsHandler = new AwardsHandler();
- new Aside();
- gl.utils.initTimeagoTimeout();
+ const flashContainer = document.querySelector('.flash-container');
+
+ if (flashContainer && flashContainer.children.length) {
+ flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => {
+ removeFlashClickListener(flashEl);
+ });
+ }
- $(document).trigger('init.scrolling-tabs');
+ initDispatcher();
});
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index e034729bd39..d27922a2099 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -1,53 +1,54 @@
-/* global Pikaday */
-/* global dateFormat */
-(() => {
- // Add datepickers to all `js-access-expiration-date` elements. If those elements are
- // children of an element with the `clearable-input` class, and have a sibling
- // `js-clear-input` element, then show that element when there is a value in the
- // datepicker, and make clicking on that element clear the field.
- //
- window.gl = window.gl || {};
- gl.MemberExpirationDate = (selector = '.js-access-expiration-date') => {
- function toggleClearInput() {
- $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== '');
- }
- const inputs = $(selector);
-
- inputs.each((i, el) => {
- const $input = $(el);
-
- const calendar = new Pikaday({
- field: $input.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- minDate: new Date(),
- container: $input.parent().get(0),
- onSelect(dateText) {
- $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
-
- $input.trigger('change');
-
- toggleClearInput.call($input);
- },
- });
-
- calendar.setDate(new Date($input.val()));
- $input.data('pikaday', calendar);
+import $ from 'jquery';
+import Pikaday from 'pikaday';
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
+
+// Add datepickers to all `js-access-expiration-date` elements. If those elements are
+// children of an element with the `clearable-input` class, and have a sibling
+// `js-clear-input` element, then show that element when there is a value in the
+// datepicker, and make clicking on that element clear the field.
+//
+export default function memberExpirationDate(selector = '.js-access-expiration-date') {
+ function toggleClearInput() {
+ $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== '');
+ }
+ const inputs = $(selector);
+
+ inputs.each((i, el) => {
+ const $input = $(el);
+
+ const calendar = new Pikaday({
+ field: $input.get(0),
+ theme: 'gitlab-theme animate-picker',
+ format: 'yyyy-mm-dd',
+ minDate: new Date(),
+ container: $input.parent().get(0),
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
+ onSelect(dateText) {
+ $input.val(calendar.toString(dateText));
+
+ $input.trigger('change');
+
+ toggleClearInput.call($input);
+ },
});
- inputs.next('.js-clear-input').on('click', function clicked(event) {
- event.preventDefault();
+ calendar.setDate(parsePikadayDate($input.val()));
+ $input.data('pikaday', calendar);
+ });
- const input = $(this).closest('.clearable-input').find(selector);
- const calendar = input.data('pikaday');
+ inputs.next('.js-clear-input').on('click', function clicked(event) {
+ event.preventDefault();
- calendar.setDate(null);
- input.trigger('change');
- toggleClearInput.call(input);
- });
+ const input = $(this).closest('.clearable-input').find(selector);
+ const calendar = input.data('pikaday');
+
+ calendar.setDate(null);
+ input.trigger('change');
+ toggleClearInput.call(input);
+ });
- inputs.on('blur', toggleClearInput);
+ inputs.on('blur', toggleClearInput);
- inputs.each(toggleClearInput);
- };
-}).call(window);
+ inputs.each(toggleClearInput);
+}
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index 8291b8c4a70..7d0c701fd70 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,81 +1,64 @@
-/* eslint-disable class-methods-use-this */
-(() => {
- window.gl = window.gl || {};
+import $ from 'jquery';
- class Members {
- constructor() {
- this.addListeners();
- this.initGLDropdown();
- }
+export default class Members {
+ constructor() {
+ this.addListeners();
+ this.initGLDropdown();
+ }
- addListeners() {
- $('.project_member, .group_member').off('ajax:success').on('ajax:success', this.removeRow);
- $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this));
- $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this));
- gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
- }
+ addListeners() {
+ $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this));
+ $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this));
+ gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
+ }
- initGLDropdown() {
- $('.js-member-permissions-dropdown').each((i, btn) => {
- const $btn = $(btn);
+ initGLDropdown() {
+ $('.js-member-permissions-dropdown').each((i, btn) => {
+ const $btn = $(btn);
- $btn.glDropdown({
- selectable: true,
- isSelectable(selected, $el) {
- return !$el.hasClass('is-active');
- },
- fieldName: $btn.data('field-name'),
- id(selected, $el) {
- return $el.data('id');
- },
- toggleLabel(selected, $el) {
- return $el.text();
- },
- clicked: (options) => {
- this.formSubmit(null, options.$el);
- },
- });
+ $btn.glDropdown({
+ selectable: true,
+ isSelectable(selected, $el) {
+ return !$el.hasClass('is-active');
+ },
+ fieldName: $btn.data('fieldName'),
+ id(selected, $el) {
+ return $el.data('id');
+ },
+ toggleLabel(selected, $el) {
+ return $el.text();
+ },
+ clicked: (options) => {
+ this.formSubmit(null, options.$el);
+ },
});
- }
-
- removeRow(e) {
- const $target = $(e.target);
-
- if ($target.hasClass('btn-remove')) {
- $target.closest('.member')
- .fadeOut(function fadeOutMemberRow() {
- $(this).remove();
- });
- }
- }
-
- formSubmit(e, $el = null) {
- const $this = e ? $(e.currentTarget) : $el;
- const { $toggle, $dateInput } = this.getMemberListItems($this);
-
- $this.closest('form').trigger('submit.rails');
+ });
+ }
- $toggle.disable();
- $dateInput.disable();
- }
+ formSubmit(e, $el = null) {
+ const $this = e ? $(e.currentTarget) : $el;
+ const { $toggle, $dateInput } = this.getMemberListItems($this);
- formSuccess(e) {
- const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member'));
+ $this.closest('form').trigger('submit.rails');
- $toggle.enable();
- $dateInput.enable();
- }
+ $toggle.disable();
+ $dateInput.disable();
+ }
- getMemberListItems($el) {
- const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('el-id')}`);
+ formSuccess(e) {
+ const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member'));
- return {
- $memberListItem,
- $toggle: $memberListItem.find('.dropdown-menu-toggle'),
- $dateInput: $memberListItem.find('.js-access-expiration-date'),
- };
- }
+ $toggle.enable();
+ $dateInput.enable();
}
+ // eslint-disable-next-line class-methods-use-this
+ getMemberListItems($el) {
+ const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
- gl.Members = Members;
-})();
+ return {
+ $memberListItem,
+ $toggle: $memberListItem.find('.dropdown-menu-toggle'),
+ $dateInput: $memberListItem.find('.js-access-expiration-date'),
+ };
+ }
+}
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
index 645045fea88..2cb238529aa 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
@@ -1,8 +1,10 @@
/* eslint-disable comma-dangle, quote-props, no-useless-computed-key, object-shorthand, no-new, no-param-reassign, max-len */
/* global ace */
-/* global Flash */
import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
@@ -49,27 +51,26 @@ import Vue from 'vue';
loadEditor() {
this.loading = true;
- $.get(this.file.content_path)
- .done((file) => {
+ axios.get(this.file.content_path)
+ .then(({ data }) => {
const content = this.$el.querySelector('pre');
- const fileContent = document.createTextNode(file.content);
+ const fileContent = document.createTextNode(data.content);
content.textContent = fileContent.textContent;
- this.originalContent = file.content;
+ this.originalContent = data.content;
this.fileLoaded = true;
this.editor = ace.edit(content);
this.editor.$blockScrolling = Infinity; // Turn off annoying warning
- this.editor.getSession().setMode(`ace/mode/${file.blob_ace_mode}`);
+ this.editor.getSession().setMode(`ace/mode/${data.blob_ace_mode}`);
this.editor.on('change', () => {
this.saveDiffResolution();
});
this.saveDiffResolution();
+ this.loading = false;
})
- .fail(() => {
- new Flash('Failed to load the file, please try again.');
- })
- .always(() => {
+ .catch(() => {
+ flash(__('An error occurred while loading the file'));
this.loading = false;
});
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
index c012b77e0bf..c68b47c9348 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
@@ -1,4 +1,5 @@
/* eslint-disable no-param-reassign, comma-dangle */
+import axios from '../lib/utils/axios_utils';
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
@@ -10,20 +11,11 @@
}
fetchConflictsData() {
- return $.ajax({
- dataType: 'json',
- url: this.conflictsPath
- });
+ return axios.get(this.conflictsPath);
}
submitResolveConflicts(data) {
- return $.ajax({
- url: this.resolveConflictsPath,
- data: JSON.stringify(data),
- contentType: 'application/json',
- dataType: 'json',
- method: 'POST'
- });
+ return axios.post(this.resolveConflictsPath, data);
}
}
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index c4e379a4a0b..db1d09eb2f2 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -1,5 +1,6 @@
/* eslint-disable comma-dangle, object-shorthand, no-param-reassign, camelcase, no-nested-ternary, no-continue, max-len */
+import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
@@ -175,7 +176,7 @@ import Cookies from 'js-cookie';
getConflictsCountText() {
const count = this.getConflictsCount();
- const text = count ? 'conflicts' : 'conflict';
+ const text = count > 1 ? 'conflicts' : 'conflict';
return `${count} ${text}`;
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 17030c3e4d3..4abd5433bb5 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,7 +1,9 @@
/* eslint-disable new-cap, comma-dangle, no-new */
-/* global Flash */
+import $ from 'jquery';
import Vue from 'vue';
+import Flash from '../flash';
+import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store';
import './merge_conflict_service';
import './mixins/line_conflict_utils';
@@ -9,8 +11,9 @@ import './mixins/line_conflict_actions';
import './components/diff_file_editor';
import './components/inline_conflict_lines';
import './components/parallel_conflict_lines';
+import syntaxHighlight from '../syntax_highlight';
-$(() => {
+export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
@@ -19,14 +22,16 @@ $(() => {
resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath
});
+ initIssuableSidebar();
+
gl.MergeConflictsResolverApp = new Vue({
el: '#conflicts',
- data: mergeConflictsStore.state,
components: {
'diff-file-editor': gl.mergeConflicts.diffFileEditor,
'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines,
'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines
},
+ data: mergeConflictsStore.state,
computed: {
conflictsCountText() { return mergeConflictsStore.getConflictsCountText(); },
readyToCommit() { return mergeConflictsStore.isReadyToCommit(); },
@@ -34,24 +39,23 @@ $(() => {
showDiffViewTypeSwitcher() { return mergeConflictsStore.fileTextTypePresent(); }
},
created() {
- mergeConflictsService
- .fetchConflictsData()
- .done((data) => {
+ mergeConflictsService.fetchConflictsData()
+ .then(({ data }) => {
if (data.type === 'error') {
mergeConflictsStore.setFailedRequest(data.message);
} else {
mergeConflictsStore.setConflictsData(data);
}
- })
- .error(() => {
- mergeConflictsStore.setFailedRequest();
- })
- .always(() => {
+
mergeConflictsStore.setLoadingState(false);
this.$nextTick(() => {
- $('.js-syntax-highlight').syntaxHighlight();
+ syntaxHighlight($('.js-syntax-highlight'));
});
+ })
+ .catch(() => {
+ mergeConflictsStore.setLoadingState(false);
+ mergeConflictsStore.setFailedRequest();
});
},
methods: {
@@ -78,14 +82,14 @@ $(() => {
mergeConflictsService
.submitResolveConflicts(mergeConflictsStore.getCommitData())
- .done((data) => {
+ .then(({ data }) => {
window.location.href = data.redirect_to;
})
- .error(() => {
+ .catch(() => {
mergeConflictsStore.setSubmitState(false);
new Flash('Failed to save merge conflicts resolutions. Please try again!');
});
}
}
});
-});
+}
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index f93feeec1c2..d8222ebec63 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,124 +1,144 @@
/* 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 */
-
-import 'vendor/jquery.waitforimages';
-import './task_list';
-import './merge_request_tabs';
-
-(function() {
- this.MergeRequest = (function() {
- function MergeRequest(opts) {
- // Initialize MergeRequest behavior
- //
- // Options:
- // action - String, current controller action
- //
- this.opts = opts != null ? opts : {};
- this.submitNoteForm = this.submitNoteForm.bind(this);
- this.$el = $('.merge-request');
- this.$('.show-all-commits').on('click', (function(_this) {
- return function() {
- return _this.showAllCommits();
- };
- })(this));
- this.initTabs();
- this.initMRBtnListeners();
- this.initCommitMessageListeners();
- if ($("a.btn-close").length) {
- this.taskList = new gl.TaskList({
- dataType: 'merge_request',
- fieldName: 'description',
- selector: '.detail-page-description',
- onSuccess: (result) => {
- document.querySelector('#task_status').innerText = result.task_status;
- document.querySelector('#task_status_short').innerText = result.task_status_short;
- }
- });
- }
- }
- // Local jQuery finder
- MergeRequest.prototype.$ = function(selector) {
- return this.$el.find(selector);
+import $ from 'jquery';
+import { __ } from '~/locale';
+import TaskList from './task_list';
+import MergeRequestTabs from './merge_request_tabs';
+import IssuablesHelper from './helpers/issuables_helper';
+import { addDelimiter } from './lib/utils/text_utility';
+
+function MergeRequest(opts) {
+ // Initialize MergeRequest behavior
+ //
+ // Options:
+ // action - String, current controller action
+ //
+ this.opts = opts != null ? opts : {};
+ this.submitNoteForm = this.submitNoteForm.bind(this);
+ this.$el = $('.merge-request');
+ this.$('.show-all-commits').on('click', (function(_this) {
+ return function() {
+ return _this.showAllCommits();
};
+ })(this));
- MergeRequest.prototype.initTabs = function() {
- if (window.mrTabs) {
- window.mrTabs.unbindEvents();
+ this.initTabs();
+ this.initMRBtnListeners();
+ this.initCommitMessageListeners();
+ this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
+
+ if ($("a.btn-close").length) {
+ this.taskList = new TaskList({
+ dataType: 'merge_request',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ onSuccess: (result) => {
+ document.querySelector('#task_status').innerText = result.task_status;
+ document.querySelector('#task_status_short').innerText = result.task_status_short;
}
- window.mrTabs = new gl.MergeRequestTabs(this.opts);
- };
+ });
+ }
+}
- MergeRequest.prototype.showAllCommits = function() {
- this.$('.first-commits').remove();
- return this.$('.all-commits').removeClass('hide');
- };
+// Local jQuery finder
+MergeRequest.prototype.$ = function(selector) {
+ return this.$el.find(selector);
+};
- MergeRequest.prototype.initMRBtnListeners = function() {
- var _this;
- _this = this;
- return $('a.btn-close, a.btn-reopen').on('click', function(e) {
- var $this, shouldSubmit;
- $this = $(this);
- shouldSubmit = $this.hasClass('btn-comment');
- if (shouldSubmit && $this.data('submitted')) {
- return;
- }
- if (shouldSubmit) {
- if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
- e.preventDefault();
- e.stopImmediatePropagation();
- return _this.submitNoteForm($this.closest('form'), $this);
- }
- }
- });
- };
+MergeRequest.prototype.initTabs = function() {
+ if (window.mrTabs) {
+ window.mrTabs.unbindEvents();
+ }
+ window.mrTabs = new MergeRequestTabs(this.opts);
+};
- MergeRequest.prototype.submitNoteForm = function(form, $button) {
- var noteText;
- noteText = form.find("textarea.js-note-text").val();
- if (noteText.trim().length > 0) {
- form.submit();
- $button.data('submitted', true);
- return $button.trigger('click');
- }
- };
+MergeRequest.prototype.showAllCommits = function() {
+ this.$('.first-commits').remove();
+ return this.$('.all-commits').removeClass('hide');
+};
- MergeRequest.prototype.initCommitMessageListeners = function() {
- $(document).on('click', 'a.js-with-description-link', function(e) {
- var textarea = $('textarea.js-commit-message');
- e.preventDefault();
+MergeRequest.prototype.initMRBtnListeners = function() {
+ var _this;
+ _this = this;
+ return $('a.btn-close, a.btn-reopen').on('click', function(e) {
+ var $this, shouldSubmit;
+ $this = $(this);
+ shouldSubmit = $this.hasClass('btn-comment');
+ if (shouldSubmit && $this.data('submitted')) {
+ return;
+ }
- textarea.val(textarea.data('messageWithDescription'));
- $('.js-with-description-hint').hide();
- $('.js-without-description-hint').show();
- });
+ if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable();
- $(document).on('click', 'a.js-without-description-link', function(e) {
- var textarea = $('textarea.js-commit-message');
+ if (shouldSubmit) {
+ if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
e.preventDefault();
+ e.stopImmediatePropagation();
- textarea.val(textarea.data('messageWithoutDescription'));
- $('.js-with-description-hint').show();
- $('.js-without-description-hint').hide();
- });
- };
+ _this.submitNoteForm($this.closest('form'), $this);
+ }
+ }
+ });
+};
- MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) {
- $('.detail-page-header .status-box')
- .removeClass(classToRemove)
- .addClass(classToAdd)
- .find('span')
- .text(newStatusText);
- };
+MergeRequest.prototype.submitNoteForm = function(form, $button) {
+ var noteText;
+ noteText = form.find("textarea.js-note-text").val();
+ if (noteText.trim().length > 0) {
+ form.submit();
+ $button.data('submitted', true);
+ return $button.trigger('click');
+ }
+};
- MergeRequest.prototype.decreaseCounter = function(by = 1) {
- const $el = $('.nav-links .js-merge-counter');
- const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
+MergeRequest.prototype.initCommitMessageListeners = function() {
+ $(document).on('click', 'a.js-with-description-link', function(e) {
+ var textarea = $('textarea.js-commit-message');
+ e.preventDefault();
- $el.text(gl.text.addDelimiter(count));
- };
+ textarea.val(textarea.data('messageWithDescription'));
+ $('.js-with-description-hint').hide();
+ $('.js-without-description-hint').show();
+ });
+
+ $(document).on('click', 'a.js-without-description-link', function(e) {
+ var textarea = $('textarea.js-commit-message');
+ e.preventDefault();
+
+ textarea.val(textarea.data('messageWithoutDescription'));
+ $('.js-with-description-hint').show();
+ $('.js-without-description-hint').hide();
+ });
+};
+
+MergeRequest.setStatusBoxToMerged = function() {
+ $('.detail-page-header .status-box')
+ .removeClass('status-box-open')
+ .addClass('status-box-mr-merged')
+ .find('span')
+ .text(__('Merged'));
+};
+
+MergeRequest.decreaseCounter = function(by = 1) {
+ const $el = $('.js-merge-counter');
+ const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
+
+ $el.text(addDelimiter(count));
+};
+
+MergeRequest.hideCloseButton = function() {
+ const el = document.querySelector('.merge-request .js-issuable-actions');
+ const closeDropdownItem = el.querySelector('li.close-item');
+ if (closeDropdownItem) {
+ closeDropdownItem.classList.add('hidden');
+ // Selects the next dropdown item
+ el.querySelector('li.report-item').click();
+ } else {
+ // No dropdown just hide the Close button
+ el.querySelector('.btn-close').classList.add('hidden');
+ }
+ // Dropdown for mobile screen
+ el.querySelector('li.js-close-item').classList.add('hidden');
+};
- return MergeRequest;
- })();
-}).call(window);
+export default MergeRequest;
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 786b6014dc6..e77318fef46 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,12 +1,23 @@
/* eslint-disable no-new, class-methods-use-this */
-/* global Breakpoints */
-/* global Flash */
-/* global notes */
+import $ from 'jquery';
import Cookies from 'js-cookie';
-import './breakpoints';
-import './flash';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
+import initChangesDropdown from './init_changes_dropdown';
+import bp from './breakpoints';
+import {
+ parseUrlPathname,
+ handleLocationHash,
+ isMetaClick,
+} from './lib/utils/common_utils';
+import { getLocationHash } from './lib/utils/url_utility';
+import initDiscussionTab from './image_diff/init_discussion_tab';
+import Diff from './diff';
+import { localTimeAgo } from './lib/utils/datetime_utility';
+import syntaxHighlight from './syntax_highlight';
+import Notes from './notes';
/* eslint-disable max-len */
// MergeRequestTabs
@@ -54,369 +65,393 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
//
/* eslint-enable max-len */
-(() => {
- // Store the `location` object, allowing for easier stubbing in tests
- let location = window.location;
+// Store the `location` object, allowing for easier stubbing in tests
+let location = window.location;
- class MergeRequestTabs {
+export default class MergeRequestTabs {
- constructor({ action, setUrl, stubLocation } = {}) {
- this.diffsLoaded = false;
- this.pipelinesLoaded = false;
- this.commitsLoaded = false;
- this.fixedLayoutPref = null;
+ constructor({ action, setUrl, stubLocation } = {}) {
+ const mergeRequestTabs = document.querySelector('.js-tabs-affix');
+ const navbar = document.querySelector('.navbar-gitlab');
+ const peek = document.getElementById('peek');
+ const paddingTop = 16;
- this.setUrl = setUrl !== undefined ? setUrl : true;
- this.setCurrentAction = this.setCurrentAction.bind(this);
- this.tabShown = this.tabShown.bind(this);
- this.showTab = this.showTab.bind(this);
+ this.diffsLoaded = false;
+ this.pipelinesLoaded = false;
+ this.commitsLoaded = false;
+ this.fixedLayoutPref = null;
- if (stubLocation) {
- location = stubLocation;
- }
+ this.setUrl = setUrl !== undefined ? setUrl : true;
+ this.setCurrentAction = this.setCurrentAction.bind(this);
+ this.tabShown = this.tabShown.bind(this);
+ this.showTab = this.showTab.bind(this);
+ this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0;
- this.bindEvents();
- this.activateTab(action);
- this.initAffix();
+ if (peek) {
+ this.stickyTop += peek.offsetHeight;
}
- bindEvents() {
- $(document)
- .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
- .on('click', '.js-show-tab', this.showTab);
+ if (mergeRequestTabs) {
+ this.stickyTop += mergeRequestTabs.offsetHeight;
+ }
- $('.merge-request-tabs a[data-toggle="tab"]')
- .on('click', this.clickTab);
+ if (stubLocation) {
+ location = stubLocation;
}
- // Used in tests
- unbindEvents() {
- $(document)
- .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
- .off('click', '.js-show-tab', this.showTab);
+ this.bindEvents();
+ this.activateTab(action);
+ this.initAffix();
+ }
- $('.merge-request-tabs a[data-toggle="tab"]')
- .off('click', this.clickTab);
- }
+ bindEvents() {
+ $(document)
+ .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
+ .on('click', '.js-show-tab', this.showTab);
- destroyPipelinesView() {
- if (this.commitPipelinesTable) {
- this.commitPipelinesTable.$destroy();
- this.commitPipelinesTable = null;
+ $('.merge-request-tabs a[data-toggle="tab"]')
+ .on('click', this.clickTab);
+ }
- document.querySelector('#commit-pipeline-table-view').innerHTML = '';
- }
+ // Used in tests
+ unbindEvents() {
+ $(document)
+ .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
+ .off('click', '.js-show-tab', this.showTab);
+
+ $('.merge-request-tabs a[data-toggle="tab"]')
+ .off('click', this.clickTab);
+ }
+
+ destroyPipelinesView() {
+ if (this.commitPipelinesTable) {
+ this.commitPipelinesTable.$destroy();
+ this.commitPipelinesTable = null;
+
+ document.querySelector('#commit-pipeline-table-view').innerHTML = '';
}
+ }
- showTab(e) {
+ showTab(e) {
+ e.preventDefault();
+ this.activateTab($(e.target).data('action'));
+ }
+
+ clickTab(e) {
+ if (e.currentTarget && isMetaClick(e)) {
+ const targetLink = e.currentTarget.getAttribute('href');
+ e.stopImmediatePropagation();
e.preventDefault();
- this.activateTab($(e.target).data('action'));
+ window.open(targetLink, '_blank');
}
+ }
- clickTab(e) {
- if (e.currentTarget && gl.utils.isMetaClick(e)) {
- const targetLink = e.currentTarget.getAttribute('href');
- e.stopImmediatePropagation();
- e.preventDefault();
- window.open(targetLink, '_blank');
+ tabShown(e) {
+ const $target = $(e.target);
+ const action = $target.data('action');
+
+ if (action === 'commits') {
+ this.loadCommits($target.attr('href'));
+ this.expandView();
+ this.resetViewContainer();
+ this.destroyPipelinesView();
+ } else if (this.isDiffAction(action)) {
+ this.loadDiff($target.attr('href'));
+ if (bp.getBreakpointSize() !== 'lg') {
+ this.shrinkView();
}
- }
-
- tabShown(e) {
- const $target = $(e.target);
- const action = $target.data('action');
-
- if (action === 'commits') {
- this.loadCommits($target.attr('href'));
- this.expandView();
- this.resetViewContainer();
- this.destroyPipelinesView();
- } else if (this.isDiffAction(action)) {
- this.loadDiff($target.attr('href'));
- if (Breakpoints.get().getBreakpointSize() !== 'lg') {
- this.shrinkView();
- }
- if (this.diffViewType() === 'parallel') {
- this.expandViewContainer();
- }
- this.destroyPipelinesView();
- } else if (action === 'pipelines') {
- this.resetViewContainer();
- this.mountPipelinesView();
- } else {
- this.expandView();
- this.resetViewContainer();
- this.destroyPipelinesView();
+ if (this.diffViewType() === 'parallel') {
+ this.expandViewContainer();
}
- if (this.setUrl) {
- this.setCurrentAction(action);
+ this.destroyPipelinesView();
+ } else if (action === 'pipelines') {
+ this.resetViewContainer();
+ this.mountPipelinesView();
+ } else {
+ if (bp.getBreakpointSize() !== 'xs') {
+ this.expandView();
}
+ this.resetViewContainer();
+ this.destroyPipelinesView();
+
+ initDiscussionTab();
}
+ if (this.setUrl) {
+ this.setCurrentAction(action);
+ }
+ }
- scrollToElement(container) {
- if (location.hash) {
- const offset = 0 - (
- $('.navbar-gitlab').outerHeight() +
- $('.js-tabs-affix').outerHeight()
- );
- const $el = $(`${container} ${location.hash}:not(.match)`);
- if ($el.length) {
- $.scrollTo($el[0], { offset });
- }
+ scrollToElement(container) {
+ if (location.hash) {
+ const offset = 0 - (
+ $('.navbar-gitlab').outerHeight() +
+ $('.js-tabs-affix').outerHeight()
+ );
+ const $el = $(`${container} ${location.hash}:not(.match)`);
+ if ($el.length) {
+ $.scrollTo($el[0], { offset });
}
}
+ }
- // Activate a tab based on the current action
- activateTab(action) {
- const activate = action === 'show' ? 'notes' : action;
- // important note: the .tab('show') method triggers 'shown.bs.tab' event itself
- $(`.merge-request-tabs a[data-action='${activate}']`).tab('show');
- }
+ // Activate a tab based on the current action
+ activateTab(action) {
+ // important note: the .tab('show') method triggers 'shown.bs.tab' event itself
+ $(`.merge-request-tabs a[data-action='${action}']`).tab('show');
+ }
- // Replaces the current Merge Request-specific action in the URL with a new one
- //
- // If the action is "notes", the URL is reset to the standard
- // `MergeRequests#show` route.
- //
- // Examples:
- //
- // location.pathname # => "/namespace/project/merge_requests/1"
- // setCurrentAction('diffs')
- // location.pathname # => "/namespace/project/merge_requests/1/diffs"
- //
- // location.pathname # => "/namespace/project/merge_requests/1/diffs"
- // setCurrentAction('notes')
- // location.pathname # => "/namespace/project/merge_requests/1"
- //
- // location.pathname # => "/namespace/project/merge_requests/1/diffs"
- // setCurrentAction('commits')
- // location.pathname # => "/namespace/project/merge_requests/1/commits"
- //
- // Returns the new URL String
- setCurrentAction(action) {
- this.currentAction = action === 'show' ? 'notes' : action;
+ // Replaces the current Merge Request-specific action in the URL with a new one
+ //
+ // If the action is "notes", the URL is reset to the standard
+ // `MergeRequests#show` route.
+ //
+ // Examples:
+ //
+ // location.pathname # => "/namespace/project/merge_requests/1"
+ // setCurrentAction('diffs')
+ // location.pathname # => "/namespace/project/merge_requests/1/diffs"
+ //
+ // location.pathname # => "/namespace/project/merge_requests/1/diffs"
+ // setCurrentAction('show')
+ // location.pathname # => "/namespace/project/merge_requests/1"
+ //
+ // location.pathname # => "/namespace/project/merge_requests/1/diffs"
+ // setCurrentAction('commits')
+ // location.pathname # => "/namespace/project/merge_requests/1/commits"
+ //
+ // Returns the new URL String
+ setCurrentAction(action) {
+ this.currentAction = action;
+
+ // Remove a trailing '/commits' '/diffs' '/pipelines'
+ let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, '');
+
+ // Append the new action if we're on a tab other than 'notes'
+ if (this.currentAction !== 'show' && this.currentAction !== 'new') {
+ newState += `/${this.currentAction}`;
+ }
- // Remove a trailing '/commits' '/diffs' '/pipelines' '/new' '/new/diffs'
- let newState = location.pathname.replace(/\/(commits|diffs|pipelines|new|new\/diffs)(\.html)?\/?$/, '');
+ // Ensure parameters and hash come along for the ride
+ newState += location.search + location.hash;
- // Append the new action if we're on a tab other than 'notes'
- if (this.currentAction !== 'notes') {
- newState += `/${this.currentAction}`;
- }
+ // TODO: Consider refactoring in light of turbolinks removal.
- // Ensure parameters and hash come along for the ride
- newState += location.search + location.hash;
+ // Replace the current history state with the new one without breaking
+ // Turbolinks' history.
+ //
+ // See https://github.com/rails/turbolinks/issues/363
+ window.history.replaceState({
+ url: newState,
+ }, document.title, newState);
- // TODO: Consider refactoring in light of turbolinks removal.
+ return newState;
+ }
- // Replace the current history state with the new one without breaking
- // Turbolinks' history.
- //
- // See https://github.com/rails/turbolinks/issues/363
- window.history.replaceState({
- url: newState,
- }, document.title, newState);
+ getCurrentAction() {
+ return this.currentAction;
+ }
- return newState;
+ loadCommits(source) {
+ if (this.commitsLoaded) {
+ return;
}
- loadCommits(source) {
- if (this.commitsLoaded) {
- return;
- }
- this.ajaxGet({
- url: `${source}.json`,
- success: (data) => {
- document.querySelector('div#commits').innerHTML = data.html;
- gl.utils.localTimeAgo($('.js-timeago', 'div#commits'));
- this.commitsLoaded = true;
- this.scrollToElement('#commits');
- },
+ this.toggleLoading(true);
+
+ axios.get(`${source}.json`)
+ .then(({ data }) => {
+ document.querySelector('div#commits').innerHTML = data.html;
+ localTimeAgo($('.js-timeago', 'div#commits'));
+ this.commitsLoaded = true;
+ this.scrollToElement('#commits');
+
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ flash('An error occurred while fetching this tab.');
});
- }
+ }
- mountPipelinesView() {
- const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- const CommitPipelinesTable = gl.CommitPipelinesTable;
- this.commitPipelinesTable = new CommitPipelinesTable({
- propsData: {
- endpoint: pipelineTableViewEl.dataset.endpoint,
- helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
- },
- }).$mount();
+ mountPipelinesView() {
+ const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
+ const CommitPipelinesTable = gl.CommitPipelinesTable;
+ this.commitPipelinesTable = new CommitPipelinesTable({
+ propsData: {
+ endpoint: pipelineTableViewEl.dataset.endpoint,
+ helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
+ emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
+ errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
+ autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
+ },
+ }).$mount();
+
+ // $mount(el) replaces the el with the new rendered component. We need it in order to mount
+ // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
+ pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
+ }
- // $mount(el) replaces the el with the new rendered component. We need it in order to mount
- // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
- pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
+ loadDiff(source) {
+ if (this.diffsLoaded) {
+ document.dispatchEvent(new CustomEvent('scroll'));
+ return;
}
- loadDiff(source) {
- if (this.diffsLoaded) {
- return;
- }
+ // We extract pathname for the current Changes tab anchor href
+ // some pages like MergeRequestsController#new has query parameters on that anchor
+ const urlPathname = parseUrlPathname(source);
+
+ this.toggleLoading(true);
+
+ axios.get(`${urlPathname}.json${location.search}`)
+ .then(({ data }) => {
+ const $container = $('#diffs');
+ $container.html(data.html);
+
+ initChangesDropdown(this.stickyTop);
- // We extract pathname for the current Changes tab anchor href
- // some pages like MergeRequestsController#new has query parameters on that anchor
- const urlPathname = gl.utils.parseUrlPathname(source);
-
- this.ajaxGet({
- url: `${urlPathname}.json${location.search}`,
- success: (data) => {
- const $container = $('#diffs');
- $container.html(data.html);
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
-
- gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
- $('#diffs .js-syntax-highlight').syntaxHighlight();
-
- if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) {
- this.expandViewContainer();
- }
- this.diffsLoaded = true;
-
- new gl.Diff();
- this.scrollToElement('#diffs');
-
- $('.diff-file').each((i, el) => {
- new BlobForkSuggestion({
- openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
- forkButtons: $(el).find('.js-fork-suggestion-button'),
- cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
- suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
- actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
- })
- .init();
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
+ }
+
+ localTimeAgo($('.js-timeago', 'div#diffs'));
+ syntaxHighlight($('#diffs .js-syntax-highlight'));
+
+ if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) {
+ this.expandViewContainer();
+ }
+ this.diffsLoaded = true;
+
+ new Diff();
+ this.scrollToElement('#diffs');
+
+ $('.diff-file').each((i, el) => {
+ new BlobForkSuggestion({
+ openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
+ forkButtons: $(el).find('.js-fork-suggestion-button'),
+ cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
+ suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
+ actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
+ })
+ .init();
+ });
+
+ // Scroll any linked note into view
+ // Similar to `toggler_behavior` in the discussion tab
+ const hash = getLocationHash();
+ const anchor = hash && $container.find(`.note[id="${hash}"]`);
+ if (anchor && anchor.length > 0) {
+ const notesContent = anchor.closest('.notes_content');
+ const lineType = notesContent.hasClass('new') ? 'new' : 'old';
+ Notes.instance.toggleDiffNote({
+ target: anchor,
+ lineType,
+ forceShow: true,
});
+ anchor[0].scrollIntoView();
+ handleLocationHash();
+ // We have multiple elements on the page with `#note_xxx`
+ // (discussion and diff tabs) and `:target` only applies to the first
+ anchor.addClass('target');
+ }
- // Scroll any linked note into view
- // Similar to `toggler_behavior` in the discussion tab
- const hash = window.gl.utils.getLocationHash();
- const anchor = hash && $container.find(`.note[id="${hash}"]`);
- if (anchor && anchor.length > 0) {
- const notesContent = anchor.closest('.notes_content');
- const lineType = notesContent.hasClass('new') ? 'new' : 'old';
- notes.toggleDiffNote({
- target: anchor,
- lineType,
- forceShow: true,
- });
- anchor[0].scrollIntoView();
- window.gl.utils.handleLocationHash();
- // We have multiple elements on the page with `#note_xxx`
- // (discussion and diff tabs) and `:target` only applies to the first
- anchor.addClass('target');
- }
- },
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ flash('An error occurred while fetching this tab.');
});
- }
+ }
- // Show or hide the loading spinner
- //
- // status - Boolean, true to show, false to hide
- toggleLoading(status) {
- $('.mr-loading-status .loading').toggle(status);
- }
+ // Show or hide the loading spinner
+ //
+ // status - Boolean, true to show, false to hide
+ toggleLoading(status) {
+ $('.mr-loading-status .loading').toggle(status);
+ }
- ajaxGet(options) {
- const defaults = {
- beforeSend: () => this.toggleLoading(true),
- error: () => new Flash('An error occurred while fetching this tab.', 'alert'),
- complete: () => this.toggleLoading(false),
- dataType: 'json',
- type: 'GET',
- };
- $.ajax($.extend({}, defaults, options));
- }
+ diffViewType() {
+ return $('.inline-parallel-buttons a.active').data('viewType');
+ }
- diffViewType() {
- return $('.inline-parallel-buttons a.active').data('view-type');
- }
+ isDiffAction(action) {
+ return action === 'diffs' || action === 'new/diffs';
+ }
- isDiffAction(action) {
- return action === 'diffs' || action === 'new/diffs';
+ expandViewContainer() {
+ const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs');
+ if (this.fixedLayoutPref === null) {
+ this.fixedLayoutPref = $wrapper.hasClass('container-limited');
}
+ $wrapper.removeClass('container-limited');
+ }
- expandViewContainer() {
- const $wrapper = $('.content-wrapper .container-fluid');
- if (this.fixedLayoutPref === null) {
- this.fixedLayoutPref = $wrapper.hasClass('container-limited');
- }
- $wrapper.removeClass('container-limited');
+ resetViewContainer() {
+ if (this.fixedLayoutPref !== null) {
+ $('.content-wrapper .container-fluid')
+ .toggleClass('container-limited', this.fixedLayoutPref);
}
+ }
- resetViewContainer() {
- if (this.fixedLayoutPref !== null) {
- $('.content-wrapper .container-fluid')
- .toggleClass('container-limited', this.fixedLayoutPref);
- }
- }
+ shrinkView() {
+ const $gutterIcon = $('.js-sidebar-toggle i:visible');
- shrinkView() {
- const $gutterIcon = $('.js-sidebar-toggle i:visible');
+ // Wait until listeners are set
+ setTimeout(() => {
+ // Only when sidebar is expanded
+ if ($gutterIcon.is('.fa-angle-double-right')) {
+ $gutterIcon.closest('a').trigger('click', [true]);
+ }
+ }, 0);
+ }
- // Wait until listeners are set
- setTimeout(() => {
- // Only when sidebar is expanded
- if ($gutterIcon.is('.fa-angle-double-right')) {
- $gutterIcon.closest('a').trigger('click', [true]);
- }
- }, 0);
+ // Expand the issuable sidebar unless the user explicitly collapsed it
+ expandView() {
+ if (Cookies.get('collapsed_gutter') === 'true') {
+ return;
}
+ const $gutterIcon = $('.js-sidebar-toggle i:visible');
- // Expand the issuable sidebar unless the user explicitly collapsed it
- expandView() {
- if (Cookies.get('collapsed_gutter') === 'true') {
- return;
+ // Wait until listeners are set
+ setTimeout(() => {
+ // Only when sidebar is collapsed
+ if ($gutterIcon.is('.fa-angle-double-left')) {
+ $gutterIcon.closest('a').trigger('click', [true]);
}
- const $gutterIcon = $('.js-sidebar-toggle i:visible');
+ }, 0);
+ }
- // Wait until listeners are set
- setTimeout(() => {
- // Only when sidebar is collapsed
- if ($gutterIcon.is('.fa-angle-double-left')) {
- $gutterIcon.closest('a').trigger('click', [true]);
- }
- }, 0);
- }
+ 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 (bp.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() - $fixedNav.height()
+ ),
+ },
+ })
+ .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
+ .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' }));
- 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() - $fixedNav.height()
- ),
- },
- })
- .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
- .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' }));
-
- // Fix bug when reloading the page already scrolling
- if ($tabs.hasClass('affix')) {
- $tabs.trigger('affix.bs.affix');
- }
+ // Fix bug when reloading the page already scrolling
+ if ($tabs.hasClass('affix')) {
+ $tabs.trigger('affix.bs.affix');
}
}
-
- window.gl = window.gl || {};
- window.gl.MergeRequestTabs = MergeRequestTabs;
-})();
+}
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index 3e07ec4d0aa..e6e3a66aa20 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -1,53 +1,46 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-use-before-define, camelcase, quotes, object-shorthand, no-shadow, no-unused-vars, comma-dangle, no-var, prefer-template, no-underscore-dangle, consistent-return, one-var, one-var-declaration-per-line, default-case, prefer-arrow-callback, max-len */
-/* global Flash */
-/* global Sortable */
-
-(function() {
- this.Milestone = (function() {
- function Milestone() {
- this.bindTabsSwitching();
-
- // Load merge request tab if it is active
- // merge request tab is active based on different conditions in the backend
- this.loadTab($('.js-milestone-tabs .active a'));
-
- this.loadInitialTab();
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+
+export default class Milestone {
+ constructor() {
+ this.bindTabsSwitching();
+
+ // Load merge request tab if it is active
+ // merge request tab is active based on different conditions in the backend
+ this.loadTab($('.js-milestone-tabs .active a'));
+
+ this.loadInitialTab();
+ }
+
+ bindTabsSwitching() {
+ return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => {
+ const $target = $(e.target);
+
+ location.hash = $target.attr('href');
+ this.loadTab($target);
+ });
+ }
+ // eslint-disable-next-line class-methods-use-this
+ loadInitialTab() {
+ const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`);
+
+ if ($target.length) {
+ $target.tab('show');
}
-
- Milestone.prototype.bindTabsSwitching = function() {
- return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => {
- const $target = $(e.target);
-
- location.hash = $target.attr('href');
- this.loadTab($target);
- });
- };
-
- Milestone.prototype.loadInitialTab = function() {
- const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`);
-
- if ($target.length) {
- $target.tab('show');
- }
- };
-
- Milestone.prototype.loadTab = function($target) {
- const endpoint = $target.data('endpoint');
- const tabElId = $target.attr('href');
-
- if (endpoint && !$target.hasClass('is-loaded')) {
- $.ajax({
- url: endpoint,
- dataType: 'JSON',
- })
- .fail(() => new Flash('Error loading milestone tab'))
- .done((data) => {
+ }
+ // eslint-disable-next-line class-methods-use-this
+ loadTab($target) {
+ const endpoint = $target.data('endpoint');
+ const tabElId = $target.attr('href');
+
+ if (endpoint && !$target.hasClass('is-loaded')) {
+ axios.get(endpoint)
+ .then(({ data }) => {
$(tabElId).html(data.html);
$target.addClass('is-loaded');
- });
- }
- };
-
- return Milestone;
- })();
-}).call(window);
+ })
+ .catch(() => flash('Error loading milestone tab'));
+ }
+ }
+}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 9d481d7c003..add07c156a4 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,225 +1,230 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, one-var-declaration-per-line, no-unused-vars, object-shorthand, comma-dangle, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, one-var-declaration-per-line, no-unused-vars, object-shorthand, comma-dangle, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */
/* global Issuable */
/* global ListMilestone */
-(function() {
- this.MilestoneSelect = (function() {
- function MilestoneSelect(currentProject, els) {
- var _this, $els;
- if (currentProject != null) {
- _this = this;
- this.currentProject = JSON.parse(currentProject);
- }
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import { timeFor } from './lib/utils/datetime_utility';
- $els = $(els);
+export default class MilestoneSelect {
+ constructor(currentProject, els, options = {}) {
+ if (currentProject !== null) {
+ this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
+ }
- if (!els) {
- $els = $('.js-milestone-select');
- }
+ this.init(els, options);
+ }
- $els.each(function(i, dropdown) {
- 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');
- showNo = $dropdown.data('show-no');
- showAny = $dropdown.data('show-any');
- showMenuAbove = $dropdown.data('showMenuAbove');
- showUpcoming = $dropdown.data('show-upcoming');
- 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');
- $block = $selectbox.closest('.block');
- $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>';
- collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- remaining %>" data-placement="left"> <%- title %> </span>');
- }
- return $dropdown.glDropdown({
- showMenuAbove: showMenuAbove,
- data: function(term, callback) {
- return $.ajax({
- url: milestonesUrl
- }).done(function(data) {
- var extraOptions = [];
- if (showAny) {
- extraOptions.push({
- id: 0,
- name: '',
- title: 'Any Milestone'
- });
- }
- if (showNo) {
- extraOptions.push({
- id: -1,
- name: 'No Milestone',
- title: 'No Milestone'
- });
- }
- if (showUpcoming) {
- extraOptions.push({
- id: -2,
- name: '#upcoming',
- title: 'Upcoming'
- });
- }
- if (showStarted) {
- extraOptions.push({
- id: -3,
- name: '#started',
- title: 'Started'
- });
- }
- if (extraOptions.length) {
- extraOptions.push('divider');
- }
+ init(els, options) {
+ let $els = $(els);
- callback(extraOptions.concat(data));
- 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']
- },
- selectable: true,
- toggleLabel: function(selected, el, e) {
- if (selected && 'id' in selected && $(el).hasClass('is-active')) {
- return selected.title;
- } else {
- return defaultLabel;
+ if (!els) {
+ $els = $('.js-milestone-select');
+ }
+
+ $els.each((i, dropdown) => {
+ let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault;
+ const $dropdown = $(dropdown);
+ const projectId = $dropdown.data('projectId');
+ const milestonesUrl = $dropdown.data('milestones');
+ const issueUpdateURL = $dropdown.data('issueUpdate');
+ const showNo = $dropdown.data('showNo');
+ const showAny = $dropdown.data('showAny');
+ const showMenuAbove = $dropdown.data('showMenuAbove');
+ const showUpcoming = $dropdown.data('showUpcoming');
+ const showStarted = $dropdown.data('showStarted');
+ const useId = $dropdown.data('useId');
+ const defaultLabel = $dropdown.data('defaultLabel');
+ const defaultNo = $dropdown.data('defaultNo');
+ const issuableId = $dropdown.data('issuableId');
+ const abilityName = $dropdown.data('abilityName');
+ const $selectBox = $dropdown.closest('.selectbox');
+ const $block = $selectBox.closest('.block');
+ const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
+ const $value = $block.find('.value');
+ const $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>';
+ collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>');
+ }
+ return $dropdown.glDropdown({
+ showMenuAbove: showMenuAbove,
+ data: (term, callback) => axios.get(milestonesUrl)
+ .then(({ data }) => {
+ const extraOptions = [];
+ if (showAny) {
+ extraOptions.push({
+ id: 0,
+ name: '',
+ title: 'Any Milestone'
+ });
}
- },
- defaultLabel: defaultLabel,
- fieldName: $dropdown.data('field-name'),
- text: function(milestone) {
- return _.escape(milestone.title);
- },
- id: function(milestone) {
- if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
- return milestone.name;
- } else {
- return milestone.id;
+ if (showNo) {
+ extraOptions.push({
+ id: -1,
+ name: 'No Milestone',
+ title: 'No Milestone'
+ });
}
- },
- isSelected: function(milestone) {
- return milestone.name === selectedMilestone;
- },
- hidden: function() {
- $selectbox.hide();
- // 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;
+ if (showUpcoming) {
+ extraOptions.push({
+ id: -2,
+ name: '#upcoming',
+ title: 'Upcoming'
+ });
}
- $('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, 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;
+ if (showStarted) {
+ extraOptions.push({
+ id: -3,
+ name: '#started',
+ title: 'Started'
+ });
+ }
+ if (extraOptions.length) {
+ extraOptions.push('divider');
}
- if ($dropdown.closest('.add-issues-modal').length) {
- boardsStore = gl.issueBoards.ModalStore.store.filter;
+ callback(extraOptions.concat(data));
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
}
+ $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
+ }),
+ renderRow: milestone => `
+ <li data-milestone-id="${milestone.name}">
+ <a href='#' class='dropdown-menu-milestone-link'>
+ ${_.escape(milestone.title)}
+ </a>
+ </li>
+ `,
+ filterable: true,
+ search: {
+ fields: ['title']
+ },
+ selectable: true,
+ toggleLabel: (selected, el, e) => {
+ if (selected && 'id' in selected && $(el).hasClass('is-active')) {
+ return selected.title;
+ } else {
+ return defaultLabel;
+ }
+ },
+ defaultLabel: defaultLabel,
+ fieldName: $dropdown.data('fieldName'),
+ text: milestone => _.escape(milestone.title),
+ id: (milestone) => {
+ if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
+ return milestone.name;
+ } else {
+ return milestone.id;
+ }
+ },
+ isSelected: milestone => milestone.name === selectedMilestone,
+ hidden: () => {
+ $selectBox.hide();
+ // display:block overrides the hide-collapse rule
+ return $value.css('display', '');
+ },
+ opened: (e) => {
+ const $el = $(e.currentTarget);
+ if ($dropdown.hasClass('js-issue-board-sidebar') || options.handleClick) {
+ 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: (clickEvent) => {
+ const { $el, e } = clickEvent;
+ let selected = clickEvent.selectedObj;
- if (boardsStore) {
- boardsStore[$dropdown.data('field-name')] = selected.name;
- e.preventDefault();
- } 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-issue-board-sidebar')) {
- if (selected.id !== -1 && isSelecting) {
- gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
- id: selected.id,
- title: selected.name
- }));
- } else {
- gl.issueBoards.boardStoreIssueDelete('milestone');
- }
+ let data, boardsStore;
+ if (!selected) return;
- $dropdown.trigger('loading.gl.dropdown');
- $loading.removeClass('hidden').fadeIn();
+ if (options.handleClick) {
+ e.preventDefault();
+ options.handleClick(selected);
+ return;
+ }
+
+ const page = $('body').attr('data-page');
+ const isIssueIndex = page === 'projects:issues:index';
+ const isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ const 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;
+ }
+
+ if ($dropdown.closest('.add-issues-modal').length) {
+ boardsStore = gl.issueBoards.ModalStore.store.filter;
+ }
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(function () {
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.fadeOut();
- })
- .catch(() => {
- $loading.fadeOut();
- });
+ if (boardsStore) {
+ boardsStore[$dropdown.data('fieldName')] = selected.name;
+ e.preventDefault();
+ } 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-issue-board-sidebar')) {
+ if (selected.id !== -1 && isSelecting) {
+ gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
+ id: selected.id,
+ title: selected.name
+ }));
} else {
- selected = $selectbox.find('input[type="hidden"]').val();
- data = {};
- data[abilityName] = {};
- data[abilityName].milestone_id = selected != null ? selected : null;
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
- type: 'PUT',
- url: issueUpdateURL,
- data: data
- }).done(function(data) {
+ gl.issueBoards.boardStoreIssueDelete('milestone');
+ }
+
+ $dropdown.trigger('loading.gl.dropdown');
+ $loading.removeClass('hidden').fadeIn();
+
+ gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
+ .then(() => {
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
- $selectbox.hide();
+ })
+ .catch(() => {
+ $loading.fadeOut();
+ });
+ } else {
+ selected = $selectBox.find('input[type="hidden"]').val();
+ data = {};
+ data[abilityName] = {};
+ data[abilityName].milestone_id = selected != null ? selected : null;
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+ return axios.put(issueUpdateURL, data)
+ .then(({ data }) => {
+ $dropdown.trigger('loaded.gl.dropdown');
+ $loading.fadeOut();
+ $selectBox.hide();
$value.css('display', '');
if (data.milestone != null) {
- data.milestone.full_path = _this.currentProject.full_path;
- data.milestone.remaining = gl.utils.timeFor(data.milestone.due_date);
+ data.milestone.full_path = this.currentProject.full_path;
+ data.milestone.remaining = timeFor(data.milestone.due_date);
+ data.milestone.name = data.milestone.title;
$value.html(milestoneLinkTemplate(data.milestone));
return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone));
} else {
$value.html(milestoneLinkNoneTemplate);
return $sidebarCollapsedValue.find('span').text('No');
}
+ })
+ .catch(() => {
+ $loading.fadeOut();
});
- }
}
- });
+ }
});
- }
-
- return MilestoneSelect;
- })();
-}).call(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
index 64c1447f427..01399de4c62 100644
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
@@ -1,5 +1,8 @@
/* eslint-disable no-new */
-/* global Flash */
+
+import $ from 'jquery';
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
/**
* In each pipelines table we have a mini pipeline graph for each pipeline.
@@ -78,27 +81,22 @@ export default class MiniPipelineGraph {
const button = e.relatedTarget;
const endpoint = button.dataset.stageEndpoint;
- return $.ajax({
- dataType: 'json',
- type: 'GET',
- url: endpoint,
- beforeSend: () => {
- this.renderBuildsList(button, '');
- this.toggleLoading(button);
- },
- success: (data) => {
+ this.renderBuildsList(button, '');
+ this.toggleLoading(button);
+
+ axios.get(endpoint)
+ .then(({ data }) => {
this.toggleLoading(button);
this.renderBuildsList(button, data.html);
this.stopDropdownClickPropagation();
- },
- error: () => {
+ })
+ .catch(() => {
this.toggleLoading(button);
if ($(button).parent().hasClass('open')) {
$(button).dropdown('toggle');
}
- new Flash('An error occurred while fetching the builds.', 'alert');
- },
- });
+ flash('An error occurred while fetching the builds.', 'alert');
+ });
}
/**
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
new file mode 100644
index 00000000000..10b3a4d2fee
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -0,0 +1,198 @@
+<script>
+ import _ from 'underscore';
+ import Flash from '../../flash';
+ import MonitoringService from '../services/monitoring_service';
+ import GraphGroup from './graph_group.vue';
+ import Graph from './graph.vue';
+ import EmptyState from './empty_state.vue';
+ import MonitoringStore from '../stores/monitoring_store';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ Graph,
+ GraphGroup,
+ EmptyState,
+ },
+
+ props: {
+ hasMetrics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showLegend: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showPanels: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ forceSmallGraph: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ documentationPath: {
+ type: String,
+ required: true,
+ },
+ settingsPath: {
+ type: String,
+ required: true,
+ },
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ tagsPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ metricsEndpoint: {
+ type: String,
+ required: true,
+ },
+ deploymentEndpoint: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ emptyGettingStartedSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyLoadingSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyNoDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyUnableToConnectSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ store: new MonitoringStore(),
+ state: 'gettingStarted',
+ showEmptyState: true,
+ updateAspectRatio: false,
+ updatedAspectRatios: 0,
+ hoverData: {},
+ resizeThrottled: {},
+ };
+ },
+
+ created() {
+ this.service = new MonitoringService({
+ metricsEndpoint: this.metricsEndpoint,
+ deploymentEndpoint: this.deploymentEndpoint,
+ });
+ eventHub.$on('toggleAspectRatio', this.toggleAspectRatio);
+ eventHub.$on('hoverChanged', this.hoverChanged);
+ },
+
+ beforeDestroy() {
+ eventHub.$off('toggleAspectRatio', this.toggleAspectRatio);
+ eventHub.$off('hoverChanged', this.hoverChanged);
+ window.removeEventListener('resize', this.resizeThrottled, false);
+ },
+
+ mounted() {
+ this.resizeThrottled = _.throttle(this.resize, 600);
+ if (!this.hasMetrics) {
+ this.state = 'gettingStarted';
+ } else {
+ this.getGraphsData();
+ window.addEventListener('resize', this.resizeThrottled, false);
+ }
+ },
+
+ methods: {
+ getGraphsData() {
+ this.state = 'loading';
+ Promise.all([
+ this.service.getGraphsData()
+ .then(data => this.store.storeMetrics(data)),
+ this.service.getDeploymentData()
+ .then(data => this.store.storeDeploymentData(data))
+ .catch(() => new Flash('Error getting deployment information.')),
+ ])
+ .then(() => {
+ if (this.store.groups.length < 1) {
+ this.state = 'noData';
+ return;
+ }
+ this.showEmptyState = false;
+ })
+ .catch(() => { this.state = 'unableToConnect'; });
+ },
+
+ resize() {
+ this.updateAspectRatio = true;
+ },
+
+ toggleAspectRatio() {
+ this.updatedAspectRatios = this.updatedAspectRatios += 1;
+ if (this.store.getMetricsCount() === this.updatedAspectRatios) {
+ this.updateAspectRatio = !this.updateAspectRatio;
+ this.updatedAspectRatios = 0;
+ }
+ },
+
+ hoverChanged(data) {
+ this.hoverData = data;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ v-if="!showEmptyState"
+ class="prometheus-graphs"
+ >
+ <graph-group
+ v-for="(groupData, index) in store.groups"
+ :key="index"
+ :name="groupData.group"
+ :show-panels="showPanels"
+ >
+ <graph
+ v-for="(graphData, index) in groupData.metrics"
+ :key="index"
+ :graph-data="graphData"
+ :hover-data="hoverData"
+ :update-aspect-ratio="updateAspectRatio"
+ :deployment-data="store.deploymentData"
+ :project-path="projectPath"
+ :tags-path="tagsPath"
+ :show-legend="showLegend"
+ :small-graph="forceSmallGraph"
+ />
+ </graph-group>
+ </div>
+ <empty-state
+ v-else
+ :selected-state="state"
+ :documentation-path="documentationPath"
+ :settings-path="settingsPath"
+ :clusters-path="clustersPath"
+ :empty-getting-started-svg-path="emptyGettingStartedSvgPath"
+ :empty-loading-svg-path="emptyLoadingSvgPath"
+ :empty-no-data-svg-path="emptyNoDataSvgPath"
+ :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
+ />
+</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
new file mode 100644
index 00000000000..fbf451fce68
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -0,0 +1,127 @@
+<script>
+ export default {
+ props: {
+ documentationPath: {
+ type: String,
+ required: true,
+ },
+ settingsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ clustersPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ selectedState: {
+ type: String,
+ required: true,
+ },
+ emptyGettingStartedSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyLoadingSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyNoDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyUnableToConnectSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ states: {
+ gettingStarted: {
+ svgUrl: this.emptyGettingStartedSvgPath,
+ title: 'Get started with performance monitoring',
+ description: `Stay updated about the performance and health
+ of your environment by configuring Prometheus to monitor your deployments.`,
+ buttonText: 'Install Prometheus on clusters',
+ buttonPath: this.clustersPath,
+ secondaryButtonText: 'Configure existing Prometheus',
+ secondaryButtonPath: this.settingsPath,
+ },
+ loading: {
+ svgUrl: this.emptyLoadingSvgPath,
+ title: 'Waiting for performance data',
+ description: `Creating graphs uses the data from the Prometheus server.
+ If this takes a long time, ensure that data is available.`,
+ buttonText: 'View documentation',
+ buttonPath: this.documentationPath,
+ },
+ noData: {
+ svgUrl: this.emptyNoDataSvgPath,
+ title: 'No data found',
+ description: `You are connected to the Prometheus server, but there is currently
+ no data to display.`,
+ buttonText: 'Configure Prometheus',
+ buttonPath: this.settingsPath,
+ },
+ unableToConnect: {
+ svgUrl: this.emptyUnableToConnectSvgPath,
+ title: 'Unable to connect to Prometheus server',
+ description: 'Ensure connectivity is available from the GitLab server to the ',
+ buttonText: 'View documentation',
+ buttonPath: this.documentationPath,
+ },
+ },
+ };
+ },
+ computed: {
+ currentState() {
+ return this.states[this.selectedState];
+ },
+
+ showButtonDescription() {
+ if (this.selectedState === 'unableToConnect') return true;
+ return false;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="prometheus-state">
+ <div class="state-svg svg-content">
+ <img :src="currentState.svgUrl" />
+ </div>
+ <h4 class="state-title">
+ {{ currentState.title }}
+ </h4>
+ <p class="state-description">
+ {{ currentState.description }}
+ <a
+ v-if="showButtonDescription"
+ :href="settingsPath"
+ >
+ Prometheus server
+ </a>
+ </p>
+ <div class="state-button">
+ <a
+ v-if="currentState.buttonPath"
+ class="btn btn-success"
+ :href="currentState.buttonPath"
+ >
+ {{ currentState.buttonText }}
+ </a>
+ </div>
+ <div class="state-button">
+ <a
+ v-if="currentState.secondaryButtonPath"
+ class="btn"
+ :href="currentState.secondaryButtonPath"
+ >
+ {{ currentState.secondaryButtonText }}
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
new file mode 100644
index 00000000000..42615d2bb8e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph.vue
@@ -0,0 +1,317 @@
+<script>
+ import { scaleLinear, scaleTime } from 'd3-scale';
+ import { axisLeft, axisBottom } from 'd3-axis';
+ import { max, extent } from 'd3-array';
+ import { select } from 'd3-selection';
+ import GraphLegend from './graph/legend.vue';
+ import GraphFlag from './graph/flag.vue';
+ import GraphDeployment from './graph/deployment.vue';
+ import GraphPath from './graph/path.vue';
+ import MonitoringMixin from '../mixins/monitoring_mixins';
+ import eventHub from '../event_hub';
+ import measurements from '../utils/measurements';
+ import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
+ import createTimeSeries from '../utils/multiple_time_series';
+ import bp from '../../breakpoints';
+
+ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
+
+ export default {
+ components: {
+ GraphLegend,
+ GraphFlag,
+ GraphDeployment,
+ GraphPath,
+ },
+
+ mixins: [MonitoringMixin],
+
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ },
+ updateAspectRatio: {
+ type: Boolean,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: true,
+ },
+ hoverData: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ tagsPath: {
+ type: String,
+ required: true,
+ },
+ showLegend: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ smallGraph: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ baseGraphHeight: 450,
+ baseGraphWidth: 600,
+ graphHeight: 450,
+ graphWidth: 600,
+ graphHeightOffset: 120,
+ margin: {},
+ unitOfDisplay: '',
+ yAxisLabel: '',
+ legendTitle: '',
+ reducedDeploymentData: [],
+ measurements: measurements.large,
+ currentData: {
+ time: new Date(),
+ value: 0,
+ },
+ currentDataIndex: 0,
+ currentXCoordinate: 0,
+ currentFlagPosition: 0,
+ showFlag: false,
+ showFlagContent: false,
+ timeSeries: [],
+ realPixelRatio: 1,
+ };
+ },
+
+ computed: {
+ outerViewBox() {
+ return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
+ },
+
+ innerViewBox() {
+ return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
+ },
+
+ axisTransform() {
+ return `translate(70, ${this.graphHeight - 100})`;
+ },
+
+ paddingBottomRootSvg() {
+ return {
+ paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`,
+ };
+ },
+
+ deploymentFlagData() {
+ return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
+ },
+ },
+
+ watch: {
+ updateAspectRatio() {
+ if (this.updateAspectRatio) {
+ this.graphHeight = 450;
+ this.graphWidth = 600;
+ this.measurements = measurements.large;
+ this.draw();
+ eventHub.$emit('toggleAspectRatio');
+ }
+ },
+
+ hoverData() {
+ this.positionFlag();
+ },
+ },
+
+ mounted() {
+ this.draw();
+ },
+
+ methods: {
+ draw() {
+ const breakpointSize = bp.getBreakpointSize();
+ const query = this.graphData.queries[0];
+ this.margin = measurements.large.margin;
+ if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
+ this.graphHeight = 300;
+ this.margin = measurements.small.margin;
+ this.measurements = measurements.small;
+ }
+ this.unitOfDisplay = query.unit || '';
+ this.yAxisLabel = this.graphData.y_label || 'Values';
+ this.legendTitle = query.label || 'Average';
+ this.graphWidth = this.$refs.baseSvg.clientWidth -
+ this.margin.left - this.margin.right;
+ this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
+ this.baseGraphHeight = this.graphHeight;
+ this.baseGraphWidth = this.graphWidth;
+
+ // pixel offsets inside the svg and outside are not 1:1
+ this.realPixelRatio = (this.$refs.baseSvg.clientWidth / this.baseGraphWidth);
+
+ this.renderAxesPaths();
+ this.formatDeployments();
+ },
+
+ handleMouseOverGraph(e) {
+ let point = this.$refs.graphData.createSVGPoint();
+ point.x = e.clientX;
+ point.y = e.clientY;
+ point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
+ point.x = point.x += 7;
+ const firstTimeSeries = this.timeSeries[0];
+ const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
+ const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
+ const d0 = firstTimeSeries.values[overlayIndex - 1];
+ const d1 = firstTimeSeries.values[overlayIndex];
+ if (d0 === undefined || d1 === undefined) return;
+ const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
+ const hoveredDataIndex = evalTime ? overlayIndex : (overlayIndex - 1);
+ const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
+ const currentDeployXPos = this.mouseOverDeployInfo(point.x);
+
+ eventHub.$emit('hoverChanged', {
+ hoveredDate,
+ currentDeployXPos,
+ });
+ },
+
+ renderAxesPaths() {
+ this.timeSeries = createTimeSeries(
+ this.graphData.queries,
+ this.graphWidth,
+ this.graphHeight,
+ this.graphHeightOffset,
+ );
+
+ if (!this.showLegend) {
+ this.baseGraphHeight -= 50;
+ } else if (this.timeSeries.length > 3) {
+ this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
+ }
+
+ const axisXScale = d3.scaleTime()
+ .range([0, this.graphWidth - 70]);
+ const axisYScale = d3.scaleLinear()
+ .range([this.graphHeight - this.graphHeightOffset, 0]);
+
+ const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
+ axisXScale.domain(d3.extent(allValues, d => d.time));
+ axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
+
+ const xAxis = d3.axisBottom()
+ .scale(axisXScale)
+ .ticks(this.graphWidth / 120)
+ .tickFormat(timeScaleFormat);
+
+ const yAxis = d3.axisLeft()
+ .scale(axisYScale)
+ .ticks(measurements.yTicks);
+
+ d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis);
+
+ const width = this.graphWidth;
+ d3.select(this.$refs.baseSvg).select('.y-axis').call(yAxis)
+ .selectAll('.tick')
+ .each(function createTickLines(d, i) {
+ if (i > 0) {
+ d3.select(this).select('line')
+ .attr('x2', width)
+ .attr('class', 'axis-tick');
+ } // Avoid adding the class to the first tick, to prevent coloring
+ }); // This will select all of the ticks once they're rendered
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="prometheus-graph"
+ @mouseover="showFlagContent = true"
+ @mouseleave="showFlagContent = false"
+ >
+ <h5 class="text-center graph-title">
+ {{ graphData.title }}
+ </h5>
+ <div
+ class="prometheus-svg-container"
+ :style="paddingBottomRootSvg"
+ >
+ <svg
+ :viewBox="outerViewBox"
+ ref="baseSvg"
+ >
+ <g
+ class="x-axis"
+ :transform="axisTransform"
+ />
+ <g
+ class="y-axis"
+ transform="translate(70, 20)"
+ />
+ <graph-legend
+ :graph-width="graphWidth"
+ :graph-height="graphHeight"
+ :margin="margin"
+ :measurements="measurements"
+ :legend-title="legendTitle"
+ :y-axis-label="yAxisLabel"
+ :time-series="timeSeries"
+ :unit-of-display="unitOfDisplay"
+ :current-data-index="currentDataIndex"
+ :show-legend-group="showLegend"
+ />
+ <svg
+ class="graph-data"
+ :viewBox="innerViewBox"
+ ref="graphData"
+ >
+ <graph-path
+ v-for="(path, index) in timeSeries"
+ :key="index"
+ :generated-line-path="path.linePath"
+ :generated-area-path="path.areaPath"
+ :line-style="path.lineStyle"
+ :line-color="path.lineColor"
+ :area-color="path.areaColor"
+ />
+ <graph-deployment
+ :deployment-data="reducedDeploymentData"
+ :graph-height="graphHeight"
+ :graph-height-offset="graphHeightOffset"
+ />
+ <rect
+ class="prometheus-graph-overlay"
+ :width="(graphWidth - 70)"
+ :height="(graphHeight - 100)"
+ transform="translate(-5, 20)"
+ ref="graphOverlay"
+ @mousemove="handleMouseOverGraph($event)"
+ />
+ </svg>
+ </svg>
+ <graph-flag
+ :real-pixel-ratio="realPixelRatio"
+ :current-x-coordinate="currentXCoordinate"
+ :current-data="currentData"
+ :graph-height="graphHeight"
+ :graph-height-offset="graphHeightOffset"
+ :show-flag-content="showFlagContent"
+ :time-series="timeSeries"
+ :unit-of-display="unitOfDisplay"
+ :current-data-index="currentDataIndex"
+ :legend-title="legendTitle"
+ :deployment-flag-data="deploymentFlagData"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue
new file mode 100644
index 00000000000..98c25307b74
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue
@@ -0,0 +1,75 @@
+<script>
+ export default {
+ props: {
+ deploymentData: {
+ type: Array,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ graphHeightOffset: {
+ type: Number,
+ required: true,
+ },
+ },
+
+ computed: {
+ calculatedHeight() {
+ return this.graphHeight - this.graphHeightOffset;
+ },
+ },
+
+ methods: {
+ transformDeploymentGroup(deployment) {
+ return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
+ },
+ },
+ };
+</script>
+<template>
+ <g class="deploy-info">
+ <g
+ v-for="(deployment, index) in deploymentData"
+ :key="index"
+ :transform="transformDeploymentGroup(deployment)">
+ <rect
+ x="0"
+ y="0"
+ :height="calculatedHeight"
+ width="3"
+ fill="url(#shadow-gradient)"
+ />
+ <line
+ class="deployment-line"
+ x1="0"
+ y1="0"
+ x2="0"
+ :y2="calculatedHeight"
+ stroke="#000"
+ />
+ </g>
+ <svg
+ height="0"
+ width="0"
+ >
+ <defs>
+ <linearGradient
+ id="shadow-gradient"
+ >
+ <stop
+ offset="0%"
+ stop-color="#000"
+ stop-opacity="0.4"
+ />
+ <stop
+ offset="100%"
+ stop-color="#000"
+ stop-opacity="0"
+ />
+ </linearGradient>
+ </defs>
+ </svg>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
new file mode 100644
index 00000000000..07aa6a3e5de
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/flag.vue
@@ -0,0 +1,201 @@
+<script>
+ import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
+ import { formatRelevantDigits } from '../../../lib/utils/number_utils';
+ import icon from '../../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ currentXCoordinate: {
+ type: Number,
+ required: true,
+ },
+ currentData: {
+ type: Object,
+ required: true,
+ },
+ deploymentFlagData: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ graphHeightOffset: {
+ type: Number,
+ required: true,
+ },
+ realPixelRatio: {
+ type: Number,
+ required: true,
+ },
+ showFlagContent: {
+ type: Boolean,
+ required: true,
+ },
+ timeSeries: {
+ type: Array,
+ required: true,
+ },
+ unitOfDisplay: {
+ type: String,
+ required: true,
+ },
+ currentDataIndex: {
+ type: Number,
+ required: true,
+ },
+ legendTitle: {
+ type: String,
+ required: true,
+ },
+ },
+
+ computed: {
+ formatTime() {
+ return this.deploymentFlagData ?
+ timeFormat(this.deploymentFlagData.time) :
+ timeFormat(this.currentData.time);
+ },
+
+ formatDate() {
+ return this.deploymentFlagData ?
+ dateFormat(this.deploymentFlagData.time) :
+ dateFormat(this.currentData.time);
+ },
+
+ cursorStyle() {
+ const xCoordinate = this.deploymentFlagData ?
+ this.deploymentFlagData.xPos :
+ this.currentXCoordinate;
+
+ const offsetTop = 20 * this.realPixelRatio;
+ const offsetLeft = (70 + xCoordinate) * this.realPixelRatio;
+ const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio;
+
+ return {
+ top: `${offsetTop}px`,
+ left: `${offsetLeft}px`,
+ height: `${height}px`,
+ };
+ },
+
+ flagOrientation() {
+ if (this.currentXCoordinate * this.realPixelRatio > 120) {
+ return 'left';
+ }
+ return 'right';
+ },
+ },
+
+ methods: {
+ seriesMetricValue(series) {
+ const index = this.deploymentFlagData ?
+ this.deploymentFlagData.seriesIndex :
+ this.currentDataIndex;
+ const value = series.values[index] &&
+ series.values[index].value;
+ if (isNaN(value)) {
+ return '-';
+ }
+ return `${formatRelevantDigits(value)}${this.unitOfDisplay}`;
+ },
+
+ seriesMetricLabel(index, series) {
+ if (this.timeSeries.length < 2) {
+ return this.legendTitle;
+ }
+ if (series.metricTag) {
+ return series.metricTag;
+ }
+ return `series ${index + 1}`;
+ },
+
+ strokeDashArray(type) {
+ if (type === 'dashed') return '6, 3';
+ if (type === 'dotted') return '3, 3';
+ return null;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="prometheus-graph-cursor"
+ :style="cursorStyle"
+ >
+ <div
+ v-if="showFlagContent"
+ class="prometheus-graph-flag popover"
+ :class="flagOrientation"
+ >
+ <div class="arrow"></div>
+ <div class="popover-title">
+ <h5 v-if="deploymentFlagData">
+ Deployed
+ </h5>
+ {{ formatDate }} at
+ <strong>{{ formatTime }}</strong>
+ </div>
+ <div
+ v-if="deploymentFlagData"
+ class="popover-content deploy-meta-content"
+ >
+ <div>
+ <icon
+ name="commit"
+ :size="12"
+ />
+ <a :href="deploymentFlagData.commitUrl">
+ {{ deploymentFlagData.sha.slice(0, 8) }}
+ </a>
+ </div>
+ <div
+ v-if="deploymentFlagData.tag"
+ >
+ <icon
+ name="label"
+ :size="12"
+ />
+ <a :href="deploymentFlagData.tagUrl">
+ {{ deploymentFlagData.ref }}
+ </a>
+ </div>
+ </div>
+ <div class="popover-content">
+ <table>
+ <tr
+ v-for="(series, index) in timeSeries"
+ :key="index"
+ >
+ <td>
+ <svg
+ width="15"
+ height="6"
+ >
+ <line
+ :stroke="series.lineColor"
+ :stroke-dasharray="strokeDashArray(series.lineStyle)"
+ stroke-width="4"
+ x1="0"
+ x2="15"
+ y1="2"
+ y2="2"
+ />
+ </svg>
+ </td>
+ <td>{{ seriesMetricLabel(index, series) }}</td>
+ <td>
+ <strong>{{ seriesMetricValue(series) }}</strong>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
new file mode 100644
index 00000000000..3149397b61f
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/legend.vue
@@ -0,0 +1,212 @@
+<script>
+ import { formatRelevantDigits } from '../../../lib/utils/number_utils';
+
+ export default {
+ props: {
+ graphWidth: {
+ type: Number,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ margin: {
+ type: Object,
+ required: true,
+ },
+ measurements: {
+ type: Object,
+ required: true,
+ },
+ legendTitle: {
+ type: String,
+ required: true,
+ },
+ yAxisLabel: {
+ type: String,
+ required: true,
+ },
+ timeSeries: {
+ type: Array,
+ required: true,
+ },
+ unitOfDisplay: {
+ type: String,
+ required: true,
+ },
+ currentDataIndex: {
+ type: Number,
+ required: true,
+ },
+ showLegendGroup: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ yLabelWidth: 0,
+ yLabelHeight: 0,
+ seriesXPosition: 0,
+ metricUsageXPosition: 0,
+ };
+ },
+ computed: {
+ textTransform() {
+ const yCoordinate = (((this.graphHeight - this.margin.top)
+ + this.measurements.axisLabelLineOffset) / 2) || 0;
+
+ return `translate(15, ${yCoordinate}) rotate(-90)`;
+ },
+
+ rectTransform() {
+ const yCoordinate = (((this.graphHeight - this.margin.top)
+ + this.measurements.axisLabelLineOffset) / 2)
+ + (this.yLabelWidth / 2) || 0;
+
+ return `translate(0, ${yCoordinate}) rotate(-90)`;
+ },
+
+ xPosition() {
+ return (((this.graphWidth + this.measurements.axisLabelLineOffset) / 2)
+ - this.margin.right) || 0;
+ },
+
+ yPosition() {
+ return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0;
+ },
+
+ },
+ mounted() {
+ this.$nextTick(() => {
+ const bbox = this.$refs.ylabel.getBBox();
+ this.metricUsageXPosition = 0;
+ this.seriesXPosition = 0;
+ if (this.$refs.legendTitleSvg != null) {
+ this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
+ }
+ if (this.$refs.seriesTitleSvg != null) {
+ this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
+ }
+ this.yLabelWidth = bbox.width + 10; // Added some padding
+ this.yLabelHeight = bbox.height + 5;
+ });
+ },
+ methods: {
+ translateLegendGroup(index) {
+ return `translate(0, ${12 * (index)})`;
+ },
+
+ formatMetricUsage(series) {
+ const value = series.values[this.currentDataIndex] &&
+ series.values[this.currentDataIndex].value;
+ if (isNaN(value)) {
+ return '-';
+ }
+ return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`;
+ },
+
+ createSeriesString(index, series) {
+ if (series.metricTag) {
+ return `${series.metricTag} ${this.formatMetricUsage(series)}`;
+ }
+ return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
+ },
+
+ strokeDashArray(type) {
+ if (type === 'dashed') return '6, 3';
+ if (type === 'dotted') return '3, 3';
+ return null;
+ },
+ },
+ };
+</script>
+<template>
+ <g class="axis-label-container">
+ <line
+ class="label-x-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ :y1="yPosition"
+ :x2="graphWidth + 20"
+ :y2="yPosition"
+ />
+ <line
+ class="label-y-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ y1="0"
+ :x2="10"
+ :y2="yPosition"
+ />
+ <rect
+ class="rect-axis-text"
+ :transform="rectTransform"
+ :width="yLabelWidth"
+ :height="yLabelHeight"
+ />
+ <text
+ class="label-axis-text y-label-text"
+ text-anchor="middle"
+ :transform="textTransform"
+ ref="ylabel"
+ >
+ {{ yAxisLabel }}
+ </text>
+ <rect
+ class="rect-axis-text"
+ :x="xPosition + 60"
+ :y="graphHeight - 80"
+ width="35"
+ height="50"
+ />
+ <text
+ class="label-axis-text x-label-text"
+ :x="xPosition + 60"
+ :y="yPosition"
+ dy=".35em"
+ >
+ Time
+ </text>
+ <template v-if="showLegendGroup">
+ <g
+ class="legend-group"
+ v-for="(series, index) in timeSeries"
+ :key="index"
+ :transform="translateLegendGroup(index)"
+ >
+ <line
+ :stroke="series.lineColor"
+ :stroke-width="measurements.legends.height"
+ :stroke-dasharray="strokeDashArray(series.lineStyle)"
+ :x1="measurements.legends.offsetX"
+ :x2="measurements.legends.offsetX + measurements.legends.width"
+ :y1="graphHeight - measurements.legends.offsetY"
+ :y2="graphHeight - measurements.legends.offsetY"
+ />
+ <text
+ v-if="timeSeries.length > 1"
+ class="legend-metric-title"
+ ref="legendTitleSvg"
+ x="38"
+ :y="graphHeight - 30"
+ >
+ {{ createSeriesString(index, series) }}
+ </text>
+ <text
+ v-else
+ class="legend-metric-title"
+ ref="legendTitleSvg"
+ x="38"
+ :y="graphHeight - 30"
+ >
+ {{ legendTitle }} {{ formatMetricUsage(series) }}
+ </text>
+ </g>
+ </template>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue
new file mode 100644
index 00000000000..c9721c4cb01
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/path.vue
@@ -0,0 +1,53 @@
+<script>
+ export default {
+ props: {
+ generatedLinePath: {
+ type: String,
+ required: true,
+ },
+ generatedAreaPath: {
+ type: String,
+ required: true,
+ },
+ lineStyle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lineColor: {
+ type: String,
+ required: true,
+ },
+ areaColor: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ strokeDashArray() {
+ if (this.lineStyle === 'dashed') return '3, 1';
+ if (this.lineStyle === 'dotted') return '1, 1';
+ return null;
+ },
+ },
+ };
+</script>
+<template>
+ <g>
+ <path
+ class="metric-area"
+ :d="generatedAreaPath"
+ :fill="areaColor"
+ transform="translate(-5, 20)"
+ />
+ <path
+ class="metric-line"
+ :d="generatedLinePath"
+ :stroke="lineColor"
+ fill="none"
+ stroke-width="1"
+ :stroke-dasharray="strokeDashArray"
+ transform="translate(-5, 20)"
+ />
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
new file mode 100644
index 00000000000..f71cf614552
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -0,0 +1,35 @@
+<script>
+ export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ showPanels: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ v-if="showPanels"
+ class="panel panel-default prometheus-panel"
+ >
+ <div class="panel-heading">
+ <h4>{{ name }}</h4>
+ </div>
+ <div class="panel-body prometheus-graph-group">
+ <slot></slot>
+ </div>
+ </div>
+ <div
+ v-else
+ class="prometheus-graph-group"
+ >
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
deleted file mode 100644
index c3a8da52404..00000000000
--- a/app/assets/javascripts/monitoring/constants.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import d3 from 'd3';
-
-export const dateFormat = d3.time.format('%b %d, %Y');
-export const timeFormat = d3.time.format('%H:%M%p');
diff --git a/app/assets/javascripts/monitoring/deployments.js b/app/assets/javascripts/monitoring/deployments.js
deleted file mode 100644
index fc92ab61b31..00000000000
--- a/app/assets/javascripts/monitoring/deployments.js
+++ /dev/null
@@ -1,211 +0,0 @@
-/* global Flash */
-import d3 from 'd3';
-import {
- dateFormat,
- timeFormat,
-} from './constants';
-
-export default class Deployments {
- constructor(width, height) {
- this.width = width;
- this.height = height;
-
- this.endpoint = document.getElementById('js-metrics').dataset.deploymentEndpoint;
-
- this.createGradientDef();
- }
-
- init(chartData) {
- this.chartData = chartData;
-
- this.x = d3.time.scale().range([0, this.width]);
- this.x.domain(d3.extent(this.chartData, d => d.time));
-
- this.charts = d3.selectAll('.prometheus-graph');
-
- this.getData();
- }
-
- getData() {
- $.ajax({
- url: this.endpoint,
- dataType: 'JSON',
- })
- .fail(() => new Flash('Error getting deployment information.'))
- .done((data) => {
- this.data = data.deployments.reduce((deploymentDataArray, deployment) => {
- const time = new Date(deployment.created_at);
- const xPos = Math.floor(this.x(time));
-
- time.setSeconds(this.chartData[0].time.getSeconds());
-
- if (xPos >= 0) {
- deploymentDataArray.push({
- id: deployment.id,
- time,
- sha: deployment.sha,
- tag: deployment.tag,
- ref: deployment.ref.name,
- xPos,
- });
- }
-
- return deploymentDataArray;
- }, []);
-
- this.plotData();
- });
- }
-
- plotData() {
- this.charts.each((d, i) => {
- const svg = d3.select(this.charts[0][i]);
- const chart = svg.select('.graph-container');
- const key = svg.node().getAttribute('graph-type');
-
- this.createLine(chart, key);
- this.createDeployInfoBox(chart, key);
- });
- }
-
- createGradientDef() {
- const defs = d3.select('body')
- .append('svg')
- .attr({
- height: 0,
- width: 0,
- })
- .append('defs');
-
- defs.append('linearGradient')
- .attr({
- id: 'shadow-gradient',
- })
- .append('stop')
- .attr({
- offset: '0%',
- 'stop-color': '#000',
- 'stop-opacity': 0.4,
- })
- .select(this.selectParentNode)
- .append('stop')
- .attr({
- offset: '100%',
- 'stop-color': '#000',
- 'stop-opacity': 0,
- });
- }
-
- createLine(chart, key) {
- chart.append('g')
- .attr({
- class: 'deploy-info',
- })
- .selectAll('.deploy-info')
- .data(this.data)
- .enter()
- .append('g')
- .attr({
- class: d => `deploy-info-${d.id}-${key}`,
- transform: d => `translate(${Math.floor(d.xPos) + 1}, 0)`,
- })
- .append('rect')
- .attr({
- x: 1,
- y: 0,
- height: this.height + 1,
- width: 3,
- fill: 'url(#shadow-gradient)',
- })
- .select(this.selectParentNode)
- .append('line')
- .attr({
- class: 'deployment-line',
- x1: 0,
- x2: 0,
- y1: 0,
- y2: this.height + 1,
- });
- }
-
- createDeployInfoBox(chart, key) {
- chart.selectAll('.deploy-info')
- .selectAll('.js-deploy-info-box')
- .data(this.data)
- .enter()
- .select(d => document.querySelector(`.deploy-info-${d.id}-${key}`))
- .append('svg')
- .attr({
- class: 'js-deploy-info-box hidden',
- x: 3,
- y: 0,
- width: 92,
- height: 60,
- })
- .append('rect')
- .attr({
- class: 'rect-text-metric deploy-info-rect rect-metric',
- x: 1,
- y: 1,
- rx: 2,
- width: 90,
- height: 58,
- })
- .select(this.selectParentNode)
- .append('g')
- .attr({
- transform: 'translate(5, 2)',
- })
- .append('text')
- .attr({
- class: 'deploy-info-text text-metric-bold',
- })
- .text(Deployments.refText)
- .select(this.selectParentNode)
- .append('text')
- .attr({
- class: 'deploy-info-text',
- y: 18,
- })
- .text(d => dateFormat(d.time))
- .select(this.selectParentNode)
- .append('text')
- .attr({
- class: 'deploy-info-text text-metric-bold',
- y: 38,
- })
- .text(d => timeFormat(d.time));
- }
-
- static toggleDeployTextbox(deploy, key, showInfoBox) {
- d3.selectAll(`.deploy-info-${deploy.id}-${key} .js-deploy-info-box`)
- .classed('hidden', !showInfoBox);
- }
-
- mouseOverDeployInfo(mouseXPos, key) {
- if (!this.data) return false;
-
- let dataFound = false;
-
- this.data.forEach((d) => {
- if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
- dataFound = d.xPos + 1;
-
- Deployments.toggleDeployTextbox(d, key, true);
- } else {
- Deployments.toggleDeployTextbox(d, key, false);
- }
- });
-
- return dataFound;
- }
-
- /* `this` is bound to the D3 node */
- selectParentNode() {
- return this.parentNode;
- }
-
- static refText(d) {
- return d.tag ? d.ref : d.sha.slice(0, 6);
- }
-}
diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/monitoring/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
new file mode 100644
index 00000000000..6cc67ba57ee
--- /dev/null
+++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
@@ -0,0 +1,73 @@
+import { bisectDate } from '../utils/date_time_formatters';
+
+const mixins = {
+ methods: {
+ mouseOverDeployInfo(mouseXPos) {
+ if (!this.reducedDeploymentData) return false;
+
+ let dataFound = false;
+ this.reducedDeploymentData = this.reducedDeploymentData.map((d) => {
+ const deployment = d;
+ if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
+ dataFound = d.xPos + 1;
+
+ deployment.showDeploymentFlag = true;
+ } else {
+ deployment.showDeploymentFlag = false;
+ }
+ return deployment;
+ });
+
+ return dataFound;
+ },
+
+ formatDeployments() {
+ this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
+ const time = new Date(deployment.created_at);
+ const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
+
+ time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
+
+ if (xPos >= 0) {
+ const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1);
+
+ deploymentDataArray.push({
+ id: deployment.id,
+ time,
+ sha: deployment.sha,
+ commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
+ tag: deployment.tag,
+ tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
+ ref: deployment.ref.name,
+ xPos,
+ seriesIndex,
+ showDeploymentFlag: false,
+ });
+ }
+
+ return deploymentDataArray;
+ }, []);
+ },
+
+ positionFlag() {
+ const timeSeries = this.timeSeries[0];
+ const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate, 1);
+ this.currentData = timeSeries.values[hoveredDataIndex];
+ this.currentDataIndex = hoveredDataIndex;
+ this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time));
+ if (this.currentXCoordinate > (this.graphWidth - 200)) {
+ this.currentFlagPosition = this.currentXCoordinate - 103;
+ } else {
+ this.currentFlagPosition = this.currentXCoordinate;
+ }
+
+ if (this.hoverData.currentDeployXPos) {
+ this.showFlag = false;
+ } else {
+ this.showFlag = true;
+ }
+ },
+ },
+};
+
+export default mixins;
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index b3ce9310417..41270e015d4 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,6 +1,22 @@
-import PrometheusGraph from './prometheus_graph';
+import Vue from 'vue';
+import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
+import Dashboard from './components/dashboard.vue';
-document.addEventListener('DOMContentLoaded', function onLoad() {
- document.removeEventListener('DOMContentLoaded', onLoad, false);
- return new PrometheusGraph();
-}, false);
+export default () => {
+ const el = document.getElementById('prometheus-graphs');
+
+ if (el && el.dataset) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(Dashboard, {
+ props: {
+ ...el.dataset,
+ hasMetrics: convertPermissionToBoolean(el.dataset.hasMetrics),
+ },
+ });
+ },
+ });
+ }
+};
diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js
deleted file mode 100644
index 6af88769129..00000000000
--- a/app/assets/javascripts/monitoring/prometheus_graph.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/* eslint-disable no-new */
-/* global Flash */
-
-import d3 from 'd3';
-import statusCodes from '~/lib/utils/http_status';
-import Deployments from './deployments';
-import '../lib/utils/common_utils';
-import { formatRelevantDigits } from '../lib/utils/number_utils';
-import '../flash';
-import {
- dateFormat,
- timeFormat,
-} from './constants';
-
-const prometheusContainer = '.prometheus-container';
-const prometheusParentGraphContainer = '.prometheus-graphs';
-const prometheusGraphsContainer = '.prometheus-graph';
-const prometheusStatesContainer = '.prometheus-state';
-const metricsEndpoint = 'metrics.json';
-const bisectDate = d3.bisector(d => d.time).left;
-const extraAddedWidthParent = 100;
-
-class PrometheusGraph {
- constructor() {
- const $prometheusContainer = $(prometheusContainer);
- const hasMetrics = $prometheusContainer.data('has-metrics');
- this.docLink = $prometheusContainer.data('doc-link');
- this.integrationLink = $prometheusContainer.data('prometheus-integration');
- this.state = '';
-
- $(document).ajaxError(() => {});
-
- if (hasMetrics) {
- this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
- this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
- const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
- extraAddedWidthParent;
- this.originalWidth = parentContainerWidth;
- this.originalHeight = 330;
- this.width = parentContainerWidth - this.margin.left - this.margin.right;
- this.height = this.originalHeight - this.margin.top - this.margin.bottom;
- this.backOffRequestCounter = 0;
- this.deployments = new Deployments(this.width, this.height);
- this.configureGraph();
- this.init();
- } else {
- const prevState = this.state;
- this.state = '.js-getting-started';
- this.updateState(prevState);
- }
- }
-
- createGraph() {
- Object.keys(this.graphSpecificProperties).forEach((key) => {
- const value = this.graphSpecificProperties[key];
- if (value.data.length > 0) {
- this.plotValues(key);
- }
- });
- }
-
- init() {
- return this.getData().then((metricsResponse) => {
- let enoughData = true;
- if (typeof metricsResponse === 'undefined') {
- enoughData = false;
- } else {
- Object.keys(metricsResponse.metrics).forEach((key) => {
- if (key === 'cpu_values' || key === 'memory_values') {
- const currentData = (metricsResponse.metrics[key])[0];
- if (currentData.values.length <= 2) {
- enoughData = false;
- }
- }
- });
- }
- if (enoughData) {
- $(prometheusStatesContainer).hide();
- $(prometheusParentGraphContainer).show();
- this.transformData(metricsResponse);
- this.createGraph();
-
- const firstMetricData = this.graphSpecificProperties[
- Object.keys(this.graphSpecificProperties)[0]
- ].data;
-
- this.deployments.init(firstMetricData);
- }
- });
- }
-
- plotValues(key) {
- const graphSpecifics = this.graphSpecificProperties[key];
-
- const x = d3.time.scale()
- .range([0, this.width]);
-
- const y = d3.scale.linear()
- .range([this.height, 0]);
-
- graphSpecifics.xScale = x;
- graphSpecifics.yScale = y;
-
- const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`;
-
- const chart = d3.select(prometheusGraphContainer)
- .attr('width', this.width + this.margin.left + this.margin.right)
- .attr('height', this.height + this.margin.bottom + this.margin.top)
- .append('g')
- .attr('class', 'graph-container')
- .attr('transform', `translate(${this.margin.left},${this.margin.top})`);
-
- const axisLabelContainer = d3.select(prometheusGraphContainer)
- .attr('width', this.originalWidth)
- .attr('height', this.originalHeight)
- .append('g')
- .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`);
-
- x.domain(d3.extent(graphSpecifics.data, d => d.time));
- y.domain([0, d3.max(graphSpecifics.data.map(metricValue => metricValue.value))]);
-
- const xAxis = d3.svg.axis()
- .scale(x)
- .ticks(this.commonGraphProperties.axis_no_ticks)
- .orient('bottom');
-
- const yAxis = d3.svg.axis()
- .scale(y)
- .ticks(this.commonGraphProperties.axis_no_ticks)
- .tickSize(-this.width)
- .outerTickSize(0)
- .orient('left');
-
- this.createAxisLabelContainers(axisLabelContainer, key);
-
- chart.append('g')
- .attr('class', 'x-axis')
- .attr('transform', `translate(0,${this.height})`)
- .call(xAxis);
-
- chart.append('g')
- .attr('class', 'y-axis')
- .call(yAxis);
-
- const area = d3.svg.area()
- .x(d => x(d.time))
- .y0(this.height)
- .y1(d => y(d.value))
- .interpolate('linear');
-
- const line = d3.svg.line()
- .x(d => x(d.time))
- .y(d => y(d.value));
-
- chart.append('path')
- .datum(graphSpecifics.data)
- .attr('d', area)
- .attr('class', 'metric-area')
- .attr('fill', graphSpecifics.area_fill_color);
-
- chart.append('path')
- .datum(graphSpecifics.data)
- .attr('class', 'metric-line')
- .attr('stroke', graphSpecifics.line_color)
- .attr('fill', 'none')
- .attr('stroke-width', this.commonGraphProperties.area_stroke_width)
- .attr('d', line);
-
- // Overlay area for the mouseover events
- chart.append('rect')
- .attr('class', 'prometheus-graph-overlay')
- .attr('width', this.width)
- .attr('height', this.height)
- .on('mousemove', this.handleMouseOverGraph.bind(this, prometheusGraphContainer));
- }
-
- // The legends from the metric
- createAxisLabelContainers(axisLabelContainer, key) {
- const graphSpecifics = this.graphSpecificProperties[key];
-
- axisLabelContainer.append('line')
- .attr('class', 'label-x-axis-line')
- .attr('stroke', '#000000')
- .attr('stroke-width', '1')
- .attr({
- x1: 10,
- y1: this.originalHeight - this.margin.top,
- x2: (this.originalWidth - this.margin.right) + 10,
- y2: this.originalHeight - this.margin.top,
- });
-
- axisLabelContainer.append('line')
- .attr('class', 'label-y-axis-line')
- .attr('stroke', '#000000')
- .attr('stroke-width', '1')
- .attr({
- x1: 10,
- y1: 0,
- x2: 10,
- y2: this.originalHeight - this.margin.top,
- });
-
- axisLabelContainer.append('rect')
- .attr('class', 'rect-axis-text')
- .attr('x', 0)
- .attr('y', 50)
- .attr('width', 30)
- .attr('height', 150);
-
- axisLabelContainer.append('text')
- .attr('class', 'label-axis-text')
- .attr('text-anchor', 'middle')
- .attr('transform', `translate(15, ${(this.originalHeight - this.margin.top) / 2}) rotate(-90)`)
- .text(graphSpecifics.graph_legend_title);
-
- axisLabelContainer.append('rect')
- .attr('class', 'rect-axis-text')
- .attr('x', (this.originalWidth / 2) - this.margin.right)
- .attr('y', this.originalHeight - 100)
- .attr('width', 30)
- .attr('height', 80);
-
- axisLabelContainer.append('text')
- .attr('class', 'label-axis-text')
- .attr('x', (this.originalWidth / 2) - this.margin.right)
- .attr('y', this.originalHeight - this.margin.top)
- .attr('dy', '.35em')
- .text('Time');
-
- // Legends
-
- // Metric Usage
- axisLabelContainer.append('rect')
- .attr('x', this.originalWidth - 170)
- .attr('y', (this.originalHeight / 2) - 60)
- .style('fill', graphSpecifics.area_fill_color)
- .attr('width', 20)
- .attr('height', 35);
-
- axisLabelContainer.append('text')
- .attr('class', 'text-metric-title')
- .attr('x', this.originalWidth - 140)
- .attr('y', (this.originalHeight / 2) - 50)
- .text('Average');
-
- axisLabelContainer.append('text')
- .attr('class', 'text-metric-usage')
- .attr('x', this.originalWidth - 140)
- .attr('y', (this.originalHeight / 2) - 25);
- }
-
- handleMouseOverGraph(prometheusGraphContainer) {
- const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`);
- const currentXCoordinate = d3.mouse(rectOverlay)[0];
-
- Object.keys(this.graphSpecificProperties).forEach((key) => {
- const currentGraphProps = this.graphSpecificProperties[key];
- const timeValueOverlay = currentGraphProps.xScale.invert(currentXCoordinate);
- const overlayIndex = bisectDate(currentGraphProps.data, timeValueOverlay, 1);
- const d0 = currentGraphProps.data[overlayIndex - 1];
- const d1 = currentGraphProps.data[overlayIndex];
- const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay;
- const currentData = evalTime ? d1 : d0;
- const currentTimeCoordinate = Math.floor(currentGraphProps.xScale(currentData.time));
- const currentDeployXPos = this.deployments.mouseOverDeployInfo(currentXCoordinate, key);
- const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`;
- const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value));
- const maxMetricValue = currentGraphProps.yScale(maxValueFromData);
-
- // Clear up all the pieces of the flag
- d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric:not(.deploy-info-rect)`).remove();
-
- const currentChart = d3.select(currentPrometheusGraphContainer).select('g');
- currentChart.append('line')
- .attr({
- class: `${currentDeployXPos ? 'hidden' : ''} selected-metric-line`,
- x1: currentTimeCoordinate,
- y1: currentGraphProps.yScale(0),
- x2: currentTimeCoordinate,
- y2: maxMetricValue,
- });
-
- currentChart.append('circle')
- .attr('class', 'circle-metric')
- .attr('fill', currentGraphProps.line_color)
- .attr('cx', currentDeployXPos || currentTimeCoordinate)
- .attr('cy', currentGraphProps.yScale(currentData.value))
- .attr('r', this.commonGraphProperties.circle_radius_metric);
-
- if (currentDeployXPos) return;
-
- // The little box with text
- const rectTextMetric = currentChart.append('svg')
- .attr({
- class: 'rect-text-metric',
- x: currentTimeCoordinate,
- y: 0,
- });
-
- rectTextMetric.append('rect')
- .attr({
- class: 'rect-metric',
- x: 4,
- y: 1,
- rx: 2,
- width: this.commonGraphProperties.rect_text_width,
- height: this.commonGraphProperties.rect_text_height,
- });
-
- rectTextMetric.append('text')
- .attr({
- class: 'text-metric text-metric-bold',
- x: 8,
- y: 35,
- })
- .text(timeFormat(currentData.time));
-
- rectTextMetric.append('text')
- .attr({
- class: 'text-metric-date',
- x: 8,
- y: 15,
- })
- .text(dateFormat(currentData.time));
-
- let currentMetricValue = formatRelevantDigits(currentData.value);
- if (key === 'cpu_values') {
- currentMetricValue = `${currentMetricValue}%`;
- } else {
- currentMetricValue = `${currentMetricValue} MB`;
- }
-
- d3.select(`${currentPrometheusGraphContainer} .text-metric-usage`)
- .text(currentMetricValue);
- });
- }
-
- configureGraph() {
- this.graphSpecificProperties = {
- cpu_values: {
- area_fill_color: '#edf3fc',
- line_color: '#5b99f7',
- graph_legend_title: 'CPU Usage (Cores)',
- data: [],
- xScale: {},
- yScale: {},
- },
- memory_values: {
- area_fill_color: '#fca326',
- line_color: '#fc6d26',
- graph_legend_title: 'Memory Usage (MB)',
- data: [],
- xScale: {},
- yScale: {},
- },
- };
-
- this.commonGraphProperties = {
- area_stroke_width: 2,
- median_total_characters: 8,
- circle_radius_metric: 5,
- rect_text_width: 90,
- rect_text_height: 40,
- axis_no_ticks: 3,
- };
- }
-
- getData() {
- const maxNumberOfRequests = 3;
- this.state = '.js-loading';
- this.updateState();
- return gl.utils.backOff((next, stop) => {
- $.ajax({
- url: metricsEndpoint,
- dataType: 'json',
- })
- .done((data, statusText, resp) => {
- if (resp.status === statusCodes.NO_CONTENT) {
- this.backOffRequestCounter = this.backOffRequestCounter += 1;
- if (this.backOffRequestCounter < maxNumberOfRequests) {
- next();
- } else if (this.backOffRequestCounter >= maxNumberOfRequests) {
- stop(new Error('loading'));
- }
- } else if (!data.success) {
- stop(new Error('loading'));
- } else {
- stop({
- status: resp.status,
- metrics: data,
- });
- }
- }).fail(stop);
- })
- .then((resp) => {
- if (resp.status === statusCodes.NO_CONTENT) {
- return {};
- }
- return resp.metrics;
- })
- .catch(() => {
- const prevState = this.state;
- this.state = '.js-unable-to-connect';
- this.updateState(prevState);
- });
- }
-
- transformData(metricsResponse) {
- Object.keys(metricsResponse.metrics).forEach((key) => {
- if (key === 'cpu_values' || key === 'memory_values') {
- const metricValues = (metricsResponse.metrics[key])[0];
- this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
- time: new Date(metric[0] * 1000),
- value: metric[1],
- }));
- }
- });
- }
-
- updateState(prevState) {
- const $statesContainer = $(prometheusStatesContainer);
- $(prometheusParentGraphContainer).hide();
- if (prevState) {
- $(`${prevState}`, $statesContainer).addClass('hidden');
- }
- $(`${this.state}`, $statesContainer).removeClass('hidden');
- $(prometheusStatesContainer).show();
- }
-}
-
-export default PrometheusGraph;
diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
new file mode 100644
index 00000000000..6fcca36d2fa
--- /dev/null
+++ b/app/assets/javascripts/monitoring/services/monitoring_service.js
@@ -0,0 +1,55 @@
+import axios from '../../lib/utils/axios_utils';
+import statusCodes from '../../lib/utils/http_status';
+import { backOff } from '../../lib/utils/common_utils';
+
+const MAX_REQUESTS = 3;
+
+function backOffRequest(makeRequestCallback) {
+ let requestCounter = 0;
+ return backOff((next, stop) => {
+ makeRequestCallback().then((resp) => {
+ if (resp.status === statusCodes.NO_CONTENT) {
+ requestCounter += 1;
+ if (requestCounter < MAX_REQUESTS) {
+ next();
+ } else {
+ stop(new Error('Failed to connect to the prometheus server'));
+ }
+ } else {
+ stop(resp);
+ }
+ }).catch(stop);
+ });
+}
+
+export default class MonitoringService {
+ constructor({ metricsEndpoint, deploymentEndpoint }) {
+ this.metricsEndpoint = metricsEndpoint;
+ this.deploymentEndpoint = deploymentEndpoint;
+ }
+
+ getGraphsData() {
+ return backOffRequest(() => axios.get(this.metricsEndpoint))
+ .then(resp => resp.data)
+ .then((response) => {
+ if (!response || !response.data) {
+ throw new Error('Unexpected metrics data response from prometheus endpoint');
+ }
+ return response.data;
+ });
+ }
+
+ getDeploymentData() {
+ if (!this.deploymentEndpoint) {
+ return Promise.resolve([]);
+ }
+ return backOffRequest(() => axios.get(this.deploymentEndpoint))
+ .then(resp => resp.data)
+ .then((response) => {
+ if (!response || !response.deployments) {
+ throw new Error('Unexpected deployment data response from prometheus endpoint');
+ }
+ return response.deployments;
+ });
+ }
+}
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
new file mode 100644
index 00000000000..854636e9a89
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -0,0 +1,43 @@
+import _ from 'underscore';
+
+function sortMetrics(metrics) {
+ return _.chain(metrics).sortBy('weight').sortBy('title').value();
+}
+
+function normalizeMetrics(metrics) {
+ return metrics.map(metric => ({
+ ...metric,
+ queries: metric.queries.map(query => ({
+ ...query,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => ({
+ time: new Date(timestamp * 1000),
+ value: Number(value),
+ })),
+ })),
+ })),
+ }));
+}
+
+export default class MonitoringStore {
+ constructor() {
+ this.groups = [];
+ this.deploymentData = [];
+ }
+
+ storeMetrics(groups = []) {
+ this.groups = groups.map(group => ({
+ ...group,
+ metrics: normalizeMetrics(sortMetrics(group.metrics)),
+ }));
+ }
+
+ storeDeploymentData(deploymentData = []) {
+ this.deploymentData = deploymentData;
+ }
+
+ getMetricsCount() {
+ return this.groups.reduce((count, group) => count + group.metrics.length, 0);
+ }
+}
diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
new file mode 100644
index 00000000000..f3c9acdd93e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
@@ -0,0 +1,42 @@
+import { timeFormat as time } from 'd3-time-format';
+import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
+import { bisector } from 'd3-array';
+
+const d3 = {
+ time,
+ bisector,
+ timeSecond,
+ timeMinute,
+ timeHour,
+ timeDay,
+ timeWeek,
+ timeMonth,
+ timeYear,
+};
+
+export const dateFormat = d3.time('%a, %b %-d');
+export const timeFormat = d3.time('%-I:%M%p');
+export const dateFormatWithName = d3.time('%a, %b %-d');
+export const bisectDate = d3.bisector(d => d.time).left;
+
+export function timeScaleFormat(date) {
+ let formatFunction;
+ if (d3.timeSecond(date) < date) {
+ formatFunction = d3.time('.%L');
+ } else if (d3.timeMinute(date) < date) {
+ formatFunction = d3.time(':%S');
+ } else if (d3.timeHour(date) < date) {
+ formatFunction = d3.time('%-I:%M');
+ } else if (d3.timeDay(date) < date) {
+ formatFunction = d3.time('%-I %p');
+ } else if (d3.timeWeek(date) < date) {
+ formatFunction = d3.time('%a %d');
+ } else if (d3.timeMonth(date) < date) {
+ formatFunction = d3.time('%b %d');
+ } else if (d3.timeYear(date) < date) {
+ formatFunction = d3.time('%B');
+ } else {
+ formatFunction = d3.time('%Y');
+ }
+ return formatFunction(date);
+}
diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js
new file mode 100644
index 00000000000..ee866850e13
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils/measurements.js
@@ -0,0 +1,42 @@
+export default {
+ small: { // Covers both xs and sm screen sizes
+ margin: {
+ top: 40,
+ right: 40,
+ bottom: 50,
+ left: 40,
+ },
+ legends: {
+ width: 15,
+ height: 3,
+ offsetX: 20,
+ offsetY: 32,
+ },
+ backgroundLegend: {
+ width: 30,
+ height: 50,
+ },
+ axisLabelLineOffset: -20,
+ },
+ large: { // This covers both md and lg screen sizes
+ margin: {
+ top: 80,
+ right: 80,
+ bottom: 100,
+ left: 80,
+ },
+ legends: {
+ width: 15,
+ height: 3,
+ offsetX: 20,
+ offsetY: 34,
+ },
+ backgroundLegend: {
+ width: 30,
+ height: 150,
+ },
+ axisLabelLineOffset: 20,
+ },
+ xTicks: 8,
+ yTicks: 3,
+};
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
new file mode 100644
index 00000000000..b5b8e3c255d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
@@ -0,0 +1,114 @@
+import _ from 'underscore';
+import { scaleLinear, scaleTime } from 'd3-scale';
+import { line, area, curveLinear } from 'd3-shape';
+import { extent, max } from 'd3-array';
+import { timeMinute } from 'd3-time';
+
+const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute };
+
+const defaultColorPalette = {
+ blue: ['#1f78d1', '#8fbce8'],
+ orange: ['#fc9403', '#feca81'],
+ red: ['#db3b21', '#ed9d90'],
+ green: ['#1aaa55', '#8dd5aa'],
+ purple: ['#6666c4', '#d1d1f0'],
+};
+
+const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
+
+const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
+
+function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
+ let usedColors = [];
+
+ function pickColor(name) {
+ let pick;
+ if (name && defaultColorPalette[name]) {
+ pick = name;
+ } else {
+ const unusedColors = _.difference(defaultColorOrder, usedColors);
+ if (unusedColors.length > 0) {
+ pick = unusedColors[0];
+ } else {
+ usedColors = [];
+ pick = defaultColorOrder[0];
+ }
+ }
+ usedColors.push(pick);
+ return defaultColorPalette[pick];
+ }
+
+ return query.result.map((timeSeries, timeSeriesNumber) => {
+ let metricTag = '';
+ let lineColor = '';
+ let areaColor = '';
+
+ const timeSeriesScaleX = d3.scaleTime()
+ .range([0, graphWidth - 70]);
+
+ const timeSeriesScaleY = d3.scaleLinear()
+ .range([graphHeight - graphHeightOffset, 0]);
+
+ timeSeriesScaleX.domain(xDom);
+ timeSeriesScaleX.ticks(d3.timeMinute, 60);
+ timeSeriesScaleY.domain(yDom);
+
+ const defined = d => !isNaN(d.value) && d.value != null;
+
+ const lineFunction = d3.line()
+ .defined(defined)
+ .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
+ .x(d => timeSeriesScaleX(d.time))
+ .y(d => timeSeriesScaleY(d.value));
+
+ const areaFunction = d3.area()
+ .defined(defined)
+ .curve(d3.curveLinear)
+ .x(d => timeSeriesScaleX(d.time))
+ .y0(graphHeight - graphHeightOffset)
+ .y1(d => timeSeriesScaleY(d.value));
+
+ const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
+ const seriesCustomizationData = query.series != null &&
+ _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
+
+ if (seriesCustomizationData) {
+ metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
+ [lineColor, areaColor] = pickColor(seriesCustomizationData.color);
+ } else {
+ metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
+ [lineColor, areaColor] = pickColor();
+ }
+
+ if (query.track) {
+ metricTag += ` - ${query.track}`;
+ }
+
+ return {
+ linePath: lineFunction(timeSeries.values),
+ areaPath: areaFunction(timeSeries.values),
+ timeSeriesScaleX,
+ values: timeSeries.values,
+ lineStyle,
+ lineColor,
+ areaColor,
+ metricTag,
+ };
+ });
+}
+
+export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
+ const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat(
+ query.result.reduce((allResults, result) => allResults.concat(result.values), []),
+ ), []);
+
+ const xDom = d3.extent(allValues, d => d.time);
+ const yDom = [0, d3.max(allValues.map(d => d.value))];
+
+ return queries.reduce((series, query, index) => {
+ const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length];
+ return series.concat(
+ queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle),
+ );
+ }, []);
+}
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
new file mode 100644
index 00000000000..096c4ef5f31
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import notesApp from '../notes/components/notes_app.vue';
+import discussionCounter from '../notes/components/discussion_counter.vue';
+import store from '../notes/stores';
+
+export default function initMrNotes() {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-vue-mr-discussions',
+ components: {
+ notesApp,
+ },
+ data() {
+ const notesDataset = document.getElementById('js-vue-mr-discussions')
+ .dataset;
+ return {
+ noteableData: JSON.parse(notesDataset.noteableData),
+ currentUserData: JSON.parse(notesDataset.currentUserData),
+ notesData: JSON.parse(notesDataset.notesData),
+ };
+ },
+ render(createElement) {
+ return createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ },
+ });
+ },
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-vue-discussion-counter',
+ components: {
+ discussionCounter,
+ },
+ store,
+ render(createElement) {
+ return createElement('discussion-counter');
+ },
+ });
+}
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index 5da2db063a4..c7a8aac79df 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,85 +1,59 @@
-/* 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 */
-import Api from './api';
-
-(function() {
- window.NamespaceSelect = (function() {
- function NamespaceSelect(opts) {
- this.onSelectItem = this.onSelectItem.bind(this);
- var fieldName, showAny;
- this.dropdown = opts.dropdown;
- showAny = true;
- fieldName = 'namespace_id';
- if (this.dropdown.attr('data-field-name')) {
- fieldName = this.dropdown.data('fieldName');
- }
- if (this.dropdown.attr('data-show-any')) {
- showAny = this.dropdown.data('showAny');
- }
- this.dropdown.glDropdown({
- filterable: true,
- selectable: true,
- filterRemote: true,
- search: {
- fields: ['path']
- },
- fieldName: fieldName,
- toggleLabel: function(selected) {
- if (selected.id == null) {
- return selected.text;
- } else {
- return selected.kind + ": " + selected.full_path;
- }
- },
- data: function(term, dataCallback) {
- return Api.namespaces(term, function(namespaces) {
- var anyNamespace;
- if (showAny) {
- anyNamespace = {
- text: 'Any namespace',
- id: null
- };
- namespaces.unshift(anyNamespace);
- namespaces.splice(1, 0, 'divider');
- }
- return dataCallback(namespaces);
- });
- },
- text: function(namespace) {
- if (namespace.id == null) {
- return namespace.text;
- } else {
- return namespace.kind + ": " + namespace.full_path;
- }
- },
- renderRow: this.renderRow,
- clicked: this.onSelectItem
- });
- }
+/* eslint-disable func-names, space-before-function-paren, no-var, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, max-len */
- NamespaceSelect.prototype.onSelectItem = function(options) {
- const { e } = options;
- return e.preventDefault();
- };
+import $ from 'jquery';
+import Api from './api';
+import { mergeUrlParams } from './lib/utils/url_utility';
- return NamespaceSelect;
- })();
+export default class NamespaceSelect {
+ constructor(opts) {
+ const isFilter = opts.dropdown.dataset.isFilter === 'true';
+ const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id';
- window.NamespaceSelects = (function() {
- function NamespaceSelects(opts) {
- var ref;
- if (opts == null) {
- opts = {};
- }
- this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-namespace-select');
- this.$dropdowns.each(function(i, dropdown) {
- var $dropdown;
- $dropdown = $(dropdown);
- return new window.NamespaceSelect({
- dropdown: $dropdown
+ $(opts.dropdown).glDropdown({
+ filterable: true,
+ selectable: true,
+ filterRemote: true,
+ search: {
+ fields: ['path']
+ },
+ fieldName: fieldName,
+ toggleLabel: function(selected) {
+ if (selected.id == null) {
+ return selected.text;
+ } else {
+ return selected.kind + ": " + selected.full_path;
+ }
+ },
+ data: function(term, dataCallback) {
+ return Api.namespaces(term, function(namespaces) {
+ if (isFilter) {
+ const anyNamespace = {
+ text: 'Any namespace',
+ id: null
+ };
+ namespaces.unshift(anyNamespace);
+ namespaces.splice(1, 0, 'divider');
+ }
+ return dataCallback(namespaces);
});
- });
- }
-
- return NamespaceSelects;
- })();
-}).call(window);
+ },
+ text: function(namespace) {
+ if (namespace.id == null) {
+ return namespace.text;
+ } else {
+ return namespace.kind + ": " + namespace.full_path;
+ }
+ },
+ renderRow: this.renderRow,
+ clicked(options) {
+ if (!isFilter) {
+ const { e } = options;
+ e.preventDefault();
+ }
+ },
+ url(namespace) {
+ return mergeUrlParams({ [fieldName]: namespace.id }, window.location.href);
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 5aad3908eb6..bd007c707f2 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,5 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, quotes, comma-dangle, one-var, one-var-declaration-per-line, no-mixed-operators, no-loop-func, no-floating-decimal, consistent-return, no-unused-vars, prefer-template, prefer-arrow-callback, camelcase, max-len */
+import $ from 'jquery';
+import { __ } from '../locale';
+import axios from '../lib/utils/axios_utils';
+import flash from '../flash';
import Raphael from './raphael';
export default (function() {
@@ -26,16 +30,13 @@ export default (function() {
}
BranchGraph.prototype.load = function() {
- return $.ajax({
- url: this.options.url,
- method: "get",
- dataType: "json",
- success: $.proxy(function(data) {
+ axios.get(this.options.url)
+ .then(({ data }) => {
$(".loading", this.element).hide();
this.prepareData(data.days, data.commits);
- return this.buildGraph();
- }, this)
- });
+ this.buildGraph();
+ })
+ .catch(() => __('Error fetching network graph.'));
};
BranchGraph.prototype.prepareData = function(days, commits) {
diff --git a/app/assets/javascripts/network/network.js b/app/assets/javascripts/network/network.js
deleted file mode 100644
index a3fd22aff2a..00000000000
--- a/app/assets/javascripts/network/network.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */
-
-import BranchGraph from './branch_graph';
-
-export default (function() {
- function Network(opts) {
- var vph;
- $("#filter_ref").click(function() {
- return $(this).closest('form').submit();
- });
- this.branch_graph = new BranchGraph($(".network-graph"), opts);
- vph = $(window).height() - 250;
- $('.network-graph').css({
- 'height': vph + 'px'
- });
- }
-
- return Network;
-})();
diff --git a/app/assets/javascripts/network/network_bundle.js b/app/assets/javascripts/network/network_bundle.js
deleted file mode 100644
index 8aae2ad201c..00000000000
--- a/app/assets/javascripts/network/network_bundle.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, camelcase, comma-dangle, consistent-return, max-len */
-/* global ShortcutsNetwork */
-
-import Network from './network';
-
-$(function() {
- if (!$(".network-graph").length) return;
-
- var network_graph;
- network_graph = new Network({
- url: $(".network-graph").attr('data-url'),
- commit_url: $(".network-graph").attr('data-commit-url'),
- ref: $(".network-graph").attr('data-ref'),
- commit_id: $(".network-graph").attr('data-commit-id')
- });
- return new ShortcutsNetwork(network_graph.branch_graph);
-});
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 39fb302b644..40c08ee0ace 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,97 +1,95 @@
/* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */
-import RefSelectDropdown from '~/ref_select_dropdown';
-(function() {
- this.NewBranchForm = (function() {
- function NewBranchForm(form, availableRefs) {
- 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');
- new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new
- this.setupRestrictions();
- this.addBinding();
- this.init();
+import $ from 'jquery';
+import RefSelectDropdown from './ref_select_dropdown';
+
+export default class NewBranchForm {
+ constructor(form, availableRefs) {
+ 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');
+ new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new
+ this.setupRestrictions();
+ this.addBinding();
+ this.init();
+ }
+
+ addBinding() {
+ return this.name.on('blur', this.validate);
+ }
+
+ init() {
+ if (this.name.length && this.name.val().length > 0) {
+ return this.name.trigger('blur');
}
+ }
- NewBranchForm.prototype.addBinding = function() {
- return this.name.on('blur', this.validate);
+ setupRestrictions() {
+ var endsWith, invalid, single, startsWith;
+ startsWith = {
+ pattern: /^(\/|\.)/g,
+ prefix: "can't start with",
+ conjunction: "or"
};
-
- NewBranchForm.prototype.init = function() {
- if (this.name.length && this.name.val().length > 0) {
- return this.name.trigger('blur');
- }
+ endsWith = {
+ pattern: /(\/|\.|\.lock)$/g,
+ prefix: "can't end in",
+ conjunction: "or"
};
-
- NewBranchForm.prototype.setupRestrictions = function() {
- var endsWith, invalid, single, startsWith;
- startsWith = {
- pattern: /^(\/|\.)/g,
- prefix: "can't start with",
- conjunction: "or"
- };
- endsWith = {
- pattern: /(\/|\.|\.lock)$/g,
- prefix: "can't end in",
- conjunction: "or"
- };
- invalid = {
- pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g,
- prefix: "can't contain",
- conjunction: ", "
- };
- single = {
- pattern: /^@+$/g,
- prefix: "can't be",
- conjunction: "or"
- };
- return this.restrictions = [startsWith, invalid, endsWith, single];
+ invalid = {
+ pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g,
+ prefix: "can't contain",
+ conjunction: ", "
};
+ single = {
+ pattern: /^@+$/g,
+ prefix: "can't be",
+ conjunction: "or"
+ };
+ return this.restrictions = [startsWith, invalid, endsWith, single];
+ }
- NewBranchForm.prototype.validate = function() {
- var errorMessage, errors, formatter, unique, validator;
- const indexOf = [].indexOf;
+ validate() {
+ var errorMessage, errors, formatter, unique, validator;
+ const indexOf = [].indexOf;
- this.branchNameError.empty();
- unique = function(values, value) {
- if (indexOf.call(values, value) === -1) {
- values.push(value);
- }
- return values;
- };
- formatter = function(values, restriction) {
- var formatted;
- formatted = values.map(function(value) {
- switch (false) {
- case !/\s/.test(value):
- return 'spaces';
- case !/\/{2,}/g.test(value):
- return 'consecutive slashes';
- default:
- return "'" + value + "'";
- }
- });
- return restriction.prefix + " " + (formatted.join(restriction.conjunction));
- };
- validator = (function(_this) {
- return function(errors, restriction) {
- var matched;
- matched = _this.name.val().match(restriction.pattern);
- if (matched) {
- return errors.concat(formatter(matched.reduce(unique, []), restriction));
- } else {
- return errors;
- }
- };
- })(this);
- errors = this.restrictions.reduce(validator, []);
- if (errors.length > 0) {
- errorMessage = $("<span/>").text(errors.join(', '));
- return this.branchNameError.append(errorMessage);
+ this.branchNameError.empty();
+ unique = function(values, value) {
+ if (indexOf.call(values, value) === -1) {
+ values.push(value);
}
+ return values;
};
-
- return NewBranchForm;
- })();
-}).call(window);
+ formatter = function(values, restriction) {
+ var formatted;
+ formatted = values.map(function(value) {
+ switch (false) {
+ case !/\s/.test(value):
+ return 'spaces';
+ case !/\/{2,}/g.test(value):
+ return 'consecutive slashes';
+ default:
+ return "'" + value + "'";
+ }
+ });
+ return restriction.prefix + " " + (formatted.join(restriction.conjunction));
+ };
+ validator = (function(_this) {
+ return function(errors, restriction) {
+ var matched;
+ matched = _this.name.val().match(restriction.pattern);
+ if (matched) {
+ return errors.concat(formatter(matched.reduce(unique, []), restriction));
+ } else {
+ return errors;
+ }
+ };
+ })(this);
+ errors = this.restrictions.reduce(validator, []);
+ if (errors.length > 0) {
+ errorMessage = $("<span/>").text(errors.join(', '));
+ return this.branchNameError.append(errorMessage);
+ }
+ }
+}
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index 04073ef7270..a2f0a44863f 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,32 +1,29 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */
-(function() {
- this.NewCommitForm = (function() {
- function NewCommitForm(form) {
- this.form = form;
- this.renderDestination = this.renderDestination.bind(this);
- this.branchName = form.find('.js-branch-name');
- this.originalBranch = form.find('.js-original-branch');
- this.createMergeRequest = form.find('.js-create-merge-request');
- this.createMergeRequestContainer = form.find('.js-create-merge-request-container');
- this.branchName.keyup(this.renderDestination);
- this.renderDestination();
- }
-
- NewCommitForm.prototype.renderDestination = function() {
- var different;
- different = this.branchName.val() !== this.originalBranch.val();
- if (different) {
- this.createMergeRequestContainer.show();
- if (!this.wasDifferent) {
- this.createMergeRequest.prop('checked', true);
- }
- } else {
- this.createMergeRequestContainer.hide();
- this.createMergeRequest.prop('checked', false);
+export default class NewCommitForm {
+ constructor(form) {
+ this.form = form;
+ this.renderDestination = this.renderDestination.bind(this);
+ this.branchName = form.find('.js-branch-name');
+ this.originalBranch = form.find('.js-original-branch');
+ this.createMergeRequest = form.find('.js-create-merge-request');
+ this.createMergeRequestContainer = form.find(
+ '.js-create-merge-request-container',
+ );
+ this.branchName.keyup(this.renderDestination);
+ this.renderDestination();
+ }
+ renderDestination() {
+ var different;
+ different = this.branchName.val() !== this.originalBranch.val();
+ if (different) {
+ this.createMergeRequestContainer.show();
+ if (!this.wasDifferent) {
+ this.createMergeRequest.prop('checked', true);
}
- return this.wasDifferent = different;
- };
-
- return NewCommitForm;
- })();
-}).call(window);
+ } else {
+ this.createMergeRequestContainer.hide();
+ this.createMergeRequest.prop('checked', false);
+ }
+ return (this.wasDifferent = different);
+ }
+}
diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue
index b8a16356576..b4067d229aa 100644
--- a/app/assets/javascripts/notebook/cells/code.vue
+++ b/app/assets/javascripts/notebook/cells/code.vue
@@ -1,18 +1,3 @@
-<template>
- <div class="cell">
- <code-cell
- type="input"
- :raw-code="rawInputCode"
- :count="cell.execution_count"
- :code-css-class="codeCssClass" />
- <output-cell
- v-if="hasOutput"
- :count="cell.execution_count"
- :output="output"
- :code-css-class="codeCssClass" />
- </div>
-</template>
-
<script>
import CodeCell from './code/index.vue';
import OutputCell from './output/index.vue';
@@ -51,6 +36,21 @@ export default {
};
</script>
+<template>
+ <div class="cell">
+ <code-cell
+ type="input"
+ :raw-code="rawInputCode"
+ :count="cell.execution_count"
+ :code-css-class="codeCssClass" />
+ <output-cell
+ v-if="hasOutput"
+ :count="cell.execution_count"
+ :output="output"
+ :code-css-class="codeCssClass" />
+ </div>
+</template>
+
<style scoped>
.cell {
flex-direction: column;
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 31b30f601e2..0f3083f05b2 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -1,17 +1,3 @@
-<template>
- <div :class="type">
- <prompt
- :type="promptType"
- :count="count" />
- <pre
- class="language-python"
- :class="codeCssClass"
- ref="code"
- v-text="code">
- </pre>
- </div>
-</template>
-
<script>
import Prism from '../../lib/highlight';
import Prompt from '../prompt.vue';
@@ -55,3 +41,17 @@
},
};
</script>
+
+<template>
+ <div :class="type">
+ <prompt
+ :type="promptType"
+ :count="count" />
+ <pre
+ class="language-python"
+ :class="codeCssClass"
+ ref="code"
+ v-text="code">
+ </pre>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 814d2ea92b4..3d09d24b6ab 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,13 +1,7 @@
-<template>
- <div class="cell text-cell">
- <prompt />
- <div class="markdown" v-html="markdown"></div>
- </div>
-</template>
-
<script>
/* global katex */
import marked from 'marked';
+ import sanitize from 'sanitize-html';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
@@ -89,20 +83,35 @@
},
computed: {
markdown() {
- return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
+ return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), {
+ allowedTags: false,
+ allowedAttributes: {
+ '*': ['class'],
+ },
+ });
},
},
};
</script>
+<template>
+ <div class="cell text-cell">
+ <prompt />
+ <div
+ class="markdown"
+ v-html="markdown">
+ </div>
+ </div>
+</template>
+
<style>
-.markdown .katex {
- display: block;
- text-align: center;
-}
+ .markdown .katex {
+ display: block;
+ text-align: center;
+ }
-.markdown .inline-katex .katex {
- display: inline;
- text-align: initial;
-}
+ .markdown .inline-katex .katex {
+ display: inline;
+ text-align: initial;
+ }
</style>
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index 0f39cd138df..0535ee7afa8 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,22 +1,35 @@
+<script>
+ import sanitize from 'sanitize-html';
+ import Prompt from '../prompt.vue';
+
+ export default {
+ components: {
+ prompt: Prompt,
+ },
+ props: {
+ rawCode: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ sanitizedOutput() {
+ return sanitize(this.rawCode, {
+ allowedTags: sanitize.defaults.allowedTags.concat([
+ 'img', 'svg',
+ ]),
+ allowedAttributes: {
+ img: ['src'],
+ },
+ });
+ },
+ },
+ };
+</script>
+
<template>
<div class="output">
<prompt />
- <div v-html="rawCode"></div>
+ <div v-html="sanitizedOutput"></div>
</div>
</template>
-
-<script>
-import Prompt from '../prompt.vue';
-
-export default {
- props: {
- rawCode: {
- type: String,
- required: true,
- },
- },
- components: {
- prompt: Prompt,
- },
-};
-</script>
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index f3b873bbc0f..67d6c5ad12b 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -1,27 +1,26 @@
-<template>
- <div class="output">
- <prompt />
- <img
- :src="'data:' + outputType + ';base64,' + rawCode" />
- </div>
-</template>
-
<script>
-import Prompt from '../prompt.vue';
+ import Prompt from '../prompt.vue';
-export default {
- props: {
- outputType: {
- type: String,
- required: true,
+ export default {
+ components: {
+ prompt: Prompt,
},
- rawCode: {
- type: String,
- required: true,
+ props: {
+ outputType: {
+ type: String,
+ required: true,
+ },
+ rawCode: {
+ type: String,
+ required: true,
+ },
},
- },
- components: {
- prompt: Prompt,
- },
-};
+ };
</script>
+
+<template>
+ <div class="output">
+ <prompt />
+ <img :src="'data:' + outputType + ';base64,' + rawCode" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 23c9ea78939..91b2269a83a 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -1,83 +1,87 @@
-<template>
- <component :is="componentName"
- type="output"
- :outputType="outputType"
- :count="count"
- :raw-code="rawCode"
- :code-css-class="codeCssClass" />
-</template>
-
<script>
-import CodeCell from '../code/index.vue';
-import Html from './html.vue';
-import Image from './image.vue';
+ import CodeCell from '../code/index.vue';
+ import Html from './html.vue';
+ import Image from './image.vue';
-export default {
- props: {
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
- count: {
- type: Number,
- required: false,
- default: 0,
+ export default {
+ components: {
+ 'code-cell': CodeCell,
+ 'html-output': Html,
+ 'image-output': Image,
},
- output: {
- type: Object,
- requred: true,
+ props: {
+ codeCssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ output: {
+ type: Object,
+ requred: true,
+ default: () => ({}),
+ },
},
- },
- components: {
- 'code-cell': CodeCell,
- 'html-output': Html,
- 'image-output': Image,
- },
- data() {
- return {
- outputType: '',
- };
- },
- computed: {
- componentName() {
- if (this.output.text) {
- return 'code-cell';
- } else if (this.output.data['image/png']) {
- this.outputType = 'image/png';
-
- return 'image-output';
- } else if (this.output.data['text/html']) {
- this.outputType = 'text/html';
+ computed: {
+ componentName() {
+ if (this.output.text) {
+ return 'code-cell';
+ } else if (this.output.data['image/png']) {
+ return 'image-output';
+ } else if (this.output.data['text/html']) {
+ return 'html-output';
+ } else if (this.output.data['image/svg+xml']) {
+ return 'html-output';
+ }
- return 'html-output';
- } else if (this.output.data['image/svg+xml']) {
- this.outputType = 'image/svg+xml';
-
- return 'html-output';
- }
+ return 'code-cell';
+ },
+ rawCode() {
+ if (this.output.text) {
+ return this.output.text.join('');
+ }
- this.outputType = 'text/plain';
- return 'code-cell';
- },
- rawCode() {
- if (this.output.text) {
- return this.output.text.join('');
- }
+ return this.dataForType(this.outputType);
+ },
+ outputType() {
+ if (this.output.text) {
+ return '';
+ } else if (this.output.data['image/png']) {
+ return 'image/png';
+ } else if (this.output.data['text/html']) {
+ return 'text/html';
+ } else if (this.output.data['image/svg+xml']) {
+ return 'image/svg+xml';
+ }
- return this.dataForType(this.outputType);
+ return 'text/plain';
+ },
},
- },
- methods: {
- dataForType(type) {
- let data = this.output.data[type];
+ methods: {
+ dataForType(type) {
+ let data = this.output.data[type];
- if (typeof data === 'object') {
- data = data.join('');
- }
+ if (typeof data === 'object') {
+ data = data.join('');
+ }
- return data;
+ return data;
+ },
},
- },
-};
+ };
</script>
+
+<template>
+ <component
+ :is="componentName"
+ type="output"
+ :output-type="outputType"
+ :count="count"
+ :raw-code="rawCode"
+ :code-css-class="codeCssClass"
+ />
+</template>
diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue
index 4540e4248d8..fe1fc37e1dc 100644
--- a/app/assets/javascripts/notebook/cells/prompt.vue
+++ b/app/assets/javascripts/notebook/cells/prompt.vue
@@ -1,30 +1,37 @@
-<template>
- <div class="prompt">
- <span v-if="type && count">
- {{ type }} [{{ count }}]:
- </span>
- </div>
-</template>
-
<script>
export default {
props: {
type: {
type: String,
required: false,
+ default: '',
},
count: {
type: Number,
required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ hasKeys() {
+ return this.type !== '' && this.count;
},
},
};
</script>
+<template>
+ <div class="prompt">
+ <span v-if="hasKeys">
+ {{ type }} [{{ count }}]:
+ </span>
+ </div>
+</template>
+
<style scoped>
-.prompt {
- padding: 0 10px;
- min-width: 7em;
- font-family: monospace;
-}
+ .prompt {
+ padding: 0 10px;
+ min-width: 7em;
+ font-family: monospace;
+ }
</style>
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index fd62c1231ef..e2e3b08c77f 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -1,14 +1,3 @@
-<template>
- <div v-if="hasNotebook">
- <component
- v-for="(cell, index) in cells"
- :is="cellType(cell.cell_type)"
- :cell="cell"
- :key="index"
- :code-css-class="codeCssClass" />
- </div>
-</template>
-
<script>
import {
MarkdownCell,
@@ -31,11 +20,6 @@
default: '',
},
},
- methods: {
- cellType(type) {
- return `${type}-cell`;
- },
- },
computed: {
cells() {
if (this.notebook.worksheets) {
@@ -56,9 +40,25 @@
return Object.keys(this.notebook).length;
},
},
+ methods: {
+ cellType(type) {
+ return `${type}-cell`;
+ },
+ },
};
</script>
+<template>
+ <div v-if="hasNotebook">
+ <component
+ v-for="(cell, index) in cells"
+ :is="cellType(cell.cell_type)"
+ :cell="cell"
+ :key="index"
+ :code-css-class="codeCssClass" />
+ </div>
+</template>
+
<style>
.cell,
.input,
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index b21d7774920..b0573510ff9 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -5,25 +5,40 @@ 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, class-methods-use-this */
-/* global Flash */
-/* global Autosave */
+
/* global ResolveService */
/* global mrRefreshWidgetUrl */
import $ from 'jquery';
+import _ from 'underscore';
import Cookies from 'js-cookie';
-import autosize from 'vendor/autosize';
-import Dropzone from 'dropzone';
+import Autosize from 'autosize';
import 'vendor/jquery.caret'; // required by jquery.atwho
import 'vendor/jquery.atwho';
import AjaxCache from '~/lib/utils/ajax_cache';
+import Vue from 'vue';
+import syntaxHighlight from '~/syntax_highlight';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import { __ } from '~/locale';
+import axios from './lib/utils/axios_utils';
+import { getLocationHash } from './lib/utils/url_utility';
+import Flash from './flash';
import CommentTypeToggle from './comment_type_toggle';
-import './autosave';
-import './dropzone_input';
-import './task_list';
-
-window.autosize = autosize;
-window.Dropzone = Dropzone;
+import GLForm from './gl_form';
+import loadAwardsHandler from './awards_handler';
+import Autosave from './autosave';
+import TaskList from './task_list';
+import {
+ isInViewport,
+ getPagePath,
+ scrollToElement,
+ isMetaKey,
+ hasVueMRDiscussionsCookie,
+} from './lib/utils/common_utils';
+import imageDiffHelper from './image_diff/helpers/index';
+import { localTimeAgo } from './lib/utils/datetime_utility';
+
+window.autosize = Autosize;
function normalizeNewlines(str) {
return str.replace(/\r\n/g, '\n');
@@ -33,12 +48,35 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export default class Notes {
+ static initialize(
+ notes_url,
+ note_ids,
+ last_fetched_at,
+ view,
+ enableGFM = true,
+ ) {
+ if (!this.instance) {
+ this.instance = new Notes(
+ notes_url,
+ note_ids,
+ last_fetched_at,
+ view,
+ enableGFM,
+ );
+ }
+ }
+
+ static getInstance() {
+ return this.instance;
+ }
+
constructor(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);
this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this);
this.onAddDiffNote = this.onAddDiffNote.bind(this);
+ this.onAddImageDiffNote = this.onAddImageDiffNote.bind(this);
this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this);
this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this);
this.removeNote = this.removeNote.bind(this);
@@ -62,26 +100,39 @@ export default class Notes {
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;
+ this.$wrapperEl = hasVueMRDiscussionsCookie()
+ ? $(document).find('.diffs')
+ : $(document);
this.cleanBinding();
this.addBinding();
this.setPollingInterval();
this.setupMainTargetNoteForm();
- this.taskList = new gl.TaskList({
+ this.taskList = new TaskList({
dataType: 'note',
fieldName: 'note',
- selector: '.notes'
+ selector: '.notes',
});
this.collapseLongCommitList();
this.setViewType(view);
// We are in the Merge Requests page so we need another edit form for Changes tab
- if (gl.utils.getPagePath(1) === 'merge_requests') {
- $('.note-edit-form').clone()
- .addClass('mr-note-edit-form').insertAfter('.note-edit-form');
+ if (getPagePath(1) === 'merge_requests') {
+ $('.note-edit-form')
+ .clone()
+ .addClass('mr-note-edit-form')
+ .insertAfter('.note-edit-form');
+ }
+
+ const hash = getLocationHash();
+ const $anchor = hash && document.getElementById(hash);
+
+ if ($anchor) {
+ this.loadLazyDiff({ currentTarget: $anchor });
}
}
@@ -91,71 +142,126 @@ export default class Notes {
addBinding() {
// Edit note link
- $(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
- $(document).on('click', '.note-edit-cancel', this.cancelEdit);
+ this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
+ this.$wrapperEl.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);
+ this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
+ this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
+ this.$wrapperEl.on(
+ 'keyup input',
+ '.js-note-text',
+ this.updateTargetButtons,
+ );
// resolve a discussion
- $(document).on('click', '.js-comment-resolve-button', this.postComment);
+ this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
- $(document).on('click', '.js-note-delete', this.removeNote);
+ this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment
- $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-note-attachment-delete',
+ this.removeAttachment,
+ );
// reset main target form when clicking discard
- $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
+ this.$wrapperEl.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);
+ this.$wrapperEl.on(
+ 'change',
+ '.js-note-attachment-input',
+ this.updateFormAttachment,
+ );
// reply to diff/discussion notes
- $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-discussion-reply-button',
+ this.onReplyToDiscussionNote,
+ );
// add diff note
- $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
+ this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote);
+ // add diff note for images
+ this.$wrapperEl.on(
+ 'click',
+ '.js-add-image-diff-note-button',
+ this.onAddImageDiffNote,
+ );
// hide diff note form
- $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-close-discussion-note-form',
+ this.cancelDiscussionForm,
+ );
// toggle commit list
- $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
+ this.$wrapperEl.on(
+ 'click',
+ '.system-note-commit-list-toggler',
+ this.toggleCommitList,
+ );
+
+ this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff);
// fetch notes when tab becomes visible
- $(document).on('visibilitychange', this.visibilityChange);
+ this.$wrapperEl.on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
- $(document).on('issuable:change', this.refresh);
+ this.$wrapperEl.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);
+ this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote);
+ this.$wrapperEl.on(
+ 'ajax:success',
+ '.js-discussion-note-form',
+ this.addDiscussionNote,
+ );
+ this.$wrapperEl.on(
+ 'ajax:success',
+ '.js-main-target-form',
+ this.resetMainTargetForm,
+ );
+ this.$wrapperEl.on(
+ 'ajax:complete',
+ '.js-main-target-form',
+ this.reenableTargetFormSubmitButton,
+ );
// when a key is clicked on the notes
- $(document).on('keydown', '.js-note-text', this.keydownNoteText);
+ this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText);
// When the URL fragment/hash has changed, `#note_xxx`
- return $(window).on('hashchange', this.onHashChange);
+ $(window).on('hashchange', this.onHashChange);
+ this.boundGetContent = this.getContent.bind(this);
+ document.addEventListener('refreshLegacyNotes', this.boundGetContent);
}
cleanBinding() {
- $(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');
+ this.$wrapperEl.off('click', '.js-note-edit');
+ this.$wrapperEl.off('click', '.note-edit-cancel');
+ this.$wrapperEl.off('click', '.js-note-delete');
+ this.$wrapperEl.off('click', '.js-note-attachment-delete');
+ this.$wrapperEl.off('click', '.js-discussion-reply-button');
+ this.$wrapperEl.off('click', '.js-add-diff-note-button');
+ this.$wrapperEl.off('click', '.js-add-image-diff-note-button');
+ this.$wrapperEl.off('visibilitychange');
+ this.$wrapperEl.off('keyup input', '.js-note-text');
+ this.$wrapperEl.off('click', '.js-note-target-reopen');
+ this.$wrapperEl.off('click', '.js-note-target-close');
+ this.$wrapperEl.off('click', '.js-note-discard');
+ this.$wrapperEl.off('keydown', '.js-note-text');
+ this.$wrapperEl.off('click', '.js-comment-resolve-button');
+ this.$wrapperEl.off('click', '.system-note-commit-list-toggler');
+ this.$wrapperEl.off('click', '.js-toggle-lazy-diff');
+ this.$wrapperEl.off('ajax:success', '.js-main-target-form');
+ this.$wrapperEl.off('ajax:success', '.js-discussion-note-form');
+ this.$wrapperEl.off('ajax:complete', '.js-main-target-form');
+ document.removeEventListener('refreshLegacyNotes', this.boundGetContent);
$(window).off('hashchange', this.onHashChange);
}
static initCommentTypeToggle(form) {
- const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle');
- const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu');
+ const dropdownTrigger = form.querySelector(
+ '.js-comment-type-dropdown .dropdown-toggle',
+ );
+ const dropdownList = form.querySelector(
+ '.js-comment-type-dropdown .dropdown-menu',
+ );
const noteTypeInput = form.querySelector('#note_type');
- const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button');
+ const submitButton = form.querySelector(
+ '.js-comment-type-dropdown .js-comment-submit-button',
+ );
const closeButton = form.querySelector('.js-note-target-close');
const reopenButton = form.querySelector('.js-note-target-reopen');
@@ -172,8 +278,14 @@ export default class Notes {
}
keydownNoteText(e) {
- var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
- if (gl.utils.isMetaKey(e)) {
+ var $textarea,
+ discussionNoteForm,
+ editNote,
+ myLastNote,
+ myLastNoteEditBtn,
+ newText,
+ originalText;
+ if (isMetaKey(e)) {
return;
}
@@ -184,7 +296,12 @@ export default class Notes {
if ($textarea.val() !== '') {
return;
}
- myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes'));
+ myLastNote = $(
+ `li.note[data-author-id='${
+ gon.current_user_id
+ }'][data-editable]:last`,
+ $textarea.closest('.note, .notes_holder, #notes'),
+ );
if (myLastNote.length) {
myLastNoteEditBtn = myLastNote.find('.js-note-edit');
return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
@@ -195,7 +312,9 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!confirm('Are you sure you want to cancel creating this comment?')) {
+ if (
+ !confirm('Are you sure you want to cancel creating this comment?')
+ ) {
return;
}
}
@@ -204,10 +323,12 @@ export default class Notes {
}
editNote = $textarea.closest('.note');
if (editNote.length) {
- originalText = $textarea.closest('form').data('original-note');
+ originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!confirm('Are you sure you want to cancel editing this comment?')) {
+ if (
+ !confirm('Are you sure you want to cancel editing this comment?')
+ ) {
return;
}
}
@@ -220,11 +341,14 @@ export default class Notes {
if (Notes.interval) {
clearInterval(Notes.interval);
}
- return Notes.interval = setInterval((function(_this) {
- return function() {
- return _this.refresh();
- };
- })(this), this.pollingInterval);
+ return (Notes.interval = setInterval(
+ (function(_this) {
+ return function() {
+ return _this.refresh();
+ };
+ })(this),
+ this.pollingInterval,
+ ));
}
refresh() {
@@ -237,27 +361,26 @@ export default class Notes {
if (this.refreshing) {
return;
}
+
this.refreshing = true;
- return $.ajax({
- url: this.notes_url,
- headers: { 'X-Last-Fetched-At': this.last_fetched_at },
- dataType: 'json',
- success: (function(_this) {
- return function(data) {
- var notes;
- notes = data.notes;
- _this.last_fetched_at = data.last_fetched_at;
- _this.setPollingInterval(data.notes.length);
- return $.each(notes, function(i, note) {
- _this.renderNote(note);
- });
- };
- })(this)
- }).always((function(_this) {
- return function() {
- return _this.refreshing = false;
- };
- })(this));
+
+ axios
+ .get(`${this.notes_url}?html=true`, {
+ headers: {
+ 'X-Last-Fetched-At': this.last_fetched_at,
+ },
+ })
+ .then(({ data }) => {
+ const notes = data.notes;
+ this.last_fetched_at = data.last_fetched_at;
+ this.setPollingInterval(data.notes.length);
+ $.each(notes, (i, note) => this.renderNote(note));
+
+ this.refreshing = false;
+ })
+ .catch(() => {
+ this.refreshing = false;
+ });
}
/**
@@ -273,7 +396,8 @@ export default class Notes {
if (shouldReset == null) {
shouldReset = true;
}
- nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ nthInterval =
+ this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
if (shouldReset) {
this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) {
@@ -291,15 +415,25 @@ export default class Notes {
if ('emoji_award' in noteEntity.commands_changes) {
votesBlock = $('.js-awards-block').eq(0);
- gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
- return gl.awardsHandler.scrollToAwards();
+
+ loadAwardsHandler()
+ .then(awardsHandler => {
+ awardsHandler.addAwardToEmojiBar(
+ votesBlock,
+ noteEntity.commands_changes.emoji_award,
+ );
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ // ignore
+ });
}
}
}
setupNewNote($note) {
// Update datetime format on the recent note
- gl.utils.localTimeAgo($note.find('.js-timeago'), false);
+ localTimeAgo($note.find('.js-timeago'), false);
this.collapseLongCommitList();
this.taskList.init();
@@ -319,7 +453,7 @@ export default class Notes {
}
static updateNoteTargetSelector($note) {
- const hash = gl.utils.getLocationHash();
+ const hash = getLocationHash();
// Needs to be an explicit true/false for the jQuery `toggleClass(force)`
const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0);
$note.toggleClass('target', addTargetClass);
@@ -336,8 +470,18 @@ export default class Notes {
}
if (!noteEntity.valid) {
- if (noteEntity.errors.commands_only) {
- this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
+ if (noteEntity.errors && noteEntity.errors.commands_only) {
+ if (
+ noteEntity.commands_changes &&
+ Object.keys(noteEntity.commands_changes).length > 0
+ ) {
+ $notesList.find('.system-note.being-posted').remove();
+ }
+ this.addFlash(
+ noteEntity.errors.commands_only,
+ 'notice',
+ this.parentTimeline.get(0),
+ );
this.refresh();
}
return;
@@ -345,6 +489,10 @@ export default class Notes {
const $note = $notesList.find(`#note_${noteEntity.id}`);
if (Notes.isNewNote(noteEntity, this.note_ids)) {
+ if (hasVueMRDiscussionsCookie()) {
+ return;
+ }
+
this.note_ids.push(noteEntity.id);
if ($notesList.length) {
@@ -355,32 +503,36 @@ export default class Notes {
this.setupNewNote($newNote);
this.refresh();
return this.updateNotesCount(1);
- }
- // The server can send the same update multiple times so we need to make sure to only update once per actual update.
- else if (Notes.isUpdatedNote(noteEntity, $note)) {
+ } else if (Notes.isUpdatedNote(noteEntity, $note)) {
+ // The server can send the same update multiple times so we need to make sure to only update once per actual update.
const isEditing = $note.hasClass('is-editing');
const initialContent = normalizeNewlines(
- $note.find('.original-note-content').text().trim()
+ $note
+ .find('.original-note-content')
+ .text()
+ .trim(),
);
const $textarea = $note.find('.js-note-text');
const currentContent = $textarea.val();
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
- const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote;
+ const isTextareaUntouched =
+ currentContent === initialContent ||
+ currentContent === sanitizedNoteNote;
if (isEditing && isTextareaUntouched) {
$textarea.val(noteEntity.note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
- }
- else if (isEditing && !isTextareaUntouched) {
+ } else if (isEditing && !isTextareaUntouched) {
this.putConflictEditWarningInPlace(noteEntity, $note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
- }
- else {
+ } else {
const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
this.setupNewNote($updatedNote);
}
}
+
+ Notes.refreshVueNotes();
}
isParallelView() {
@@ -388,22 +540,41 @@ export default class Notes {
}
/**
- * Render note in discussion area.
- *
- * Note: for rendering inline notes use renderDiscussionNote
+ * Render note in discussion area. To render inline notes use renderDiscussionNote.
*/
renderDiscussionNote(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer;
+
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
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.length || !noteEntity.discussion_line_code
+ ? form.closest('tr')
+ : $(`#${noteEntity.discussion_line_code}`);
+
+ if (noteEntity.on_image) {
+ row = form;
+ }
+
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
- diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
+ diffAvatarContainer = row
+ .prevAll('.line_holder')
+ .first()
+ .find('.js-avatar-container.' + lineType + '_line');
// is this the first note of discussion?
- discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
+ discussionContainer = $(
+ `.notes[data-discussion-id="${noteEntity.discussion_id}"]`,
+ );
if (!discussionContainer.length) {
discussionContainer = form.closest('.discussion').find('.notes');
}
@@ -411,49 +582,72 @@ export default class Notes {
if (noteEntity.diff_discussion_html) {
var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
- if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
+ if (
+ !this.isParallelView() ||
+ row.hasClass('js-temp-notes-holder') ||
+ noteEntity.on_image
+ ) {
// insert the note and the reply button after the temp row
row.after($discussion);
} else {
// Merge new discussion HTML in
- var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
- var contentContainerClass = '.' + $notes.closest('.notes_content')
- .attr('class')
- .split(' ')
- .join('.');
-
- row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
+ var $notes = $discussion.find(
+ `.notes[data-discussion-id="${noteEntity.discussion_id}"]`,
+ );
+ var contentContainerClass =
+ '.' +
+ $notes
+ .closest('.notes_content')
+ .attr('class')
+ .split(' ')
+ .join('.');
+
+ row
+ .find(contentContainerClass + ' .content')
+ .append($notes.closest('.content').children());
}
}
// Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-page');
- if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
- Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
+ if (
+ (page && page.indexOf('projects:merge_request') !== -1) ||
+ !noteEntity.diff_discussion_html
+ ) {
+ if (!hasVueMRDiscussionsCookie()) {
+ Notes.animateAppendNote(
+ noteEntity.discussion_html,
+ $('.main-notes-list'),
+ );
+ }
}
} else {
// append new note to all matching discussions
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
+ if (
+ typeof gl.diffNotesCompileComponents !== 'undefined' &&
+ noteEntity.discussion_resolvable
+ ) {
gl.diffNotesCompileComponents();
+
this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
}
- gl.utils.localTimeAgo($('.js-timeago'), false);
+ localTimeAgo($('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
}
getLineHolder(changesDiscussionContainer) {
- return $(changesDiscussionContainer).closest('.notes_holder')
+ return $(changesDiscussionContainer)
+ .closest('.notes_holder')
.prevAll('.line_holder')
.first()
.get(0);
}
renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
- var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
@@ -464,10 +658,6 @@ export default class Notes {
gl.diffNotesCompileComponents();
}
-
- if (commentButton.length) {
- commentButton.remove();
- }
}
/**
@@ -484,8 +674,14 @@ export default class Notes {
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-note-text')
+ .val('')
+ .trigger('input');
+ form
+ .find('.js-note-text')
+ .data('autosave')
+ .reset();
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
@@ -519,8 +715,12 @@ export default class Notes {
form.find('#note_line_code').remove();
form.find('#note_position').remove();
form.find('#note_type').val('');
+ form.find('#note_project_id').remove();
form.find('#in_reply_to_discussion_id').remove();
- form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
+ form
+ .find('.js-comment-resolve-button')
+ .closest('comment-and-resolve-btn')
+ .remove();
this.parentTimeline = form.parents('.timeline');
if (form.length) {
@@ -538,7 +738,7 @@ export default class Notes {
*/
setupNoteForm(form) {
var textarea, key;
- new gl.GLForm(form, this.enableGFM);
+ this.glForm = new GLForm(form, this.enableGFM);
textarea = form.find('.js-note-text');
key = [
'Note',
@@ -546,13 +746,14 @@ export default class Notes {
form.find('#note_noteable_id').val(),
form.find('#note_commit_id').val(),
form.find('#note_type').val(),
+ form.find('#note_project_id').val(),
form.find('#in_reply_to_discussion_id').val(),
// LegacyDiffNote
form.find('#note_line_code').val(),
// DiffNote
- form.find('#note_position').val()
+ form.find('#note_position').val(),
];
return new Autosave(textarea, key);
}
@@ -573,11 +774,17 @@ export default class Notes {
} else if ($form.hasClass('js-discussion-note-form')) {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
- return this.addFlash('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.get(0),
+ );
}
updateNoteError($parentTimeline) {
- new Flash('Your comment could not be updated! Please check your network connection and try again.');
+ new Flash(
+ 'Your comment could not be updated! Please check your network connection and try again.',
+ );
}
/**
@@ -587,9 +794,9 @@ export default class Notes {
*/
addDiscussionNote($form, note, isNewDiffComment) {
if ($form.attr('data-resolve-all') != null) {
- var projectPath = $form.data('project-path');
- var discussionId = $form.data('discussion-id');
- var mergeRequestId = $form.data('noteable-iid');
+ var projectPath = $form.data('projectPath');
+ var discussionId = $form.data('discussionId');
+ var mergeRequestId = $form.data('noteableIid');
if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
@@ -612,7 +819,6 @@ export default class Notes {
var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
$noteEntityEl = $(noteEntity.html);
- $noteEntityEl.addClass('fade-in-full');
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
@@ -627,19 +833,21 @@ export default class Notes {
}
checkContentToAllowEditing($el) {
- var initialContent = $el.find('.original-note-content').text().trim();
+ var initialContent = $el
+ .find('.original-note-content')
+ .text()
+ .trim();
var currentContent = $el.find('.js-note-text').val();
var isAllowed = true;
if (currentContent === initialContent) {
this.removeNoteEditForm($el);
- }
- else {
+ } else {
var $buttons = $el.find('.note-form-actions');
- var isWidgetVisible = gl.utils.isInViewport($el.get(0));
+ var isWidgetVisible = isInViewport($el.get(0));
if (!isWidgetVisible) {
- gl.utils.scrollToElement($el);
+ scrollToElement($el);
}
$el.find('.js-finish-edit-warning').show();
@@ -696,8 +904,7 @@ export default class Notes {
this.setupNewNote($newNote);
// Now that we have taken care of the update, clear it out
delete this.updatedNotesTrackingMap[noteId];
- }
- else {
+ } else {
$note.find('.js-finish-edit-warning').hide();
this.removeNoteEditForm($note);
}
@@ -708,7 +915,7 @@ export default class Notes {
var selector = this.getEditFormSelector($target);
var $editForm = $(selector);
- $editForm.insertBefore('.notes-form');
+ $editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide();
}
@@ -724,12 +931,15 @@ export default class Notes {
}
removeNoteEditForm($note) {
- var form = $note.find('.current-note-edit-form');
+ var form = $note.find('.diffs .current-note-edit-form');
+
$note.removeClass('is-editing');
form.removeClass('current-note-edit-form');
form.find('.js-finish-edit-warning').hide();
// Replace markdown textarea text with original note text.
- return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note'));
+ return form
+ .find('.js-note-text')
+ .val(form.find('form.edit-note').data('originalNote'));
}
/**
@@ -743,43 +953,69 @@ export default class Notes {
$note = $(e.currentTarget).closest('.note');
noteElId = $note.attr('id');
noteId = $note.attr('data-note-id');
- lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]')
+ lineHolder = $(e.currentTarget)
+ .closest('.notes[data-discussion-id]')
.closest('.notes_holder')
.prev('.line_holder');
- $(`.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.
- return function(i, el) {
- var $note, $notes;
- $note = $(el);
- $notes = $note.closest('.discussion-notes');
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- if (gl.diffNoteApps[noteElId]) {
- gl.diffNoteApps[noteElId].$destroy();
+ $(`.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.
+ return function(i, el) {
+ var $note, $notes;
+ $note = $(el);
+ $notes = $note.closest('.discussion-notes');
+ const discussionId = $('.notes', $notes).data('discussionId');
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ if (gl.diffNoteApps[noteElId]) {
+ gl.diffNoteApps[noteElId].$destroy();
+ }
}
- }
-
- $note.remove();
-
- // check if this is the last note for this line
- if ($notes.find('.note').length === 0) {
- var notesTr = $notes.closest('tr');
- // "Discussions" tab
- $notes.closest('.timeline-entry').remove();
-
- // The notes tr can contain multiple lists of notes, like on the parallel diff
- if (notesTr.find('.discussion-notes').length > 1) {
- $notes.remove();
- } else {
- notesTr.remove();
+ $note.remove();
+
+ // check if this is the last note for this line
+ if ($notes.find('.note').length === 0) {
+ var notesTr = $notes.closest('tr');
+
+ // "Discussions" tab
+ $notes.closest('.timeline-entry').remove();
+
+ $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
+
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ // notesTr does not exist for image diffs
+ if (
+ notesTr.find('.discussion-notes').length > 1 ||
+ notesTr.length === 0
+ ) {
+ const $diffFile = $notes.closest('.diff-file');
+ if ($diffFile.length > 0) {
+ const removeBadgeEvent = new CustomEvent(
+ 'removeBadge.imageDiff',
+ {
+ detail: {
+ // badgeNumber's start with 1 and index starts with 0
+ badgeNumber: $notes.index() + 1,
+ },
+ },
+ );
+
+ $diffFile[0].dispatchEvent(removeBadgeEvent);
+ }
+
+ $notes.remove();
+ } else if (notesTr.length > 0) {
+ notesTr.remove();
+ }
}
- }
- };
- })(this));
+ };
+ })(this),
+ );
+ Notes.refreshVueNotes();
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
@@ -829,6 +1065,12 @@ export default class Notes {
*/
setupDiscussionNoteForm(dataHolder, form) {
// setup note target
+ let diffFileData = dataHolder.closest('.text-file');
+
+ if (diffFileData.length === 0) {
+ diffFileData = dataHolder.closest('.image');
+ }
+
var discussionID = dataHolder.data('discussionId');
if (discussionID) {
@@ -836,12 +1078,15 @@ export default class Notes {
form.find('#in_reply_to_discussion_id').val(discussionID);
}
+ form.find('#note_project_id').val(dataHolder.data('discussionProjectId'));
+
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_noteable_type').val(diffFileData.data('noteableType'));
+ form.find('#note_noteable_id').val(diffFileData.data('noteableId'));
+ form.find('#note_commit_id').val(diffFileData.data('commitId'));
+
form.find('#note_type').val(dataHolder.data('noteType'));
// LegacyDiffNote
@@ -850,7 +1095,12 @@ export default class Notes {
// DiffNote
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-discard')
+ .show()
+ .removeClass('js-note-discard')
+ .addClass('js-close-discussion-note-form')
+ .text(form.find('.js-close-discussion-note-form').data('cancelText'));
form.find('.js-note-target-close').remove();
form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form);
@@ -886,17 +1136,48 @@ export default class Notes {
this.toggleDiffNote({
target: $link,
lineType: link.dataset.lineType,
- showReplyInput
+ showReplyInput,
});
}
- toggleDiffNote({
- target,
- lineType,
- forceShow,
- showReplyInput = false,
- }) {
- var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
+ onAddImageDiffNote(e) {
+ const $link = $(e.currentTarget || e.target);
+ const $diffFile = $link.closest('.diff-file');
+
+ const clickEvent = new CustomEvent('click.imageDiff', {
+ detail: e,
+ });
+
+ $diffFile[0].dispatchEvent(clickEvent);
+
+ // Setup comment form
+ let newForm;
+ const $noteContainer = $link
+ .closest('.diff-viewer')
+ .find('.note-container');
+ const $form = $noteContainer.find('> .discussion-form');
+
+ if ($form.length === 0) {
+ newForm = this.cleanForm(this.formClone.clone());
+ newForm.appendTo($noteContainer);
+ } else {
+ newForm = $form;
+ }
+
+ this.setupDiscussionNoteForm($link, newForm);
+ }
+
+ toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) {
+ var $link,
+ addForm,
+ hasNotes,
+ newForm,
+ noteForm,
+ replyButton,
+ row,
+ rowCssToAdd,
+ targetContent,
+ isDiffCommentAvatar;
$link = $(target);
row = $link.closest('tr');
const nextRow = row.next();
@@ -908,11 +1189,13 @@ export default class Notes {
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);
@@ -940,7 +1223,9 @@ export default class Notes {
notesContent = targetRow.find(notesContentSelector);
addForm = true;
} else {
- const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
+ const isCurrentlyShown = targetRow
+ .find('.content:not(:empty)')
+ .is(':visible');
const isForced = forceShow === true || forceShow === false;
const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
@@ -965,13 +1250,14 @@ export default class Notes {
removeDiscussionNoteForm(form) {
var glForm, row;
row = form.closest('tr');
- glForm = form.data('gl-form');
+ glForm = form.data('glForm');
glForm.destroy();
- form.find('.js-note-text').data('autosave').reset();
- // show the reply button (will only work for replies)
form
- .prev('.discussion-reply-holder')
- .show();
+ .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')) {
// remove temporary row for diff lines
return row.remove();
@@ -982,10 +1268,25 @@ export default class Notes {
}
cancelDiscussionForm(e) {
- var form;
e.preventDefault();
- form = $(e.target).closest('.js-discussion-note-form');
- return this.removeDiscussionNoteForm(form);
+ const $form = $(e.target).closest('.js-discussion-note-form');
+ const $discussionNote = $(e.target).closest('.discussion-notes');
+
+ if ($discussionNote.length === 0) {
+ // Only send blur event when the discussion form
+ // is not part of a discussion note
+ const $diffFile = $form.closest('.diff-file');
+
+ if ($diffFile.length > 0) {
+ const blurEvent = new CustomEvent('blur.imageDiff', {
+ detail: e,
+ });
+
+ $diffFile[0].dispatchEvent(blurEvent);
+ }
+ }
+
+ return this.removeDiscussionNoteForm($form);
}
/**
@@ -997,7 +1298,9 @@ export default class Notes {
var filename, form;
form = $(this).closest('form');
// get only the basename
- filename = $(this).val().replace(/^.*[\\\/]/, '');
+ filename = $(this)
+ .val()
+ .replace(/^.*[\\\/]/, '');
return form.find('.js-attachment-filename').text(filename);
}
@@ -1035,8 +1338,8 @@ export default class Notes {
return discardbtn.show();
}
} else {
- reopentext = reopenbtn.data('original-text');
- closetext = closebtn.data('original-text');
+ reopentext = reopenbtn.data('originalText');
+ closetext = closebtn.data('originalText');
if (reopenbtn.text() !== reopentext) {
reopenbtn.text(reopentext);
}
@@ -1063,18 +1366,22 @@ export default class Notes {
var $originalContentEl = $note.find('.original-note-content');
var originalContent = $originalContentEl.text().trim();
- var postUrl = $originalContentEl.data('post-url');
- var targetId = $originalContentEl.data('target-id');
- var targetType = $originalContentEl.data('target-type');
+ var postUrl = $originalContentEl.data('postUrl');
+ var targetId = $originalContentEl.data('targetId');
+ var targetType = $originalContentEl.data('targetType');
- new gl.GLForm($editForm.find('form'), this.enableGFM);
+ this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
- $editForm.find('form')
- .attr('action', postUrl)
+ $editForm
+ .find('form')
+ .attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
- $editForm.find('.js-note-text').focus().val(originalContent);
+ $editForm
+ .find('.js-note-text')
+ .focus()
+ .val(originalContent);
$editForm.find('.js-md-write-button').trigger('click');
$editForm.find('.referenced-users').hide();
}
@@ -1083,7 +1390,9 @@ export default class Notes {
if ($note.find('.js-conflict-edit-warning').length === 0) {
const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the
- <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer">
+ <a href="#note_${
+ noteEntity.id
+ }" target="_blank" rel="noopener noreferrer">
updated comment
</a>
to ensure information is not lost
@@ -1093,14 +1402,79 @@ export default class Notes {
}
updateNotesCount(updateCount) {
- return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
+ return this.notesCountBadge.text(
+ parseInt(this.notesCountBadge.text(), 10) + updateCount,
+ );
+ }
+
+ static renderPlaceholderComponent($container) {
+ const el = $container.find('.js-code-placeholder').get(0);
+ new Vue({
+ // eslint-disable-line no-new
+ el,
+ components: {
+ SkeletonLoadingContainer,
+ },
+ render(createElement) {
+ return createElement('skeleton-loading-container');
+ },
+ });
+ }
+
+ static renderDiffContent($container, data) {
+ const { discussion_html } = data;
+ const lines = $(discussion_html).find('.line_holder');
+ lines.addClass('fade-in');
+ $container.find('tbody').prepend(lines);
+ const fileHolder = $container.find('.file-holder');
+ $container.find('.line-holder-placeholder').remove();
+ syntaxHighlight(fileHolder);
+ }
+
+ static renderDiffError($container) {
+ $container.find('.line_content').html(
+ $(`
+ <div class="nothing-here-block">
+ ${__(
+ 'Unable to load the diff.',
+ )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>?
+ </div>
+ `),
+ );
+ }
+
+ loadLazyDiff(e) {
+ const $container = $(e.currentTarget).closest('.js-toggle-container');
+ Notes.renderPlaceholderComponent($container);
+
+ $container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff');
+
+ const tableEl = $container.find('tbody');
+ if (tableEl.length === 0) return;
+
+ const fileHolder = $container.find('.file-holder');
+ const url = fileHolder.data('linesPath');
+
+ axios
+ .get(url)
+ .then(({ data }) => {
+ Notes.renderDiffContent($container, data);
+ })
+ .catch(() => {
+ Notes.renderDiffError($container);
+ });
}
toggleCommitList(e) {
const $element = $(e.currentTarget);
- const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
+ const $closestSystemCommitList = $element.siblings(
+ '.system-note-commit-list',
+ );
- $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
+ $element
+ .find('.fa')
+ .toggleClass('fa-angle-down')
+ .toggleClass('fa-angle-up');
$closestSystemCommitList.toggleClass('hide-shade');
}
@@ -1110,11 +1484,17 @@ export default class Notes {
* intrusive.
*/
collapseLongCommitList() {
- const systemNotes = $('#notes-list').find('li.system-note').has('ul');
+ const systemNotes = $('#notes-list')
+ .find('li.system-note')
+ .has('ul');
$.each(systemNotes, function(index, systemNote) {
const $systemNote = $(systemNote);
- const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', '');
+ const headerMessage = $systemNote
+ .find('.note-text')
+ .find('p:first')
+ .text()
+ .replace(':', '');
$systemNote.find('.note-header .system-note-message').html(headerMessage);
@@ -1122,32 +1502,30 @@ export default class Notes {
$systemNote.find('.note-text').addClass('system-note-commit-list');
$systemNote.find('.system-note-commit-list-toggler').show();
} else {
- $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade');
+ $systemNote
+ .find('.note-text')
+ .addClass('system-note-commit-list hide-shade');
}
});
}
addFlash(...flashParams) {
- this.flashInstance = new Flash(...flashParams);
+ this.flashContainer = new Flash(...flashParams);
}
clearFlash() {
- if (this.flashInstance && this.flashInstance.flashContainer) {
- this.flashInstance.flashContainer.hide();
- this.flashInstance = null;
+ if (this.flashContainer) {
+ this.flashContainer.style.display = 'none';
+ this.flashContainer = null;
}
}
cleanForm($form) {
// Remove JS classes that are not needed here
- $form
- .find('.js-comment-type-dropdown')
- .removeClass('btn-group');
+ $form.find('.js-comment-type-dropdown').removeClass('btn-group');
// Remove dropdown
- $form
- .find('.dropdown-menu')
- .remove();
+ $form.find('.dropdown-menu').remove();
return $form;
}
@@ -1166,13 +1544,17 @@ export default class Notes {
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim());
const currentNoteText = normalizeNewlines(
- $note.find('.original-note-content').first().text().trim()
+ $note
+ .find('.original-note-content')
+ .first()
+ .text()
+ .trim(),
);
return sanitizedNoteEntityText !== currentNoteText;
}
static checkMergeRequestStatus() {
- if (gl.utils.getPagePath(1) === 'merge_requests') {
+ if (getPagePath(1) === 'merge_requests' && gl.mrWidget) {
gl.mrWidget.checkStatus();
}
}
@@ -1193,14 +1575,20 @@ export default class Notes {
return $updatedNote;
}
+ static refreshVueNotes() {
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
+ }
+
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
getFormData($form) {
+ const content = $form.find('.js-note-text').val();
return {
formData: $form.serialize(),
- formContent: _.escape($form.find('.js-note-text').val()),
+ formContent: _.escape(content),
formAction: $form.attr('action'),
+ formContentOriginal: content,
};
}
@@ -1250,22 +1638,33 @@ export default class Notes {
* Once comment is _actually_ posted on server, we will have final element
* in response that we will show in place of this temporary element.
*/
- createPlaceholderNote({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
+ createPlaceholderNote({
+ formContent,
+ uniqueId,
+ isDiscussionNote,
+ currentUsername,
+ currentUserFullname,
+ currentUserAvatar,
+ }) {
const discussionClass = isDiscussionNote ? 'discussion' : '';
const $tempNote = $(
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
- <a href="/${currentUsername}">
- <img class="avatar s40" src="${currentUserAvatar}">
+ <a href="/${_.escape(currentUsername)}">
+ <img class="avatar s40" src="${currentUserAvatar}" />
</a>
</div>
<div class="timeline-content ${discussionClass}">
<div class="note-header">
<div class="note-header-info">
- <a href="/${currentUsername}">
- <span class="hidden-xs">${currentUserFullname}</span>
- <span class="note-headline-light">@${currentUsername}</span>
+ <a href="/${_.escape(currentUsername)}">
+ <span class="hidden-xs">${_.escape(
+ currentUsername,
+ )}</span>
+ <span class="note-headline-light">${_.escape(
+ currentUsername,
+ )}</span>
</a>
</div>
</div>
@@ -1276,9 +1675,14 @@ export default class Notes {
</div>
</div>
</div>
- </li>`
+ </li>`,
);
+ $tempNote.find('.hidden-xs').text(_.escape(currentUserFullname));
+ $tempNote
+ .find('.note-headline-light')
+ .text(`@${_.escape(currentUsername)}`);
+
return $tempNote;
}
@@ -1293,7 +1697,7 @@ export default class Notes {
<i>${formContent}</i>
</div>
</div>
- </li>`
+ </li>`,
);
return $tempNote;
@@ -1307,7 +1711,7 @@ export default class Notes {
* 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
* 3) Build temporary placeholder element (using `createPlaceholderNote`)
* 4) Show placeholder note on UI
- * 5) Perform network request to submit the note using `gl.utils.ajaxPost`
+ * 5) Perform network request to submit the note using `axios.post`
* a) If request is successfully completed
* 1. Remove placeholder element
* 2. Show submitted Note element
@@ -1323,13 +1727,25 @@ export default class Notes {
// Get Form metadata
const $submitBtn = $(e.target);
+ $submitBtn.prop('disabled', true);
let $form = $submitBtn.parents('form');
const $closeBtn = $form.find('.js-note-target-close');
- const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
+ const isDiscussionNote =
+ $submitBtn
+ .parent()
+ .find('li.droplab-item-selected')
+ .attr('id') === 'discussion';
const isMainForm = $form.hasClass('js-main-target-form');
const isDiscussionForm = $form.hasClass('js-discussion-note-form');
- const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
- const { formData, formContent, formAction } = this.getFormData($form);
+ const isDiscussionResolve = $submitBtn.hasClass(
+ 'js-comment-resolve-button',
+ );
+ const {
+ formData,
+ formContent,
+ formAction,
+ formContentOriginal,
+ } = this.getFormData($form);
let noteUniqueId;
let systemNoteUniqueId;
let hasQuickActions = false;
@@ -1346,7 +1762,6 @@ export default class Notes {
// If comment is to resolve discussion, disable submit buttons while
// comment posting is finished.
if (isDiscussionResolve) {
- $submitBtn.disable();
$form.find('.js-comment-submit-button').disable();
}
@@ -1359,23 +1774,30 @@ export default class Notes {
// Show placeholder note
if (tempFormContent) {
noteUniqueId = _.uniqueId('tempNote_');
- $notesContainer.append(this.createPlaceholderNote({
- formContent: tempFormContent,
- uniqueId: noteUniqueId,
- isDiscussionNote,
- currentUsername: gon.current_username,
- currentUserFullname: gon.current_user_fullname,
- currentUserAvatar: gon.current_user_avatar_url,
- }));
+ $notesContainer.append(
+ this.createPlaceholderNote({
+ formContent: tempFormContent,
+ uniqueId: noteUniqueId,
+ isDiscussionNote,
+ currentUsername: gon.current_username,
+ currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }),
+ );
}
// Show placeholder system note
if (hasQuickActions) {
systemNoteUniqueId = _.uniqueId('tempSystemNote_');
- $notesContainer.append(this.createPlaceholderSystemNote({
- formContent: this.getQuickActionDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
- uniqueId: systemNoteUniqueId,
- }));
+ $notesContainer.append(
+ this.createPlaceholderSystemNote({
+ formContent: this.getQuickActionDescription(
+ formContent,
+ AjaxCache.get(gl.GfmAutoComplete.dataSources.commands),
+ ),
+ uniqueId: systemNoteUniqueId,
+ }),
+ );
}
// Clear the form textarea
@@ -1387,16 +1809,33 @@ export default class Notes {
}
}
+ $closeBtn.text($closeBtn.data('originalText'));
+
/* eslint-disable promise/catch-or-return */
// Make request to submit comment on server
- gl.utils.ajaxPost(formAction, formData)
- .then((note) => {
+ return axios
+ .post(`${formAction}?html=true`, formData)
+ .then(res => {
+ const note = res.data;
+
+ $submitBtn.prop('disabled', false);
// Submission successful! remove placeholder
$notesContainer.find(`#${noteUniqueId}`).remove();
+ const $diffFile = $form.closest('.diff-file');
+ if ($diffFile.length > 0) {
+ const blurEvent = new CustomEvent('blur.imageDiff', {
+ detail: e,
+ });
+
+ $diffFile[0].dispatchEvent(blurEvent);
+ }
+
// Reset cached commands list when command is applied
if (hasQuickActions) {
- $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ $form
+ .find('textarea.js-note-text')
+ .trigger('clear-commands-cache.atwho');
}
// Clear previous form errors
@@ -1410,19 +1849,45 @@ export default class Notes {
// If comment intends to resolve discussion, do the same.
if (isDiscussionResolve) {
$form
- .attr('data-discussion-id', $submitBtn.data('discussion-id'))
+ .attr('data-discussion-id', $submitBtn.data('discussionId'))
.attr('data-resolve-all', 'true')
- .attr('data-project-path', $submitBtn.data('project-path'));
+ .attr('data-project-path', $submitBtn.data('projectPath'));
}
// Show final note element on UI
- this.addDiscussionNote($form, note, $notesContainer.length === 0);
+ const isNewDiffComment = $notesContainer.length === 0;
+ this.addDiscussionNote($form, note, isNewDiffComment);
+
+ if (isNewDiffComment) {
+ // Add image badge, avatar badge and toggle discussion badge for new image diffs
+ const notePosition = $form.find('#note_position').val();
+ if ($diffFile.length > 0 && notePosition.length > 0) {
+ const { x, y, width, height } = JSON.parse(notePosition);
+ const addBadgeEvent = new CustomEvent('addBadge.imageDiff', {
+ detail: {
+ x,
+ y,
+ width,
+ height,
+ noteId: `note_${note.id}`,
+ discussionId: note.discussion_id,
+ },
+ });
+
+ $diffFile[0].dispatchEvent(addBadgeEvent);
+ }
+ }
// append flash-container to the Notes list
if ($notesContainer.length) {
- $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
+ $notesContainer.append(
+ '<div class="flash-container" style="display: none;"></div>',
+ );
}
- } else if (isMainForm) { // Check if this was main thread comment
+
+ Notes.refreshVueNotes();
+ } else if (isMainForm) {
+ // Check if this was main thread comment
// Show final note element on UI and perform form and action buttons cleanup
this.addNote($form, note);
this.reenableTargetFormSubmitButton(e);
@@ -1433,9 +1898,20 @@ export default class Notes {
}
$form.trigger('ajax:success', [note]);
- }).fail(() => {
+ })
+ .catch(() => {
// Submission failed, remove placeholder note and show Flash error message
$notesContainer.find(`#${noteUniqueId}`).remove();
+ $submitBtn.prop('disabled', false);
+ const blurEvent = new CustomEvent('blur.imageDiff', {
+ detail: e,
+ });
+
+ const closestDiffFile = $form.closest('.diff-file');
+
+ if (closestDiffFile.length) {
+ closestDiffFile[0].dispatchEvent(blurEvent);
+ }
if (hasQuickActions) {
$notesContainer.find(`#${systemNoteUniqueId}`).remove();
@@ -1443,17 +1919,17 @@ export default class Notes {
// Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) {
- const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
+ const replyButton = $notesContainer
+ .parent()
+ .find('.js-discussion-reply-button');
this.replyToDiscussionNote(replyButton[0]);
$form = $notesContainer.parent().find('form');
}
- $form.find('.js-note-text').val(formContent);
+ $form.find('.js-note-text').val(formContentOriginal);
this.reenableTargetFormSubmitButton(e);
this.addNoteError($form);
});
-
- return $closeBtn.text($closeBtn.data('original-text'));
}
/**
@@ -1462,7 +1938,7 @@ export default class Notes {
*
* 1) Get Form metadata
* 2) Update note element with new content
- * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
+ * 3) Perform network request to submit the updated note using `axios.post`
* a) If request is successfully completed
* 1. Show submitted Note element
* b) If request failed
@@ -1480,23 +1956,32 @@ export default class Notes {
const $noteBody = $editingNote.find('.js-task-list-container');
const $noteBodyText = $noteBody.find('.note-text');
const { formData, formContent, formAction } = this.getFormData($form);
+ const $diffFile = $form.closest('.diff-file');
+ const $notesContainer = $form.closest('.notes');
// Cache original comment content
const cachedNoteBodyText = $noteBodyText.html();
// Show updated comment content temporarily
- $noteBodyText.html(_.escape(formContent));
- $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
- $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>');
+ $noteBodyText.html(formContent);
+ $editingNote
+ .removeClass('is-editing fade-in-full')
+ .addClass('being-posted fade-in-half');
+ $editingNote
+ .find('.note-headline-meta a')
+ .html(
+ '<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>',
+ );
/* eslint-disable promise/catch-or-return */
// Make request to update comment on server
- gl.utils.ajaxPost(formAction, formData)
- .then((note) => {
+ axios
+ .post(`${formAction}?html=true`, formData)
+ .then(({ data }) => {
// Submission successful! render final note element
- this.updateNote(note, $editingNote);
+ this.updateNote(data, $editingNote);
})
- .fail(() => {
+ .catch(() => {
// Submission failed, revert back to original note
$noteBodyText.html(_.escape(cachedNoteBodyText));
$editingNote.removeClass('being-posted fade-in');
@@ -1506,7 +1991,7 @@ export default class Notes {
this.updateNoteError();
});
- return $closeBtn.text($closeBtn.data('original-text'));
+ return $closeBtn.text($closeBtn.data('originalText'));
}
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
new file mode 100644
index 00000000000..90dcafd75b7
--- /dev/null
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -0,0 +1,459 @@
+<script>
+import $ from 'jquery';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import _ from 'underscore';
+import Autosize from 'autosize';
+import { __, sprintf } from '~/locale';
+import Flash from '../../flash';
+import Autosave from '../../autosave';
+import TaskList from '../../task_list';
+import {
+ capitalizeFirstCharacter,
+ convertToCamelCase,
+} from '../../lib/utils/text_utility';
+import * as constants from '../constants';
+import eventHub from '../event_hub';
+import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
+import markdownField from '../../vue_shared/components/markdown/field.vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import loadingButton from '../../vue_shared/components/loading_button.vue';
+import noteSignedOutWidget from './note_signed_out_widget.vue';
+import discussionLockedWidget from './discussion_locked_widget.vue';
+import issuableStateMixin from '../mixins/issuable_state';
+
+export default {
+ name: 'CommentForm',
+ components: {
+ issueWarning,
+ noteSignedOutWidget,
+ discussionLockedWidget,
+ markdownField,
+ userAvatarLink,
+ loadingButton,
+ },
+ mixins: [issuableStateMixin],
+ props: {
+ noteableType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ note: '',
+ noteType: constants.COMMENT,
+ isSubmitting: false,
+ isSubmitButtonDisabled: true,
+ };
+ },
+ computed: {
+ ...mapGetters([
+ 'getCurrentUserLastNote',
+ 'getUserData',
+ 'getNoteableData',
+ 'getNotesData',
+ 'openState',
+ ]),
+ ...mapState(['isToggleStateButtonLoading']),
+ noteableDisplayName() {
+ return this.noteableType.replace(/_/g, ' ');
+ },
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ commentButtonTitle() {
+ return this.noteType === constants.COMMENT
+ ? 'Comment'
+ : 'Start discussion';
+ },
+ isOpen() {
+ return (
+ this.openState === constants.OPENED ||
+ this.openState === constants.REOPENED
+ );
+ },
+ canCreateNote() {
+ return this.getNoteableData.current_user.can_create_note;
+ },
+ issueActionButtonTitle() {
+ const openOrClose = this.isOpen ? 'close' : 'reopen';
+
+ if (this.note.length) {
+ return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), {
+ actionText: this.commentButtonTitle,
+ openOrClose,
+ noteable: this.noteableDisplayName,
+ });
+ }
+
+ return sprintf(__('%{openOrClose} %{noteable}'), {
+ openOrClose: capitalizeFirstCharacter(openOrClose),
+ noteable: this.noteableDisplayName,
+ });
+ },
+ actionButtonClassNames() {
+ return {
+ 'btn-reopen': !this.isOpen,
+ 'btn-close': this.isOpen,
+ 'js-note-target-close': this.isOpen,
+ 'js-note-target-reopen': !this.isOpen,
+ };
+ },
+ markdownDocsPath() {
+ return this.getNotesData.markdownDocsPath;
+ },
+ quickActionsDocsPath() {
+ return this.getNotesData.quickActionsDocsPath;
+ },
+ markdownPreviewPath() {
+ return this.getNoteableData.preview_note_path;
+ },
+ author() {
+ return this.getUserData;
+ },
+ canUpdateIssue() {
+ return this.getNoteableData.current_user.can_update;
+ },
+ endpoint() {
+ return this.getNoteableData.create_note_path;
+ },
+ },
+ watch: {
+ note(newNote) {
+ this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
+ },
+ isSubmitting(newValue) {
+ this.setIsSubmitButtonDisabled(this.note, newValue);
+ },
+ },
+ mounted() {
+ // jQuery is needed here because it is a custom event being dispatched with jQuery.
+ $(document).on('issuable:change', (e, isClosed) => {
+ this.toggleIssueLocalState(
+ isClosed ? constants.CLOSED : constants.REOPENED,
+ );
+ });
+
+ this.initAutoSave();
+ this.initTaskList();
+ },
+ methods: {
+ ...mapActions([
+ 'saveNote',
+ 'stopPolling',
+ 'restartPolling',
+ 'removePlaceholderNotes',
+ 'closeIssue',
+ 'reopenIssue',
+ 'toggleIssueLocalState',
+ 'toggleStateButtonLoading',
+ ]),
+ setIsSubmitButtonDisabled(note, isSubmitting) {
+ if (!_.isEmpty(note) && !isSubmitting) {
+ this.isSubmitButtonDisabled = false;
+ } else {
+ this.isSubmitButtonDisabled = true;
+ }
+ },
+ handleSave(withIssueAction) {
+ this.isSubmitting = true;
+
+ if (this.note.length) {
+ const noteData = {
+ endpoint: this.endpoint,
+ flashContainer: this.$el,
+ data: {
+ note: {
+ noteable_type: this.noteableType,
+ noteable_id: this.getNoteableData.id,
+ note: this.note,
+ },
+ },
+ };
+
+ if (this.noteType === constants.DISCUSSION) {
+ noteData.data.note.type = constants.DISCUSSION_NOTE;
+ }
+
+ this.note = ''; // Empty textarea while being requested. Repopulate in catch
+ this.resizeTextarea();
+ this.stopPolling();
+
+ this.saveNote(noteData)
+ .then(res => {
+ this.enableButton();
+ this.restartPolling();
+
+ if (res.errors) {
+ if (res.errors.commands_only) {
+ this.discard();
+ } else {
+ Flash(
+ 'Something went wrong while adding your comment. Please try again.',
+ 'alert',
+ this.$refs.commentForm,
+ );
+ }
+ } else {
+ this.discard();
+ }
+
+ if (withIssueAction) {
+ this.toggleIssueState();
+ }
+ })
+ .catch(() => {
+ this.enableButton();
+ this.discard(false);
+ const msg = `Your comment could not be submitted!
+Please check your network connection and try again.`;
+ Flash(msg, 'alert', this.$el);
+ this.note = noteData.data.note.note; // Restore textarea content.
+ this.removePlaceholderNotes();
+ });
+ } else {
+ this.toggleIssueState();
+ }
+ },
+ enableButton() {
+ this.isSubmitting = false;
+ },
+ toggleIssueState() {
+ if (this.isOpen) {
+ this.closeIssue()
+ .then(() => this.enableButton())
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __(
+ 'Something went wrong while closing the %{issuable}. Please try again later',
+ ),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ } else {
+ this.reopenIssue()
+ .then(() => this.enableButton())
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __(
+ 'Something went wrong while reopening the %{issuable}. Please try again later',
+ ),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ }
+ },
+ discard(shouldClear = true) {
+ // `blur` is needed to clear slash commands autocomplete cache if event fired.
+ // `focus` is needed to remain cursor in the textarea.
+ this.$refs.textarea.blur();
+ this.$refs.textarea.focus();
+
+ if (shouldClear) {
+ this.note = '';
+ this.resizeTextarea();
+ this.$refs.markdownField.previewMarkdown = false;
+ }
+
+ this.autosave.reset();
+ },
+ setNoteType(type) {
+ this.noteType = type;
+ },
+ editCurrentUserLastNote() {
+ if (this.note === '') {
+ const lastNote = this.getCurrentUserLastNote;
+
+ if (lastNote) {
+ eventHub.$emit('enterEditMode', {
+ noteId: lastNote.id,
+ });
+ }
+ }
+ },
+ initAutoSave() {
+ if (this.isLoggedIn) {
+ const noteableType = capitalizeFirstCharacter(
+ convertToCamelCase(this.noteableType),
+ );
+
+ this.autosave = new Autosave($(this.$refs.textarea), [
+ 'Note',
+ noteableType,
+ this.getNoteableData.id,
+ ]);
+ }
+ },
+ initTaskList() {
+ return new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes',
+ });
+ },
+ resizeTextarea() {
+ this.$nextTick(() => {
+ Autosize.update(this.$refs.textarea);
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <note-signed-out-widget v-if="!isLoggedIn" />
+ <discussion-locked-widget
+ issuable-type="issue"
+ v-else-if="!canCreateNote"
+ />
+ <ul
+ v-else
+ class="notes notes-form timeline">
+ <li class="timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="flash-container error-alert timeline-content"></div>
+ <div class="timeline-icon hidden-xs hidden-sm">
+ <user-avatar-link
+ v-if="author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content timeline-content-form">
+ <form
+ ref="commentForm"
+ class="new-note common-note-form gfm-form js-main-target-form"
+ >
+
+ <div class="error-alert"></div>
+
+ <issue-warning
+ v-if="hasWarning(getNoteableData)"
+ :is-locked="isLocked(getNoteableData)"
+ :is-confidential="isConfidential(getNoteableData)"
+ />
+
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :add-spacing-classes="false"
+ ref="markdownField">
+ <textarea
+ id="note-body"
+ name="note[note]"
+ class="note-textarea js-vue-comment-form
+js-gfm-input js-autosize markdown-area js-vue-textarea"
+ data-supports-quick-actions="true"
+ aria-label="Description"
+ v-model="note"
+ ref="textarea"
+ slot="textarea"
+ :disabled="isSubmitting"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.up="editCurrentUserLastNote()"
+ @keydown.meta.enter="handleSave()"
+ @keydown.ctrl.enter="handleSave()">
+ </textarea>
+ </markdown-field>
+ <div class="note-form-actions">
+ <div
+ class="pull-left btn-group
+append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
+ <button
+ @click.prevent="handleSave()"
+ :disabled="isSubmitButtonDisabled"
+ class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
+ type="submit">
+ {{ __(commentButtonTitle) }}
+ </button>
+ <button
+ :disabled="isSubmitButtonDisabled"
+ name="button"
+ type="button"
+ class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle"
+ data-toggle="dropdown"
+ aria-label="Open comment type dropdown">
+ <i
+ aria-hidden="true"
+ class="fa fa-caret-down toggle-icon">
+ </i>
+ </button>
+
+ <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
+ <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
+ <button
+ type="button"
+ class="btn btn-transparent"
+ @click.prevent="setNoteType('comment')">
+ <i
+ aria-hidden="true"
+ class="fa fa-check icon">
+ </i>
+ <div class="description">
+ <strong>Comment</strong>
+ <p>
+ Add a general comment to this {{ noteableDisplayName }}.
+ </p>
+ </div>
+ </button>
+ </li>
+ <li class="divider droplab-item-ignore"></li>
+ <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
+ <button
+ type="button"
+ class="btn btn-transparent"
+ @click.prevent="setNoteType('discussion')">
+ <i
+ aria-hidden="true"
+ class="fa fa-check icon">
+ </i>
+ <div class="description">
+ <strong>Start discussion</strong>
+ <p>
+ Discuss a specific suggestion or question.
+ </p>
+ </div>
+ </button>
+ </li>
+ </ul>
+ </div>
+
+ <loading-button
+ v-if="canUpdateIssue"
+ :loading="isToggleStateButtonLoading"
+ @click="handleSave(true)"
+ :container-class="[
+ actionButtonClassNames,
+ 'btn btn-comment btn-comment-and-close js-action-button'
+ ]"
+ :disabled="isToggleStateButtonLoading || isSubmitting"
+ :label="issueActionButtonTitle"
+ />
+
+ <button
+ type="button"
+ v-if="note.length"
+ @click="discard"
+ class="btn btn-cancel js-note-discard">
+ Discard draft
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue
new file mode 100644
index 00000000000..94d9dc69964
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_file_header.vue
@@ -0,0 +1,94 @@
+<script>
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ ClipboardButton,
+ Icon,
+ },
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ titleTag() {
+ return this.diffFile.discussionPath ? 'a' : 'span';
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-header-content">
+ <div
+ v-if="diffFile.submodule"
+ >
+ <span>
+ <icon name="archive" />
+ <strong
+ v-html="diffFile.submoduleLink"
+ class="file-title-name"
+ ></strong>
+ <clipboard-button
+ title="Copy file path to clipboard"
+ :text="diffFile.submoduleLink"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </span>
+ </div>
+ <template v-else>
+ <component
+ ref="titleWrapper"
+ :is="titleTag"
+ :href="diffFile.discussionPath"
+ >
+ <span v-html="diffFile.blobIcon"></span>
+ <span v-if="diffFile.renamedFile">
+ <strong
+ class="file-title-name has-tooltip"
+ :title="diffFile.oldPath"
+ data-container="body"
+ >
+ {{ diffFile.oldPath }}
+ </strong>
+ &rarr;
+ <strong
+ class="file-title-name has-tooltip"
+ :title="diffFile.newPath"
+ data-container="body"
+ >
+ {{ diffFile.newPath }}
+ </strong>
+ </span>
+
+ <strong
+ v-else
+ class="file-title-name has-tooltip"
+ :title="diffFile.oldPath"
+ data-container="body"
+ >
+ {{ diffFile.filePath }}
+ <span v-if="diffFile.deletedFile">
+ deleted
+ </span>
+ </strong>
+ </component>
+
+ <clipboard-button
+ title="Copy file path to clipboard"
+ :text="diffFile.filePath"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+
+ <small
+ v-if="diffFile.modeChanged"
+ ref="fileMode"
+ >
+ {{ diffFile.aMode }} → {{ diffFile.bMode }}
+ </small>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
new file mode 100644
index 00000000000..ee01ec85bbb
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -0,0 +1,101 @@
+<script>
+import $ from 'jquery';
+import syntaxHighlight from '~/syntax_highlight';
+import imageDiffHelper from '~/image_diff/helpers/index';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import DiffFileHeader from './diff_file_header.vue';
+
+export default {
+ components: {
+ DiffFileHeader,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isImageDiff() {
+ return !this.diffFile.text;
+ },
+ diffFileClass() {
+ const { text } = this.diffFile;
+ return text ? 'text-file' : 'js-image-file';
+ },
+ diffRows() {
+ return $(this.discussion.truncatedDiffLines);
+ },
+ diffFile() {
+ return convertObjectPropsToCamelCase(this.discussion.diffFile);
+ },
+ imageDiffHtml() {
+ return this.discussion.imageDiffHtml;
+ },
+ },
+ mounted() {
+ if (this.isImageDiff) {
+ const canCreateNote = false;
+ const renderCommentBadge = true;
+ imageDiffHelper.initImageDiff(
+ this.$refs.fileHolder,
+ canCreateNote,
+ renderCommentBadge,
+ );
+ } else {
+ const fileHolder = $(this.$refs.fileHolder);
+ this.$nextTick(() => {
+ syntaxHighlight(fileHolder);
+ });
+ }
+ },
+ methods: {
+ rowTag(html) {
+ return html.outerHTML ? 'tr' : 'template';
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="fileHolder"
+ class="diff-file file-holder"
+ :class="diffFileClass"
+ >
+ <div class="js-file-title file-title file-title-flex-parent">
+ <diff-file-header
+ :diff-file="diffFile"
+ />
+ </div>
+ <div
+ v-if="diffFile.text"
+ class="diff-content code js-syntax-highlight"
+ >
+ <table>
+ <component
+ :is="rowTag(html)"
+ :class="html.className"
+ v-for="(html, index) in diffRows"
+ v-html="html.outerHTML"
+ :key="index"
+ />
+ <tr class="notes_holder">
+ <td
+ class="notes_line"
+ colspan="2"
+ ></td>
+ <td class="notes_content">
+ <slot></slot>
+ </td>
+ </tr>
+ </table>
+ </div>
+ <div
+ v-else
+ >
+ <div v-html="imageDiffHtml"></div>
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
new file mode 100644
index 00000000000..d492d1cd001
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -0,0 +1,121 @@
+<script>
+import { mapGetters } from 'vuex';
+import resolveSvg from 'icons/_icon_resolve_discussion.svg';
+import resolvedSvg from 'icons/_icon_status_success_solid.svg';
+import mrIssueSvg from 'icons/_icon_mr_issue.svg';
+import nextDiscussionSvg from 'icons/_next_discussion.svg';
+import { pluralize } from '../../lib/utils/text_utility';
+import { scrollToElement } from '../../lib/utils/common_utils';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserData',
+ 'getNoteableData',
+ 'discussionCount',
+ 'unresolvedDiscussions',
+ 'resolvedDiscussionCount',
+ ]),
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ hasNextButton() {
+ return this.isLoggedIn && !this.allResolved;
+ },
+ countText() {
+ return pluralize('discussion', this.discussionCount);
+ },
+ allResolved() {
+ return this.resolvedDiscussionCount === this.discussionCount;
+ },
+ resolveAllDiscussionsIssuePath() {
+ return this.getNoteableData.create_issue_to_resolve_discussions_path;
+ },
+ firstUnresolvedDiscussionId() {
+ const item = this.unresolvedDiscussions[0] || {};
+
+ return item.id;
+ },
+ },
+ created() {
+ this.resolveSvg = resolveSvg;
+ this.resolvedSvg = resolvedSvg;
+ this.mrIssueSvg = mrIssueSvg;
+ this.nextDiscussionSvg = nextDiscussionSvg;
+ },
+ methods: {
+ jumpToFirstDiscussion() {
+ const el = document.querySelector(
+ `[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`,
+ );
+ const activeTab = window.mrTabs.currentAction;
+
+ if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.activateTab('show');
+ }
+
+ if (el) {
+ scrollToElement(el);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="line-resolve-all-container prepend-top-10">
+ <div>
+ <div
+ v-if="discussionCount > 0"
+ :class="{ 'has-next-btn': hasNextButton }"
+ class="line-resolve-all">
+ <span
+ :class="{ 'is-active': allResolved }"
+ class="line-resolve-btn is-disabled"
+ type="button">
+ <span
+ v-if="allResolved"
+ v-html="resolvedSvg"
+ ></span>
+ <span
+ v-else
+ v-html="resolveSvg"
+ ></span>
+ </span>
+ <span class=".line-resolve-text">
+ {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
+ </span>
+ </div>
+ <div
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ class="btn-group"
+ role="group">
+ <a
+ :href="resolveAllDiscussionsIssuePath"
+ v-tooltip
+ title="Resolve all discussions in new issue"
+ data-container="body"
+ class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
+ <span v-html="mrIssueSvg"></span>
+ </a>
+ </div>
+ <div
+ v-if="isLoggedIn && !allResolved"
+ class="btn-group"
+ role="group">
+ <button
+ @click="jumpToFirstDiscussion"
+ v-tooltip
+ title="Jump to first unresolved discussion"
+ data-container="body"
+ class="btn btn-default discussion-next-btn">
+ <span v-html="nextDiscussionSvg"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
new file mode 100644
index 00000000000..13283b187d1
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -0,0 +1,26 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import Issuable from '~/vue_shared/mixins/issuable';
+
+export default {
+ components: {
+ Icon,
+ },
+ mixins: [Issuable],
+};
+</script>
+
+<template>
+ <div class="disabled-comment text-center">
+ <span class="issuable-note-warning inline">
+ <icon
+ name="lock"
+ :size="16"
+ class="icon"
+ />
+ <span>
+ This {{ issuableDisplayName }} is locked. Only <b>project members</b> can comment.
+ </span>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
new file mode 100644
index 00000000000..a7e2d857013
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -0,0 +1,231 @@
+<script>
+import { mapGetters } from 'vuex';
+import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
+import emojiSmile from 'icons/_emoji_smile.svg';
+import emojiSmiley from 'icons/_emoji_smiley.svg';
+import editSvg from 'icons/_icon_pencil.svg';
+import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
+import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
+import ellipsisSvg from 'icons/_ellipsis_v.svg';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ name: 'NoteActions',
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ },
+ props: {
+ authorId: {
+ type: Number,
+ required: true,
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ accessLevel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ reportAbusePath: {
+ type: String,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ canDelete: {
+ type: Boolean,
+ required: true,
+ },
+ resolvable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isResolved: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isResolving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ resolvedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ canReportAsAbuse: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['getUserDataByProp']),
+ shouldShowActionsDropdown() {
+ return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
+ },
+ canAddAwardEmoji() {
+ return this.currentUserId;
+ },
+ isAuthoredByCurrentUser() {
+ return this.authorId === this.currentUserId;
+ },
+ currentUserId() {
+ return this.getUserDataByProp('id');
+ },
+ resolveButtonTitle() {
+ let title = 'Mark as resolved';
+
+ if (this.resolvedBy) {
+ title = `Resolved by ${this.resolvedBy.name}`;
+ }
+
+ return title;
+ },
+ },
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ this.editSvg = editSvg;
+ this.ellipsisSvg = ellipsisSvg;
+ this.resolveDiscussionSvg = resolveDiscussionSvg;
+ this.resolvedDiscussionSvg = resolvedDiscussionSvg;
+ },
+ methods: {
+ onEdit() {
+ this.$emit('handleEdit');
+ },
+ onDelete() {
+ this.$emit('handleDelete');
+ },
+ onResolve() {
+ this.$emit('handleResolve');
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="note-actions">
+ <span
+ v-if="accessLevel"
+ class="note-role user-access-role">
+ {{ accessLevel }}
+ </span>
+ <div
+ v-if="resolvable"
+ class="note-actions-item">
+ <button
+ v-tooltip
+ @click="onResolve"
+ :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
+ :title="resolveButtonTitle"
+ :aria-label="resolveButtonTitle"
+ type="button"
+ class="line-resolve-btn note-action-button">
+ <template v-if="!isResolving">
+ <div
+ v-if="isResolved"
+ v-html="resolvedDiscussionSvg"></div>
+ <div
+ v-else
+ v-html="resolveDiscussionSvg"></div>
+ </template>
+ <loading-icon
+ v-else
+ :inline="true"
+ />
+ </button>
+ </div>
+ <div
+ v-if="canAddAwardEmoji"
+ class="note-actions-item">
+ <a
+ v-tooltip
+ :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
+ class="note-action-button note-emoji-button js-add-award js-note-emoji"
+ data-position="right"
+ data-placement="bottom"
+ data-container="body"
+ href="#"
+ title="Add reaction"
+ >
+ <loading-icon :inline="true" />
+ <span
+ v-html="emojiSmiling"
+ class="link-highlight award-control-icon-neutral">
+ </span>
+ <span
+ v-html="emojiSmiley"
+ class="link-highlight award-control-icon-positive">
+ </span>
+ <span
+ v-html="emojiSmile"
+ class="link-highlight award-control-icon-super-positive">
+ </span>
+ </a>
+ </div>
+ <div
+ v-if="canEdit"
+ class="note-actions-item">
+ <button
+ @click="onEdit"
+ v-tooltip
+ type="button"
+ title="Edit comment"
+ class="note-action-button js-note-edit btn btn-transparent"
+ data-container="body"
+ data-placement="bottom">
+ <span
+ v-html="editSvg"
+ class="link-highlight">
+ </span>
+ </button>
+ </div>
+ <div
+ v-if="shouldShowActionsDropdown"
+ class="dropdown more-actions note-actions-item">
+ <button
+ v-tooltip
+ type="button"
+ title="More actions"
+ class="note-action-button more-actions-toggle btn btn-transparent"
+ data-toggle="dropdown"
+ data-container="body"
+ data-placement="bottom">
+ <span
+ class="icon"
+ v-html="ellipsisSvg">
+ </span>
+ </button>
+ <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
+ <li v-if="canReportAsAbuse">
+ <a :href="reportAbusePath">
+ Report as abuse
+ </a>
+ </li>
+ <li v-if="canEdit">
+ <button
+ @click.prevent="onDelete"
+ class="btn btn-transparent js-note-delete js-note-delete"
+ type="button">
+ <span class="text-danger">
+ Delete comment
+ </span>
+ </button>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
new file mode 100644
index 00000000000..34ecbd00c63
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -0,0 +1,39 @@
+<script>
+export default {
+ name: 'NoteAttachment',
+ props: {
+ attachment: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="note-attachment">
+ <a
+ v-if="attachment.image"
+ :href="attachment.url"
+ target="_blank"
+ rel="noopener noreferrer">
+ <img
+ :src="attachment.url"
+ class="note-image-attach"
+ />
+ </a>
+ <div class="attachment">
+ <a
+ v-if="attachment.url"
+ :href="attachment.url"
+ target="_blank"
+ rel="noopener noreferrer">
+ <i
+ class="fa fa-paperclip"
+ aria-hidden="true">
+ </i>
+ {{ attachment.filename }}
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
new file mode 100644
index 00000000000..6cb8229e268
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -0,0 +1,240 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
+import emojiSmile from 'icons/_emoji_smile.svg';
+import emojiSmiley from 'icons/_emoji_smiley.svg';
+import Flash from '../../flash';
+import { glEmojiTag } from '../../emoji';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ awards: {
+ type: Array,
+ required: true,
+ },
+ toggleAwardPath: {
+ type: String,
+ required: true,
+ },
+ noteAuthorId: {
+ type: Number,
+ required: true,
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['getUserData']),
+ // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
+ // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
+ // This method will group emojis by their name as an Object. See below.
+ // {
+ // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
+ // bar: [ { name: bar, user: user1 } ]
+ // }
+ // We need to do this otherwise we will render the same emoji over and over again.
+ groupedAwards() {
+ const awards = this.awards.reduce((acc, award) => {
+ if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
+ acc[award.name].push(award);
+ } else {
+ Object.assign(acc, { [award.name]: [award] });
+ }
+
+ return acc;
+ }, {});
+
+ const orderedAwards = {};
+ const { thumbsdown, thumbsup } = awards;
+ // Always show thumbsup and thumbsdown first
+ if (thumbsup) {
+ orderedAwards.thumbsup = thumbsup;
+ delete awards.thumbsup;
+ }
+ if (thumbsdown) {
+ orderedAwards.thumbsdown = thumbsdown;
+ delete awards.thumbsdown;
+ }
+
+ return Object.assign({}, orderedAwards, awards);
+ },
+ isAuthoredByMe() {
+ return this.noteAuthorId === this.getUserData.id;
+ },
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ },
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ },
+ methods: {
+ ...mapActions(['toggleAwardRequest']),
+ getAwardHTML(name) {
+ return glEmojiTag(name);
+ },
+ getAwardClassBindings(awardList, awardName) {
+ return {
+ active: this.hasReactionByCurrentUser(awardList),
+ disabled: !this.canInteractWithEmoji(awardList, awardName),
+ };
+ },
+ canInteractWithEmoji(awardList, awardName) {
+ let isAllowed = true;
+ const restrictedEmojis = ['thumbsup', 'thumbsdown'];
+
+ // Users can not add :+1: and :-1: to their own notes
+ if (
+ this.getUserData.id === this.noteAuthorId &&
+ restrictedEmojis.indexOf(awardName) > -1
+ ) {
+ isAllowed = false;
+ }
+
+ return this.getUserData.id && isAllowed;
+ },
+ hasReactionByCurrentUser(awardList) {
+ return awardList.filter(award => award.user.id === this.getUserData.id)
+ .length;
+ },
+ awardTitle(awardsList) {
+ const hasReactionByCurrentUser = this.hasReactionByCurrentUser(
+ awardsList,
+ );
+ const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
+ let awardList = awardsList;
+
+ // Filter myself from list if I am awarded.
+ if (hasReactionByCurrentUser) {
+ awardList = awardList.filter(
+ award => award.user.id !== this.getUserData.id,
+ );
+ }
+
+ // Get only 9-10 usernames to show in tooltip text.
+ const namesToShow = awardList
+ .slice(0, TOOLTIP_NAME_COUNT)
+ .map(award => award.user.name);
+
+ // Get the remaining list to use in `and x more` text.
+ const remainingAwardList = awardList.slice(
+ TOOLTIP_NAME_COUNT,
+ awardList.length,
+ );
+
+ // Add myself to the begining of the list so title will start with You.
+ if (hasReactionByCurrentUser) {
+ namesToShow.unshift('You');
+ }
+
+ let title = '';
+
+ // We have 10+ awarded user, join them with comma and add `and x more`.
+ if (remainingAwardList.length) {
+ title = `${namesToShow.join(', ')}, and ${
+ remainingAwardList.length
+ } more.`;
+ } else if (namesToShow.length > 1) {
+ // Join all names with comma but not the last one, it will be added with and text.
+ title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
+ // If we have more than 2 users we need an extra comma before and text.
+ title += namesToShow.length > 2 ? ',' : '';
+ title += ` and ${namesToShow.slice(-1)}`; // Append and text
+ } else {
+ // We have only 2 users so join them with and.
+ title = namesToShow.join(' and ');
+ }
+
+ return title;
+ },
+ handleAward(awardName) {
+ if (!this.isLoggedIn) {
+ return;
+ }
+
+ let parsedName;
+
+ // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
+ switch (awardName) {
+ case '100':
+ parsedName = 100;
+ break;
+ case '1234':
+ parsedName = 1234;
+ break;
+ default:
+ parsedName = awardName;
+ break;
+ }
+
+ const data = {
+ endpoint: this.toggleAwardPath,
+ noteId: this.noteId,
+ awardName: parsedName,
+ };
+
+ this.toggleAwardRequest(data).catch(() =>
+ Flash('Something went wrong on our end.'),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="note-awards">
+ <div class="awards js-awards-block">
+ <button
+ v-tooltip
+ v-for="(awardList, awardName, index) in groupedAwards"
+ :key="index"
+ :class="getAwardClassBindings(awardList, awardName)"
+ :title="awardTitle(awardList)"
+ @click="handleAward(awardName)"
+ class="btn award-control"
+ data-placement="bottom"
+ type="button">
+ <span v-html="getAwardHTML(awardName)"></span>
+ <span class="award-control-text js-counter">
+ {{ awardList.length }}
+ </span>
+ </button>
+ <div
+ v-if="isLoggedIn"
+ class="award-menu-holder">
+ <button
+ v-tooltip
+ :class="{ 'js-user-authored': isAuthoredByMe }"
+ class="award-control btn js-add-award"
+ title="Add reaction"
+ aria-label="Add reaction"
+ data-placement="bottom"
+ type="button">
+ <span
+ v-html="emojiSmiling"
+ class="award-control-icon award-control-icon-neutral">
+ </span>
+ <span
+ v-html="emojiSmiley"
+ class="award-control-icon award-control-icon-positive">
+ </span>
+ <span
+ v-html="emojiSmile"
+ class="award-control-icon award-control-icon-super-positive">
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
new file mode 100644
index 00000000000..069f94c5845
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -0,0 +1,121 @@
+<script>
+import $ from 'jquery';
+import noteEditedText from './note_edited_text.vue';
+import noteAwardsList from './note_awards_list.vue';
+import noteAttachment from './note_attachment.vue';
+import noteForm from './note_form.vue';
+import TaskList from '../../task_list';
+import autosave from '../mixins/autosave';
+
+export default {
+ components: {
+ noteEditedText,
+ noteAwardsList,
+ noteAttachment,
+ noteForm,
+ },
+ mixins: [autosave],
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ noteBody() {
+ return this.note.note;
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ this.initTaskList();
+
+ if (this.isEditing) {
+ this.initAutoSave(this.note.noteable_type);
+ }
+ },
+ updated() {
+ this.initTaskList();
+ this.renderGFM();
+
+ if (this.isEditing) {
+ if (!this.autosave) {
+ this.initAutoSave(this.note.noteable_type);
+ } else {
+ this.setAutoSave();
+ }
+ }
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['note-body']).renderGFM();
+ },
+ initTaskList() {
+ if (this.canEdit) {
+ this.taskList = new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes',
+ });
+ }
+ },
+ handleFormUpdate(note, parentElement, callback) {
+ this.$emit('handleFormUpdate', note, parentElement, callback);
+ },
+ formCancelHandler(shouldConfirm, isDirty) {
+ this.$emit('cancelFormEdition', shouldConfirm, isDirty);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{ 'js-task-list-container': canEdit }"
+ ref="note-body"
+ class="note-body">
+ <div
+ v-html="note.note_html"
+ class="note-text md"></div>
+ <note-form
+ v-if="isEditing"
+ ref="noteForm"
+ @handleFormUpdate="handleFormUpdate"
+ @cancelFormEdition="formCancelHandler"
+ :is-editing="isEditing"
+ :note-body="noteBody"
+ :note-id="note.id"
+ />
+ <textarea
+ v-if="canEdit"
+ v-model="note.note"
+ :data-update-url="note.path"
+ class="hidden js-task-list-field"></textarea>
+ <note-edited-text
+ v-if="note.last_edited_at"
+ :edited-at="note.last_edited_at"
+ :edited-by="note.last_edited_by"
+ action-text="Edited"
+ />
+ <note-awards-list
+ v-if="note.award_emoji.length"
+ :note-id="note.id"
+ :note-author-id="note.author.id"
+ :awards="note.award_emoji"
+ :toggle-award-path="note.toggle_award_path"
+ />
+ <note-attachment
+ v-if="note.attachment"
+ :attachment="note.attachment"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
new file mode 100644
index 00000000000..4ddca918495
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -0,0 +1,48 @@
+<script>
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'EditedNoteText',
+ components: {
+ timeAgoTooltip,
+ },
+ props: {
+ actionText: {
+ type: String,
+ required: true,
+ },
+ editedAt: {
+ type: String,
+ required: true,
+ },
+ editedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ className: {
+ type: String,
+ required: false,
+ default: 'edited-text',
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="className">
+ {{ actionText }}
+ <time-ago-tooltip
+ :time="editedAt"
+ tooltip-placement="bottom"
+ />
+ <template v-if="editedBy">
+ by
+ <a
+ :href="editedBy.path"
+ class="js-vue-author author_link">
+ {{ editedBy.name }}
+ </a>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
new file mode 100644
index 00000000000..c59a2e7a406
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -0,0 +1,207 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import eventHub from '../event_hub';
+import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
+import markdownField from '../../vue_shared/components/markdown/field.vue';
+import issuableStateMixin from '../mixins/issuable_state';
+import resolvable from '../mixins/resolvable';
+
+export default {
+ name: 'IssueNoteForm',
+ components: {
+ issueWarning,
+ markdownField,
+ },
+ mixins: [issuableStateMixin, resolvable],
+ props: {
+ noteBody: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ noteId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ saveButtonTitle: {
+ type: String,
+ required: false,
+ default: 'Save comment',
+ },
+ note: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isEditing: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ updatedNoteBody: this.noteBody,
+ conflictWhileEditing: false,
+ isSubmitting: false,
+ isResolving: false,
+ resolveAsThread: true,
+ };
+ },
+ computed: {
+ ...mapGetters([
+ 'getDiscussionLastNote',
+ 'getNoteableData',
+ 'getNoteableDataByProp',
+ 'getNotesDataByProp',
+ 'getUserDataByProp',
+ ]),
+ noteHash() {
+ return `#note_${this.noteId}`;
+ },
+ markdownPreviewPath() {
+ return this.getNoteableDataByProp('preview_note_path');
+ },
+ markdownDocsPath() {
+ return this.getNotesDataByProp('markdownDocsPath');
+ },
+ quickActionsDocsPath() {
+ return !this.isEditing
+ ? this.getNotesDataByProp('quickActionsDocsPath')
+ : undefined;
+ },
+ currentUserId() {
+ return this.getUserDataByProp('id');
+ },
+ isDisabled() {
+ return !this.updatedNoteBody.length || this.isSubmitting;
+ },
+ },
+ watch: {
+ noteBody() {
+ if (this.updatedNoteBody === this.noteBody) {
+ this.updatedNoteBody = this.noteBody;
+ } else {
+ this.conflictWhileEditing = true;
+ }
+ },
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ methods: {
+ ...mapActions(['toggleResolveNote']),
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
+ this.isSubmitting = true;
+
+ this.$emit(
+ 'handleFormUpdate',
+ this.updatedNoteBody,
+ this.$refs.editNoteForm,
+ () => {
+ this.isSubmitting = false;
+
+ if (shouldResolve) {
+ this.resolveHandler(beforeSubmitDiscussionState);
+ }
+ },
+ );
+ },
+ editMyLastNote() {
+ if (this.updatedNoteBody === '') {
+ const lastNoteInDiscussion = this.getDiscussionLastNote(
+ this.updatedNoteBody,
+ );
+
+ if (lastNoteInDiscussion) {
+ eventHub.$emit('enterEditMode', {
+ noteId: lastNoteInDiscussion.id,
+ });
+ }
+ }
+ },
+ cancelHandler(shouldConfirm = false) {
+ // Sends information about confirm message and if the textarea has changed
+ this.$emit(
+ 'cancelFormEdition',
+ shouldConfirm,
+ this.noteBody !== this.updatedNoteBody,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="editNoteForm"
+ class="note-edit-form current-note-edit-form">
+ <div
+ v-if="conflictWhileEditing"
+ class="js-conflict-edit-warning alert alert-danger">
+ This comment has changed since you started editing, please review the
+ <a
+ :href="noteHash"
+ target="_blank"
+ rel="noopener noreferrer">
+ updated comment
+ </a>
+ to ensure information is not lost.
+ </div>
+ <div class="flash-container timeline-content"></div>
+ <form class="edit-note common-note-form js-quick-submit gfm-form">
+
+ <issue-warning
+ v-if="hasWarning(getNoteableData)"
+ :is-locked="isLocked(getNoteableData)"
+ :is-confidential="isConfidential(getNoteableData)"
+ />
+
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :add-spacing-classes="false">
+ <textarea
+ id="note_note"
+ name="note[note]"
+ class="note-textarea js-gfm-input
+js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
+ :data-supports-quick-actions="!isEditing"
+ aria-label="Description"
+ v-model="updatedNoteBody"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.meta.enter="handleUpdate()"
+ @keydown.ctrl.enter="handleUpdate()"
+ @keydown.up="editMyLastNote()"
+ @keydown.esc="cancelHandler(true)">
+ </textarea>
+ </markdown-field>
+ <div class="note-form-actions clearfix">
+ <button
+ type="button"
+ @click="handleUpdate()"
+ :disabled="isDisabled"
+ class="js-vue-issue-save btn btn-save">
+ {{ saveButtonTitle }}
+ </button>
+ <button
+ v-if="note.resolvable"
+ @click.prevent="handleUpdate(true)"
+ class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
+ >
+ {{ resolveButtonTitle }}
+ </button>
+ <button
+ @click="cancelHandler()"
+ class="btn btn-cancel note-edit-cancel"
+ type="button">
+ Cancel
+ </button>
+ </div>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
new file mode 100644
index 00000000000..c3d1ef1fcc6
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -0,0 +1,114 @@
+<script>
+import { mapActions } from 'vuex';
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ timeAgoTooltip,
+ },
+ props: {
+ author: {
+ type: Object,
+ required: true,
+ },
+ createdAt: {
+ type: String,
+ required: true,
+ },
+ actionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ actionTextHtml: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ includeToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ toggleChevronClass() {
+ return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
+ },
+ noteTimestampLink() {
+ return `#note_${this.noteId}`;
+ },
+ },
+ methods: {
+ ...mapActions(['setTargetNoteHash']),
+ handleToggle() {
+ this.$emit('toggleHandler');
+ },
+ updateTargetNoteHash() {
+ this.setTargetNoteHash(this.noteTimestampLink);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="note-header-info">
+ <a :href="author.path">
+ <span class="note-header-author-name">{{ author.name }}</span>
+ <span class="note-headline-light">
+ @{{ author.username }}
+ </span>
+ </a>
+ <span class="note-headline-light">
+ <span class="note-headline-meta">
+ <template v-if="actionText">
+ {{ actionText }}
+ </template>
+ <span
+ v-if="actionTextHtml"
+ v-html="actionTextHtml"
+ class="system-note-message">
+ </span>
+ <a
+ :href="noteTimestampLink"
+ @click="updateTargetNoteHash"
+ class="note-timestamp">
+ <time-ago-tooltip
+ :time="createdAt"
+ tooltip-placement="bottom"
+ />
+ </a>
+ <i
+ class="fa fa-spinner fa-spin editing-spinner"
+ aria-label="Comment is being updated"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </span>
+ <div
+ v-if="includeToggle"
+ class="discussion-actions">
+ <button
+ @click="handleToggle"
+ class="note-action-button discussion-toggle-button js-vue-toggle-button"
+ type="button">
+ <i
+ :class="toggleChevronClass"
+ class="fa"
+ aria-hidden="true">
+ </i>
+ Toggle discussion
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
new file mode 100644
index 00000000000..91f7c269757
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
@@ -0,0 +1,25 @@
+<script>
+import { mapGetters } from 'vuex';
+
+export default {
+ computed: {
+ ...mapGetters(['getNotesDataByProp']),
+ registerLink() {
+ return this.getNotesDataByProp('registerPath');
+ },
+ signInLink() {
+ return this.getNotesDataByProp('newSessionPath');
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="disabled-comment text-center">
+ Please
+ <a :href="registerLink">register</a>
+ or
+ <a :href="signInLink">sign in</a>
+ to reply
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
new file mode 100644
index 00000000000..cf579c5d4dc
--- /dev/null
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -0,0 +1,346 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
+import nextDiscussionsSvg from 'icons/_next_discussion.svg';
+import Flash from '../../flash';
+import { SYSTEM_NOTE } from '../constants';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import noteableNote from './noteable_note.vue';
+import noteHeader from './note_header.vue';
+import noteSignedOutWidget from './note_signed_out_widget.vue';
+import noteEditedText from './note_edited_text.vue';
+import noteForm from './note_form.vue';
+import diffWithNote from './diff_with_note.vue';
+import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
+import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import autosave from '../mixins/autosave';
+import noteable from '../mixins/noteable';
+import resolvable from '../mixins/resolvable';
+import tooltip from '../../vue_shared/directives/tooltip';
+import { scrollToElement } from '../../lib/utils/common_utils';
+
+export default {
+ components: {
+ noteableNote,
+ diffWithNote,
+ userAvatarLink,
+ noteHeader,
+ noteSignedOutWidget,
+ noteEditedText,
+ noteForm,
+ placeholderNote,
+ placeholderSystemNote,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [autosave, noteable, resolvable],
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isReplying: false,
+ isResolving: false,
+ resolveAsThread: true,
+ };
+ },
+ computed: {
+ ...mapGetters([
+ 'getNoteableData',
+ 'discussionCount',
+ 'resolvedDiscussionCount',
+ 'unresolvedDiscussions',
+ ]),
+ discussion() {
+ return {
+ ...this.note.notes[0],
+ truncatedDiffLines: this.note.truncated_diff_lines,
+ diffFile: this.note.diff_file,
+ diffDiscussion: this.note.diff_discussion,
+ imageDiffHtml: this.note.image_diff_html,
+ };
+ },
+ author() {
+ return this.discussion.author;
+ },
+ canReply() {
+ return this.getNoteableData.current_user.can_create_note;
+ },
+ newNotePath() {
+ return this.getNoteableData.create_note_path;
+ },
+ lastUpdatedBy() {
+ const { notes } = this.note;
+
+ if (notes.length > 1) {
+ return notes[notes.length - 1].author;
+ }
+
+ return null;
+ },
+ lastUpdatedAt() {
+ const { notes } = this.note;
+
+ if (notes.length > 1) {
+ return notes[notes.length - 1].created_at;
+ }
+
+ return null;
+ },
+ hasUnresolvedDiscussion() {
+ return this.unresolvedDiscussions.length > 0;
+ },
+ wrapperComponent() {
+ return this.discussion.diffDiscussion && this.discussion.diffFile
+ ? diffWithNote
+ : 'div';
+ },
+ wrapperClass() {
+ return this.isDiffDiscussion ? '' : 'panel panel-default';
+ },
+ },
+ mounted() {
+ if (this.isReplying) {
+ this.initAutoSave(this.discussion.noteable_type);
+ }
+ },
+ updated() {
+ if (this.isReplying) {
+ if (!this.autosave) {
+ this.initAutoSave(this.discussion.noteable_type);
+ } else {
+ this.setAutoSave();
+ }
+ }
+ },
+ created() {
+ this.resolveDiscussionsSvg = resolveDiscussionsSvg;
+ this.nextDiscussionsSvg = nextDiscussionsSvg;
+ },
+ methods: {
+ ...mapActions([
+ 'saveNote',
+ 'toggleDiscussion',
+ 'removePlaceholderNotes',
+ 'toggleResolveNote',
+ ]),
+ componentName(note) {
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === SYSTEM_NOTE) {
+ return placeholderSystemNote;
+ }
+ return placeholderNote;
+ }
+
+ return noteableNote;
+ },
+ componentData(note) {
+ return note.isPlaceholderNote ? this.note.notes[0] : note;
+ },
+ toggleDiscussionHandler() {
+ this.toggleDiscussion({ discussionId: this.note.id });
+ },
+ showReplyForm() {
+ this.isReplying = true;
+ },
+ cancelReplyForm(shouldConfirm) {
+ if (shouldConfirm && this.$refs.noteForm.isDirty) {
+ const msg = 'Are you sure you want to cancel creating this comment?';
+
+ // eslint-disable-next-line no-alert
+ if (!confirm(msg)) {
+ return;
+ }
+ }
+
+ this.resetAutoSave();
+ this.isReplying = false;
+ },
+ saveReply(noteText, form, callback) {
+ const replyData = {
+ endpoint: this.newNotePath,
+ flashContainer: this.$el,
+ data: {
+ in_reply_to_discussion_id: this.note.reply_id,
+ target_type: this.noteableType,
+ target_id: this.discussion.noteable_id,
+ note: { note: noteText },
+ },
+ };
+ this.isReplying = false;
+
+ this.saveNote(replyData)
+ .then(() => {
+ this.resetAutoSave();
+ callback();
+ })
+ .catch(err => {
+ this.removePlaceholderNotes();
+ this.isReplying = true;
+ this.$nextTick(() => {
+ const msg = `Your comment could not be submitted!
+Please check your network connection and try again.`;
+ Flash(msg, 'alert', this.$el);
+ this.$refs.noteForm.note = noteText;
+ callback(err);
+ });
+ });
+ },
+ jumpToDiscussion() {
+ const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
+ const index = unresolvedIds.indexOf(this.note.id);
+
+ if (index >= 0 && index !== unresolvedIds.length) {
+ const nextId = unresolvedIds[index + 1];
+ const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
+
+ if (el) {
+ scrollToElement(el);
+ }
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ :data-discussion-id="note.id"
+ class="note note-discussion timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content">
+ <div class="discussion">
+ <div class="discussion-header">
+ <note-header
+ :author="author"
+ :created-at="discussion.created_at"
+ :note-id="discussion.id"
+ :include-toggle="true"
+ :expanded="note.expanded"
+ @toggleHandler="toggleDiscussionHandler"
+ action-text="started a discussion"
+ class="discussion"
+ />
+ <note-edited-text
+ v-if="lastUpdatedAt"
+ :edited-at="lastUpdatedAt"
+ :edited-by="lastUpdatedBy"
+ action-text="Last updated"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ </div>
+ <div
+ v-if="note.expanded"
+ class="discussion-body">
+ <component
+ :is="wrapperComponent"
+ :discussion="discussion"
+ :class="wrapperClass"
+ >
+ <div class="discussion-notes">
+ <ul class="notes">
+ <component
+ v-for="note in note.notes"
+ :is="componentName(note)"
+ :note="componentData(note)"
+ :key="note.id"
+ />
+ </ul>
+ <div
+ :class="{ 'is-replying': isReplying }"
+ class="discussion-reply-holder">
+ <template v-if="!isReplying && canReply">
+ <div
+ class="btn-group-justified discussion-with-resolve-btn"
+ role="group">
+ <div
+ class="btn-group"
+ role="group">
+ <button
+ @click="showReplyForm"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ title="Add a reply">Reply...</button>
+ </div>
+ <div
+ v-if="note.resolvable"
+ class="btn-group"
+ role="group">
+ <button
+ @click="resolveHandler()"
+ type="button"
+ class="btn btn-default"
+ >
+ <i
+ v-if="isResolving"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin"
+ ></i>
+ {{ resolveButtonTitle }}
+ </button>
+ </div>
+ <div
+ class="btn-group discussion-actions"
+ role="group">
+ <div
+ v-if="note.resolvable && !discussionResolved"
+ class="btn-group"
+ role="group">
+ <a
+ :href="note.resolve_with_issue_path"
+ v-tooltip
+ class="new-issue-for-discussion btn
+ btn-default discussion-create-issue-btn"
+ title="Resolve this discussion in a new issue"
+ data-container="body"
+ >
+ <span v-html="resolveDiscussionsSvg"></span>
+ </a>
+ </div>
+ <div
+ v-if="hasUnresolvedDiscussion"
+ class="btn-group"
+ role="group">
+ <button
+ @click="jumpToDiscussion"
+ v-tooltip
+ class="btn btn-default discussion-next-btn"
+ title="Jump to next unresolved discussion"
+ data-container="body"
+ >
+ <span v-html="nextDiscussionsSvg"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </template>
+ <note-form
+ v-if="isReplying"
+ save-button-title="Comment"
+ :note="note"
+ :is-editing="false"
+ @handleFormUpdate="saveReply"
+ @cancelFormEdition="cancelReplyForm"
+ ref="noteForm" />
+ <note-signed-out-widget v-if="!canReply" />
+ </div>
+ </div>
+ </component>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
new file mode 100644
index 00000000000..3554027d2b4
--- /dev/null
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -0,0 +1,203 @@
+<script>
+import $ from 'jquery';
+import { mapGetters, mapActions } from 'vuex';
+import { escape } from 'underscore';
+import Flash from '../../flash';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import noteHeader from './note_header.vue';
+import noteActions from './note_actions.vue';
+import noteBody from './note_body.vue';
+import eventHub from '../event_hub';
+import noteable from '../mixins/noteable';
+import resolvable from '../mixins/resolvable';
+
+export default {
+ components: {
+ userAvatarLink,
+ noteHeader,
+ noteActions,
+ noteBody,
+ },
+ mixins: [noteable, resolvable],
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ isDeleting: false,
+ isRequesting: false,
+ isResolving: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['targetNoteHash', 'getUserData']),
+ author() {
+ return this.note.author;
+ },
+ classNameBindings() {
+ return {
+ 'is-editing': this.isEditing && !this.isRequesting,
+ 'is-requesting being-posted': this.isRequesting,
+ 'disabled-content': this.isDeleting,
+ target: this.targetNoteHash === this.noteAnchorId,
+ };
+ },
+ canReportAsAbuse() {
+ return (
+ this.note.report_abuse_path && this.author.id !== this.getUserData.id
+ );
+ },
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ },
+
+ created() {
+ eventHub.$on('enterEditMode', ({ noteId }) => {
+ if (noteId === this.note.id) {
+ this.isEditing = true;
+ this.scrollToNoteIfNeeded($(this.$el));
+ }
+ });
+ },
+
+ methods: {
+ ...mapActions([
+ 'deleteNote',
+ 'updateNote',
+ 'toggleResolveNote',
+ 'scrollToNoteIfNeeded',
+ ]),
+ editHandler() {
+ this.isEditing = true;
+ },
+ deleteHandler() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Are you sure you want to delete this comment?')) {
+ this.isDeleting = true;
+
+ this.deleteNote(this.note)
+ .then(() => {
+ this.isDeleting = false;
+ })
+ .catch(() => {
+ Flash(
+ 'Something went wrong while deleting your note. Please try again.',
+ );
+ this.isDeleting = false;
+ });
+ }
+ },
+ formUpdateHandler(noteText, parentElement, callback) {
+ const data = {
+ endpoint: this.note.path,
+ note: {
+ target_type: this.noteableType,
+ target_id: this.note.noteable_id,
+ note: { note: noteText },
+ },
+ };
+ this.isRequesting = true;
+ this.oldContent = this.note.note_html;
+ this.note.note_html = escape(noteText);
+
+ this.updateNote(data)
+ .then(() => {
+ this.isEditing = false;
+ this.isRequesting = false;
+ this.oldContent = null;
+ $(this.$refs.noteBody.$el).renderGFM();
+ this.$refs.noteBody.resetAutoSave();
+ callback();
+ })
+ .catch(() => {
+ this.isRequesting = false;
+ this.isEditing = true;
+ this.$nextTick(() => {
+ const msg =
+ 'Something went wrong while editing your comment. Please try again.';
+ Flash(msg, 'alert', this.$el);
+ this.recoverNoteContent(noteText);
+ callback();
+ });
+ });
+ },
+ formCancelHandler(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ // eslint-disable-next-line no-alert
+ if (!confirm('Are you sure you want to cancel editing this comment?'))
+ return;
+ }
+ this.$refs.noteBody.resetAutoSave();
+ if (this.oldContent) {
+ this.note.note_html = this.oldContent;
+ this.oldContent = null;
+ }
+ this.isEditing = false;
+ },
+ recoverNoteContent(noteText) {
+ // we need to do this to prevent noteForm inconsistent content warning
+ // this is something we intentionally do so we need to recover the content
+ this.note.note = noteText;
+ this.$refs.noteBody.$refs.noteForm.note.note = noteText;
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ class="note timeline-entry"
+ :id="noteAnchorId"
+ :class="classNameBindings"
+ :data-award-url="note.toggle_award_path">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header
+ :author="author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ action-text="commented"
+ />
+ <note-actions
+ :author-id="author.id"
+ :note-id="note.id"
+ :access-level="note.human_access"
+ :can-edit="note.current_user.can_edit"
+ :can-delete="note.current_user.can_edit"
+ :can-report-as-abuse="canReportAsAbuse"
+ :report-abuse-path="note.report_abuse_path"
+ :resolvable="note.resolvable"
+ :is-resolved="note.resolved"
+ :is-resolving="isResolving"
+ :resolved-by="note.resolved_by"
+ @handleEdit="editHandler"
+ @handleDelete="deleteHandler"
+ @handleResolve="resolveHandler"
+ />
+ </div>
+ <note-body
+ :note="note"
+ :can-edit="note.current_user.can_edit"
+ :is-editing="isEditing"
+ @handleFormUpdate="formUpdateHandler"
+ @cancelFormEdition="formCancelHandler"
+ ref="noteBody"
+ />
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
new file mode 100644
index 00000000000..a90c6d6381d
--- /dev/null
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -0,0 +1,180 @@
+<script>
+import $ from 'jquery';
+import { mapGetters, mapActions } from 'vuex';
+import { getLocationHash } from '../../lib/utils/url_utility';
+import Flash from '../../flash';
+import store from '../stores/';
+import * as constants from '../constants';
+import noteableNote from './noteable_note.vue';
+import noteableDiscussion from './noteable_discussion.vue';
+import systemNote from '../../vue_shared/components/notes/system_note.vue';
+import commentForm from './comment_form.vue';
+import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
+import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
+
+export default {
+ name: 'NotesApp',
+ components: {
+ noteableNote,
+ noteableDiscussion,
+ systemNote,
+ commentForm,
+ loadingIcon,
+ placeholderNote,
+ placeholderSystemNote,
+ },
+ props: {
+ noteableData: {
+ type: Object,
+ required: true,
+ },
+ notesData: {
+ type: Object,
+ required: true,
+ },
+ userData: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ store,
+ data() {
+ return {
+ isLoading: true,
+ };
+ },
+ computed: {
+ ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']),
+ noteableType() {
+ // FIXME -- @fatihacet Get this from JSON data.
+ const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants;
+
+ return this.noteableData.merge_params
+ ? MERGE_REQUEST_NOTEABLE_TYPE
+ : ISSUE_NOTEABLE_TYPE;
+ },
+ allNotes() {
+ if (this.isLoading) {
+ const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
+
+ return new Array(totalNotes).fill({
+ isSkeletonNote: true,
+ });
+ }
+ return this.notes;
+ },
+ },
+ created() {
+ this.setNotesData(this.notesData);
+ this.setNoteableData(this.noteableData);
+ this.setUserData(this.userData);
+ },
+ mounted() {
+ this.fetchNotes();
+
+ const parentElement = this.$el.parentElement;
+
+ if (
+ parentElement &&
+ parentElement.classList.contains('js-vue-notes-event')
+ ) {
+ parentElement.addEventListener('toggleAward', event => {
+ const { awardName, noteId } = event.detail;
+ this.actionToggleAward({ awardName, noteId });
+ });
+ }
+ document.addEventListener('refreshVueNotes', this.fetchNotes);
+ },
+ beforeDestroy() {
+ document.removeEventListener('refreshVueNotes', this.fetchNotes);
+ },
+ methods: {
+ ...mapActions({
+ actionFetchNotes: 'fetchNotes',
+ poll: 'poll',
+ actionToggleAward: 'toggleAward',
+ scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
+ setNotesData: 'setNotesData',
+ setNoteableData: 'setNoteableData',
+ setUserData: 'setUserData',
+ setLastFetchedAt: 'setLastFetchedAt',
+ setTargetNoteHash: 'setTargetNoteHash',
+ }),
+ getComponentName(note) {
+ if (note.isSkeletonNote) {
+ return skeletonLoadingContainer;
+ }
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === constants.SYSTEM_NOTE) {
+ return placeholderSystemNote;
+ }
+ return placeholderNote;
+ } else if (note.individual_note) {
+ return note.notes[0].system ? systemNote : noteableNote;
+ }
+
+ return noteableDiscussion;
+ },
+ getComponentData(note) {
+ return note.individual_note ? note.notes[0] : note;
+ },
+ fetchNotes() {
+ return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
+ .then(() => this.initPolling())
+ .then(() => {
+ this.isLoading = false;
+ })
+ .then(() => this.$nextTick())
+ .then(() => this.checkLocationHash())
+ .catch(() => {
+ this.isLoading = false;
+ Flash(
+ 'Something went wrong while fetching comments. Please try again.',
+ );
+ });
+ },
+ initPolling() {
+ if (this.isPollingInitialized) {
+ return;
+ }
+
+ this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
+
+ this.poll();
+ this.isPollingInitialized = true;
+ },
+ checkLocationHash() {
+ const hash = getLocationHash();
+ const element = document.getElementById(hash);
+
+ if (hash && element) {
+ this.setTargetNoteHash(hash);
+ this.scrollToNoteIfNeeded($(element));
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div id="notes">
+ <ul
+ id="notes-list"
+ class="notes main-notes-list timeline">
+
+ <component
+ v-for="note in allNotes"
+ :is="getComponentName(note)"
+ :note="getComponentData(note)"
+ :key="note.id"
+ />
+ </ul>
+
+ <comment-form
+ :noteable-type="noteableType"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
new file mode 100644
index 00000000000..f4f407ffd8a
--- /dev/null
+++ b/app/assets/javascripts/notes/constants.js
@@ -0,0 +1,15 @@
+export const DISCUSSION_NOTE = 'DiscussionNote';
+export const DIFF_NOTE = 'DiffNote';
+export const DISCUSSION = 'discussion';
+export const NOTE = 'note';
+export const SYSTEM_NOTE = 'systemNote';
+export const COMMENT = 'comment';
+export const OPENED = 'opened';
+export const REOPENED = 'reopened';
+export const CLOSED = 'closed';
+export const EMOJI_THUMBSUP = 'thumbsup';
+export const EMOJI_THUMBSDOWN = 'thumbsdown';
+export const ISSUE_NOTEABLE_TYPE = 'issue';
+export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
+export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
+export const RESOLVE_NOTE_METHOD_NAME = 'post';
diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/notes/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
new file mode 100644
index 00000000000..f90775d0157
--- /dev/null
+++ b/app/assets/javascripts/notes/index.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import notesApp from './components/notes_app.vue';
+
+document.addEventListener(
+ 'DOMContentLoaded',
+ () =>
+ new Vue({
+ el: '#js-vue-notes',
+ components: {
+ notesApp,
+ },
+ data() {
+ const notesDataset = document.getElementById('js-vue-notes').dataset;
+ const parsedUserData = JSON.parse(notesDataset.currentUserData);
+ let currentUserData = {};
+
+ if (parsedUserData) {
+ currentUserData = {
+ id: parsedUserData.id,
+ name: parsedUserData.name,
+ username: parsedUserData.username,
+ avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
+ path: parsedUserData.path,
+ };
+ }
+
+ return {
+ noteableData: JSON.parse(notesDataset.noteableData),
+ currentUserData,
+ notesData: JSON.parse(notesDataset.notesData),
+ };
+ },
+ render(createElement) {
+ return createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ },
+ });
+ },
+ }),
+);
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
new file mode 100644
index 00000000000..3dff715905f
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -0,0 +1,21 @@
+import $ from 'jquery';
+import Autosave from '../../autosave';
+import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
+
+export default {
+ methods: {
+ initAutoSave(noteableType) {
+ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [
+ 'Note',
+ capitalizeFirstCharacter(noteableType),
+ this.note.id,
+ ]);
+ },
+ resetAutoSave() {
+ this.autosave.reset();
+ },
+ setAutoSave() {
+ this.autosave.save();
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js
new file mode 100644
index 00000000000..97f3ea0d5de
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/issuable_state.js
@@ -0,0 +1,15 @@
+export default {
+ methods: {
+ isConfidential(issue) {
+ return !!issue.confidential;
+ },
+
+ isLocked(issue) {
+ return !!issue.discussion_locked;
+ },
+
+ hasWarning(issue) {
+ return this.isConfidential(issue) || this.isLocked(issue);
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js
new file mode 100644
index 00000000000..0da4ff49f08
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/noteable.js
@@ -0,0 +1,22 @@
+import * as constants from '../constants';
+
+export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ noteableType() {
+ switch (this.note.noteable_type) {
+ case 'MergeRequest':
+ return constants.MERGE_REQUEST_NOTEABLE_TYPE;
+ case 'Issue':
+ return constants.ISSUE_NOTEABLE_TYPE;
+ default:
+ return '';
+ }
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
new file mode 100644
index 00000000000..f79049b85f6
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -0,0 +1,55 @@
+import Flash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ discussionResolved() {
+ const { notes, resolved } = this.note;
+
+ if (notes) {
+ // Decide resolved state using store. Only valid for discussions.
+ return notes.every(note => note.resolved && !note.system);
+ }
+
+ return resolved;
+ },
+ resolveButtonTitle() {
+ if (this.updatedNoteBody) {
+ if (this.discussionResolved) {
+ return __('Comment and unresolve discussion');
+ }
+
+ return __('Comment and resolve discussion');
+ }
+ return this.discussionResolved
+ ? __('Unresolve discussion')
+ : __('Resolve discussion');
+ },
+ },
+ methods: {
+ resolveHandler(resolvedState = false) {
+ this.isResolving = true;
+ const endpoint = this.note.resolve_path || `${this.note.path}/resolve`;
+ const isResolved = this.discussionResolved || resolvedState;
+ const discussion = this.resolveAsThread;
+
+ this.toggleResolveNote({ endpoint, isResolved, discussion })
+ .then(() => {
+ this.isResolving = false;
+ })
+ .catch(() => {
+ this.isResolving = false;
+ const msg = __(
+ 'Something went wrong while resolving this discussion. Please try again.',
+ );
+ Flash(msg, 'alert', this.$el);
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
new file mode 100644
index 00000000000..7c623aac6ed
--- /dev/null
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -0,0 +1,48 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import * as constants from '../constants';
+
+Vue.use(VueResource);
+
+export default {
+ fetchNotes(endpoint) {
+ return Vue.http.get(endpoint);
+ },
+ deleteNote(endpoint) {
+ return Vue.http.delete(endpoint);
+ },
+ replyToDiscussion(endpoint, data) {
+ return Vue.http.post(endpoint, data, { emulateJSON: true });
+ },
+ updateNote(endpoint, data) {
+ return Vue.http.put(endpoint, data, { emulateJSON: true });
+ },
+ createNewNote(endpoint, data) {
+ return Vue.http.post(endpoint, data, { emulateJSON: true });
+ },
+ toggleResolveNote(endpoint, isResolved) {
+ const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
+ const method = isResolved
+ ? UNRESOLVE_NOTE_METHOD_NAME
+ : RESOLVE_NOTE_METHOD_NAME;
+
+ return Vue.http[method](endpoint);
+ },
+ poll(data = {}) {
+ const endpoint = data.notesData.notesPath;
+ const lastFetchedAt = data.lastFetchedAt;
+ const options = {
+ headers: {
+ 'X-Last-Fetched-At': lastFetchedAt ? `${lastFetchedAt}` : undefined,
+ },
+ };
+
+ return Vue.http.get(endpoint, options);
+ },
+ toggleAward(endpoint, data) {
+ return Vue.http.post(endpoint, data, { emulateJSON: true });
+ },
+ toggleIssueState(endpoint, data) {
+ return Vue.http.put(endpoint, data);
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
new file mode 100644
index 00000000000..244a6980b5a
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -0,0 +1,317 @@
+import $ from 'jquery';
+import Visibility from 'visibilityjs';
+import Flash from '../../flash';
+import Poll from '../../lib/utils/poll';
+import * as types from './mutation_types';
+import * as utils from './utils';
+import * as constants from '../constants';
+import service from '../services/notes_service';
+import loadAwardsHandler from '../../awards_handler';
+import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
+import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
+
+let eTagPoll;
+
+export const setNotesData = ({ commit }, data) =>
+ commit(types.SET_NOTES_DATA, data);
+export const setNoteableData = ({ commit }, data) =>
+ commit(types.SET_NOTEABLE_DATA, data);
+export const setUserData = ({ commit }, data) =>
+ commit(types.SET_USER_DATA, data);
+export const setLastFetchedAt = ({ commit }, data) =>
+ commit(types.SET_LAST_FETCHED_AT, data);
+export const setInitialNotes = ({ commit }, data) =>
+ commit(types.SET_INITIAL_NOTES, data);
+export const setTargetNoteHash = ({ commit }, data) =>
+ commit(types.SET_TARGET_NOTE_HASH, data);
+export const toggleDiscussion = ({ commit }, data) =>
+ commit(types.TOGGLE_DISCUSSION, data);
+
+export const fetchNotes = ({ commit }, path) =>
+ service
+ .fetchNotes(path)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.SET_INITIAL_NOTES, res);
+ });
+
+export const deleteNote = ({ commit }, note) =>
+ service.deleteNote(note.path).then(() => {
+ commit(types.DELETE_NOTE, note);
+ });
+
+export const updateNote = ({ commit }, { endpoint, note }) =>
+ service
+ .updateNote(endpoint, note)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.UPDATE_NOTE, res);
+ });
+
+export const replyToDiscussion = ({ commit }, { endpoint, data }) =>
+ service
+ .replyToDiscussion(endpoint, data)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
+
+ return res;
+ });
+
+export const createNewNote = ({ commit }, { endpoint, data }) =>
+ service
+ .createNewNote(endpoint, data)
+ .then(res => res.json())
+ .then(res => {
+ if (!res.errors) {
+ commit(types.ADD_NEW_NOTE, res);
+ }
+ return res;
+ });
+
+export const removePlaceholderNotes = ({ commit }) =>
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+export const toggleResolveNote = (
+ { commit },
+ { endpoint, isResolved, discussion },
+) =>
+ service
+ .toggleResolveNote(endpoint, isResolved)
+ .then(res => res.json())
+ .then(res => {
+ const mutationType = discussion
+ ? types.UPDATE_DISCUSSION
+ : types.UPDATE_NOTE;
+
+ commit(mutationType, res);
+ });
+
+export const closeIssue = ({ commit, dispatch, state }) => {
+ dispatch('toggleStateButtonLoading', true);
+ return service
+ .toggleIssueState(state.notesData.closePath)
+ .then(res => res.json())
+ .then(data => {
+ commit(types.CLOSE_ISSUE);
+ dispatch('emitStateChangedEvent', data);
+ dispatch('toggleStateButtonLoading', false);
+ });
+};
+
+export const reopenIssue = ({ commit, dispatch, state }) => {
+ dispatch('toggleStateButtonLoading', true);
+ return service
+ .toggleIssueState(state.notesData.reopenPath)
+ .then(res => res.json())
+ .then(data => {
+ commit(types.REOPEN_ISSUE);
+ dispatch('emitStateChangedEvent', data);
+ dispatch('toggleStateButtonLoading', false);
+ });
+};
+
+export const toggleStateButtonLoading = ({ commit }, value) =>
+ commit(types.TOGGLE_STATE_BUTTON_LOADING, value);
+
+export const emitStateChangedEvent = ({ commit, getters }, data) => {
+ const event = new CustomEvent('issuable_vue_app:change', {
+ detail: {
+ data,
+ isClosed: getters.openState === constants.CLOSED,
+ },
+ });
+
+ document.dispatchEvent(event);
+};
+
+export const toggleIssueLocalState = ({ commit }, newState) => {
+ if (newState === constants.CLOSED) {
+ commit(types.CLOSE_ISSUE);
+ } else if (newState === constants.REOPENED) {
+ commit(types.REOPEN_ISSUE);
+ }
+};
+
+export const saveNote = ({ commit, dispatch }, noteData) => {
+ const { note } = noteData.data.note;
+ let placeholderText = note;
+ const hasQuickActions = utils.hasQuickActions(placeholderText);
+ const replyId = noteData.data.in_reply_to_discussion_id;
+ const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
+
+ commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
+ $('.notes-form .flash-container').hide(); // hide previous flash notification
+
+ if (hasQuickActions) {
+ placeholderText = utils.stripQuickActions(placeholderText);
+ }
+
+ if (placeholderText.length) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ noteBody: placeholderText,
+ replyId,
+ });
+ }
+
+ if (hasQuickActions) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ isSystemNote: true,
+ noteBody: utils.getQuickActionText(note),
+ replyId,
+ });
+ }
+
+ return dispatch(methodToDispatch, noteData).then(res => {
+ const { errors } = res;
+ const commandsChanges = res.commands_changes;
+
+ if (hasQuickActions && errors && Object.keys(errors).length) {
+ eTagPoll.makeRequest();
+
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash('Commands applied', 'notice', noteData.flashContainer);
+ }
+
+ if (commandsChanges) {
+ if (commandsChanges.emoji_award) {
+ const votesBlock = $('.js-awards-block').eq(0);
+
+ loadAwardsHandler()
+ .then(awardsHandler => {
+ awardsHandler.addAwardToEmojiBar(
+ votesBlock,
+ commandsChanges.emoji_award,
+ );
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ Flash(
+ 'Something went wrong while adding your award. Please try again.',
+ 'alert',
+ noteData.flashContainer,
+ );
+ });
+ }
+
+ if (
+ commandsChanges.spend_time != null ||
+ commandsChanges.time_estimate != null
+ ) {
+ sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
+ }
+ }
+
+ if (errors && errors.commands_only) {
+ Flash(errors.commands_only, 'notice', noteData.flashContainer);
+ }
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+ return res;
+ });
+};
+
+const pollSuccessCallBack = (resp, commit, state, getters) => {
+ if (resp.notes && resp.notes.length) {
+ const { notesById } = getters;
+
+ resp.notes.forEach(note => {
+ if (notesById[note.id]) {
+ commit(types.UPDATE_NOTE, note);
+ } else if (
+ note.type === constants.DISCUSSION_NOTE ||
+ note.type === constants.DIFF_NOTE
+ ) {
+ const discussion = utils.findNoteObjectById(
+ state.notes,
+ note.discussion_id,
+ );
+
+ if (discussion) {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ });
+ }
+
+ commit(types.SET_LAST_FETCHED_AT, resp.last_fetched_at);
+
+ return resp;
+};
+
+export const poll = ({ commit, state, getters }) => {
+ eTagPoll = new Poll({
+ resource: service,
+ method: 'poll',
+ data: state,
+ successCallback: resp =>
+ resp
+ .json()
+ .then(data => pollSuccessCallBack(data, commit, state, getters)),
+ errorCallback: () =>
+ Flash('Something went wrong while fetching latest comments.'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ service.poll(state);
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ eTagPoll.restart();
+ } else {
+ eTagPoll.stop();
+ }
+ });
+};
+
+export const stopPolling = () => {
+ eTagPoll.stop();
+};
+
+export const restartPolling = () => {
+ eTagPoll.restart();
+};
+
+export const fetchData = ({ commit, state, getters }) => {
+ const requestData = {
+ endpoint: state.notesData.notesPath,
+ lastFetchedAt: state.lastFetchedAt,
+ };
+
+ service
+ .poll(requestData)
+ .then(resp => resp.json)
+ .then(data => pollSuccessCallBack(data, commit, state, getters))
+ .catch(() => Flash('Something went wrong while fetching latest comments.'));
+};
+
+export const toggleAward = (
+ { commit, state, getters, dispatch },
+ { awardName, noteId },
+) => {
+ commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
+};
+
+export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => {
+ const { endpoint, awardName } = data;
+
+ return service
+ .toggleAward(endpoint, { name: awardName })
+ .then(res => res.json())
+ .then(() => {
+ dispatch('toggleAward', data);
+ });
+};
+
+export const scrollToNoteIfNeeded = (context, el) => {
+ if (!isInViewport(el[0])) {
+ scrollToElement(el);
+ }
+};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
new file mode 100644
index 00000000000..f89591a54d6
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -0,0 +1,70 @@
+import _ from 'underscore';
+
+export const notes = state => state.notes;
+export const targetNoteHash = state => state.targetNoteHash;
+
+export const getNotesData = state => state.notesData;
+export const getNotesDataByProp = state => prop => state.notesData[prop];
+
+export const getNoteableData = state => state.noteableData;
+export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const openState = state => state.noteableData.state;
+
+export const getUserData = state => state.userData || {};
+export const getUserDataByProp = state => prop =>
+ state.userData && state.userData[prop];
+
+export const notesById = state =>
+ state.notes.reduce((acc, note) => {
+ note.notes.every(n => Object.assign(acc, { [n.id]: n }));
+ return acc;
+ }, {});
+
+const reverseNotes = array => array.slice(0).reverse();
+const isLastNote = (note, state) =>
+ !note.system &&
+ state.userData &&
+ note.author &&
+ note.author.id === state.userData.id;
+
+export const getCurrentUserLastNote = state =>
+ _.flatten(
+ reverseNotes(state.notes).map(note => reverseNotes(note.notes)),
+ ).find(el => isLastNote(el, state));
+
+export const getDiscussionLastNote = state => discussion =>
+ reverseNotes(discussion.notes).find(el => isLastNote(el, state));
+
+export const discussionCount = state => {
+ const discussions = state.notes.filter(n => !n.individual_note);
+
+ return discussions.length;
+};
+
+export const unresolvedDiscussions = (state, getters) => {
+ const resolvedMap = getters.resolvedDiscussionsById;
+
+ return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]);
+};
+
+export const resolvedDiscussionsById = state => {
+ const map = {};
+
+ state.notes.forEach(n => {
+ if (n.notes) {
+ const resolved = n.notes.every(note => note.resolved && !note.system);
+
+ if (resolved) {
+ map[n.id] = n;
+ }
+ }
+ });
+
+ return map;
+};
+
+export const resolvedDiscussionCount = (state, getters) => {
+ const resolvedMap = getters.resolvedDiscussionsById;
+
+ return Object.keys(resolvedMap).length;
+};
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
new file mode 100644
index 00000000000..9ed19bf171e
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+
+ // View layer
+ isToggleStateButtonLoading: false,
+
+ // holds endpoints and permissions provided through haml
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ },
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
new file mode 100644
index 00000000000..b455e23ecde
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -0,0 +1,20 @@
+export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
+export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
+export const DELETE_NOTE = 'DELETE_NOTE';
+export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
+export const SET_NOTES_DATA = 'SET_NOTES_DATA';
+export const SET_NOTEABLE_DATA = 'SET_NOTEABLE_DATA';
+export const SET_USER_DATA = 'SET_USER_DATA';
+export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
+export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
+export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
+export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
+export const TOGGLE_AWARD = 'TOGGLE_AWARD';
+export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
+export const UPDATE_NOTE = 'UPDATE_NOTE';
+export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
+
+// Issue
+export const CLOSE_ISSUE = 'CLOSE_ISSUE';
+export const REOPEN_ISSUE = 'REOPEN_ISSUE';
+export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
new file mode 100644
index 00000000000..c8edc06349f
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -0,0 +1,214 @@
+import * as utils from './utils';
+import * as types from './mutation_types';
+import * as constants from '../constants';
+import { isInMRPage } from '../../lib/utils/common_utils';
+
+export default {
+ [types.ADD_NEW_NOTE](state, note) {
+ const { discussion_id, type } = note;
+ const [exists] = state.notes.filter(n => n.id === note.discussion_id);
+ const isDiscussion = type === constants.DISCUSSION_NOTE;
+
+ if (!exists) {
+ const noteData = {
+ expanded: true,
+ id: discussion_id,
+ individual_note: !isDiscussion,
+ notes: [note],
+ reply_id: discussion_id,
+ };
+
+ if (isDiscussion && isInMRPage()) {
+ noteData.resolvable = note.resolvable;
+ noteData.resolved = false;
+ noteData.resolve_path = note.resolve_path;
+ noteData.resolve_with_issue_path = note.resolve_with_issue_path;
+ }
+
+ state.notes.push(noteData);
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ }
+ },
+
+ [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj) {
+ noteObj.notes.push(note);
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ }
+ },
+
+ [types.DELETE_NOTE](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj.individual_note) {
+ state.notes.splice(state.notes.indexOf(noteObj), 1);
+ } else {
+ const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ noteObj.notes.splice(noteObj.notes.indexOf(comment), 1);
+
+ if (!noteObj.notes.length) {
+ state.notes.splice(state.notes.indexOf(noteObj), 1);
+ }
+ }
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ },
+
+ [types.REMOVE_PLACEHOLDER_NOTES](state) {
+ const { notes } = state;
+
+ for (let i = notes.length - 1; i >= 0; i -= 1) {
+ const note = notes[i];
+ const children = note.notes;
+
+ if (children.length && !note.individual_note) {
+ // remove placeholder from discussions
+ for (let j = children.length - 1; j >= 0; j -= 1) {
+ if (children[j].isPlaceholderNote) {
+ children.splice(j, 1);
+ }
+ }
+ } else if (note.isPlaceholderNote) {
+ // remove placeholders from state root
+ notes.splice(i, 1);
+ }
+ }
+ },
+
+ [types.SET_NOTES_DATA](state, data) {
+ Object.assign(state, { notesData: data });
+ },
+
+ [types.SET_NOTEABLE_DATA](state, data) {
+ Object.assign(state, { noteableData: data });
+ },
+
+ [types.SET_USER_DATA](state, data) {
+ Object.assign(state, { userData: data });
+ },
+ [types.SET_INITIAL_NOTES](state, notesData) {
+ const notes = [];
+
+ notesData.forEach(note => {
+ // To support legacy notes, should be very rare case.
+ if (note.individual_note && note.notes.length > 1) {
+ note.notes.forEach(n => {
+ notes.push({
+ ...note,
+ notes: [n], // override notes array to only have one item to mimick individual_note
+ });
+ });
+ } else {
+ const oldNote = utils.findNoteObjectById(state.notes, note.id);
+
+ notes.push({
+ ...note,
+ expanded: oldNote ? oldNote.expanded : note.expanded,
+ });
+ }
+ });
+
+ Object.assign(state, { notes });
+ },
+
+ [types.SET_LAST_FETCHED_AT](state, fetchedAt) {
+ Object.assign(state, { lastFetchedAt: fetchedAt });
+ },
+
+ [types.SET_TARGET_NOTE_HASH](state, hash) {
+ Object.assign(state, { targetNoteHash: hash });
+ },
+
+ [types.SHOW_PLACEHOLDER_NOTE](state, data) {
+ let notesArr = state.notes;
+ if (data.replyId) {
+ notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes;
+ }
+
+ notesArr.push({
+ individual_note: true,
+ isPlaceholderNote: true,
+ placeholderType: data.isSystemNote
+ ? constants.SYSTEM_NOTE
+ : constants.NOTE,
+ notes: [
+ {
+ body: data.noteBody,
+ },
+ ],
+ });
+ },
+
+ [types.TOGGLE_AWARD](state, data) {
+ const { awardName, note } = data;
+ const { id, name, username } = state.userData;
+
+ const hasEmojiAwardedByCurrentUser = note.award_emoji.filter(
+ emoji => emoji.name === data.awardName && emoji.user.id === id,
+ );
+
+ if (hasEmojiAwardedByCurrentUser.length) {
+ // If current user has awarded this emoji, remove it.
+ note.award_emoji.splice(
+ note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]),
+ 1,
+ );
+ } else {
+ note.award_emoji.push({
+ name: awardName,
+ user: { id, name, username },
+ });
+ }
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ },
+
+ [types.TOGGLE_DISCUSSION](state, { discussionId }) {
+ const discussion = utils.findNoteObjectById(state.notes, discussionId);
+
+ discussion.expanded = !discussion.expanded;
+ },
+
+ [types.UPDATE_NOTE](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj.individual_note) {
+ noteObj.notes.splice(0, 1, note);
+ } else {
+ const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
+ }
+
+ // document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ },
+
+ [types.UPDATE_DISCUSSION](state, noteData) {
+ const note = noteData;
+ let index = 0;
+
+ state.notes.forEach((n, i) => {
+ if (n.id === note.id) {
+ index = i;
+ }
+ });
+
+ note.expanded = true; // override expand flag to prevent collapse
+ state.notes.splice(index, 1, note);
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ },
+
+ [types.CLOSE_ISSUE](state) {
+ Object.assign(state.noteableData, { state: constants.CLOSED });
+ },
+
+ [types.REOPEN_ISSUE](state) {
+ Object.assign(state.noteableData, { state: constants.REOPENED });
+ },
+
+ [types.TOGGLE_STATE_BUTTON_LOADING](state, value) {
+ Object.assign(state, { isToggleStateButtonLoading: value });
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
new file mode 100644
index 00000000000..a0e096ebfaf
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -0,0 +1,33 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+
+const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+
+export const findNoteObjectById = (notes, id) =>
+ notes.filter(n => n.id === id)[0];
+
+export const getQuickActionText = note => {
+ let text = 'Applying command';
+ const quickActions =
+ AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
+
+ const executedCommands = quickActions.filter(command => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(note);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ text = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ text = `Applying command to ${commandDescription}`;
+ }
+ }
+
+ return text;
+};
+
+export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
+
+export const stripQuickActions = note =>
+ note.replace(REGEX_QUICK_ACTIONS, '').trim();
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index 838356133cd..8ff8bb446ad 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,31 +1,26 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, consistent-return, prefer-arrow-callback, no-else-return, max-len */
-/* global Flash */
+import $ from 'jquery';
+import Flash from './flash';
-(function() {
- this.NotificationsDropdown = (function() {
- function NotificationsDropdown() {
- $(document).off('click', '.update-notification').on('click', '.update-notification', function(e) {
- var form, label, notificationLevel;
- e.preventDefault();
- if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') {
- return;
- }
- notificationLevel = $(this).data('notification-level');
- label = $(this).data('notification-title');
- form = $(this).parents('.notification-form:first');
- form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
- form.find('#notification_setting_level').val(notificationLevel);
- return form.submit();
- });
- $(document).off('ajax:success', '.notification-form').on('ajax:success', '.notification-form', function(e, data) {
- if (data.saved) {
- return $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
- } else {
- return new Flash('Failed to save new settings', 'alert');
- }
- });
+export default function notificationsDropdown() {
+ $(document).on('click', '.update-notification', function updateNotificationCallback(e) {
+ e.preventDefault();
+ if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') {
+ return;
}
- return NotificationsDropdown;
- })();
-}).call(window);
+ const notificationLevel = $(this).data('notificationLevel');
+ const form = $(this).parents('.notification-form:first');
+
+ form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
+ form.find('#notification_setting_level').val(notificationLevel);
+ form.submit();
+ });
+
+ $(document).on('ajax:success', '.notification-form', (e, data) => {
+ if (data.saved) {
+ $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
+ } else {
+ Flash('Failed to save new settings', 'alert');
+ }
+ });
+}
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 2ab9c4fed2c..9e6cf67dff0 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -1,55 +1,51 @@
-/* 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() {
- this.NotificationsForm = (function() {
- function NotificationsForm() {
- this.toggleCheckbox = this.toggleCheckbox.bind(this);
- this.removeEventListeners();
- this.initEventListeners();
- }
-
- NotificationsForm.prototype.removeEventListeners = function() {
- return $(document).off('change', '.js-custom-notification-event');
- };
-
- NotificationsForm.prototype.initEventListeners = function() {
- return $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox);
- };
-
- NotificationsForm.prototype.toggleCheckbox = function(e) {
- var $checkbox, $parent;
- $checkbox = $(e.currentTarget);
- $parent = $checkbox.closest('.checkbox');
- return this.saveEvent($checkbox, $parent);
- };
-
- NotificationsForm.prototype.showCheckboxLoadingSpinner = function($parent) {
- return $parent.addClass('is-loading').find('.custom-notification-event-loading').removeClass('fa-check').addClass('fa-spin fa-spinner').removeClass('is-done');
- };
-
- NotificationsForm.prototype.saveEvent = function($checkbox, $parent) {
- var form;
- form = $parent.parents('form:first');
- return $.ajax({
- url: form.attr('action'),
- method: form.attr('method'),
- dataType: 'json',
- data: form.serialize(),
- beforeSend: (function(_this) {
- return function() {
- return _this.showCheckboxLoadingSpinner($parent);
- };
- })(this)
- }).done(function(data) {
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+
+export default class NotificationsForm {
+ constructor() {
+ this.toggleCheckbox = this.toggleCheckbox.bind(this);
+ this.initEventListeners();
+ }
+
+ initEventListeners() {
+ $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox);
+ }
+
+ toggleCheckbox(e) {
+ const $checkbox = $(e.currentTarget);
+ const $parent = $checkbox.closest('.checkbox');
+
+ this.saveEvent($checkbox, $parent);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ showCheckboxLoadingSpinner($parent) {
+ $parent.addClass('is-loading')
+ .find('.custom-notification-event-loading')
+ .removeClass('fa-check')
+ .addClass('fa-spin fa-spinner')
+ .removeClass('is-done');
+ }
+
+ saveEvent($checkbox, $parent) {
+ const form = $parent.parents('form:first');
+
+ this.showCheckboxLoadingSpinner($parent);
+
+ axios[form.attr('method')](form.attr('action'), form.serialize())
+ .then(({ data }) => {
$checkbox.enable();
if (data.saved) {
$parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
- return setTimeout(function() {
- return $parent.removeClass('is-loading').find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
+ setTimeout(() => {
+ $parent.removeClass('is-loading')
+ .find('.custom-notification-event-loading')
+ .toggleClass('fa-spin fa-spinner fa-check is-done');
}, 2000);
}
- });
- };
-
- return NotificationsForm;
- })();
-}).call(window);
+ })
+ .catch(() => flash(__('There was an error saving your notification settings.')));
+ }
+}
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 01110420cca..86a43b66cc8 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,78 +1,74 @@
-import '~/lib/utils/common_utils';
-import '~/lib/utils/url_utility';
+import $ from 'jquery';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import axios from './lib/utils/axios_utils';
+import { removeParams } from './lib/utils/url_utility';
-(() => {
- const ENDLESS_SCROLL_BOTTOM_PX = 400;
- const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
+const ENDLESS_SCROLL_BOTTOM_PX = 400;
+const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
- const Pager = {
- 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) {
- this.offset = 0;
- this.getOld();
- }
- this.initLoadMore();
- },
+export default {
+ init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) {
+ this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
+ this.limit = limit;
+ this.offset = parseInt(getParameterByName('offset'), 10) || this.limit;
+ this.disable = disable;
+ this.prepareData = prepareData;
+ this.callback = callback;
+ this.loading = $('.loading').first();
+ if (preload) {
+ this.offset = 0;
+ this.getOld();
+ }
+ this.initLoadMore();
+ },
- getOld() {
- this.loading.show();
- $.ajax({
- type: 'GET',
- url: this.url,
- data: `limit=${this.limit}&offset=${this.offset}`,
- dataType: 'json',
- error: () => this.loading.hide(),
- success: (data) => {
- this.append(data.count, this.prepareData(data.html));
- this.callback();
+ getOld() {
+ this.loading.show();
+ axios.get(this.url, {
+ params: {
+ limit: this.limit,
+ offset: this.offset,
+ },
+ }).then(({ data }) => {
+ this.append(data.count, this.prepareData(data.html));
+ this.callback();
- // keep loading until we've filled the viewport height
- if (!this.disable && !this.isScrollable()) {
- this.getOld();
- } else {
- this.loading.hide();
- }
- },
- });
- },
-
- append(count, html) {
- $('.content_list').append(html);
- if (count > 0) {
- this.offset += count;
+ // keep loading until we've filled the viewport height
+ if (!this.disable && !this.isScrollable()) {
+ this.getOld();
} else {
- this.disable = true;
+ this.loading.hide();
}
- },
+ }).catch(() => this.loading.hide());
+ },
- isScrollable() {
- const $w = $(window);
- return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX;
- },
+ append(count, html) {
+ $('.content_list').append(html);
+ if (count > 0) {
+ this.offset += count;
+ } else {
+ this.disable = true;
+ }
+ },
- initLoadMore() {
- $(document).unbind('scroll');
- $(document).endlessScroll({
- bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
- fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS,
- fireOnce: true,
- ceaseFire: () => this.disable === true,
- callback: () => {
- if (!this.loading.is(':visible')) {
- this.loading.show();
- this.getOld();
- }
- },
- });
- },
- };
+ isScrollable() {
+ const $w = $(window);
+ return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX;
+ },
- window.Pager = Pager;
-})();
+ initLoadMore() {
+ $(document).off('scroll');
+ $(document).endlessScroll({
+ bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
+ fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS,
+ fireOnce: true,
+ ceaseFire: () => this.disable === true,
+ callback: () => {
+ if (!this.loading.is(':visible')) {
+ this.loading.show();
+ this.getOld();
+ }
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
new file mode 100644
index 00000000000..15e737fff05
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
@@ -0,0 +1,37 @@
+import $ from 'jquery';
+import { truncate } from '../../../lib/utils/text_utility';
+
+const MAX_MESSAGE_LENGTH = 500;
+const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
+
+export default class AbuseReports {
+ constructor() {
+ $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
+ $(document)
+ .off('click', MESSAGE_CELL_SELECTOR)
+ .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
+ }
+
+ truncateLongMessage() {
+ const $messageCellElement = $(this);
+ const reportMessage = $messageCellElement.text();
+ if (reportMessage.length > MAX_MESSAGE_LENGTH) {
+ $messageCellElement.data('originalMessage', reportMessage);
+ $messageCellElement.data('messageTruncated', 'true');
+ $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH));
+ }
+ }
+
+ toggleMessageTruncation() {
+ const $messageCellElement = $(this);
+ const originalMessage = $messageCellElement.data('originalMessage');
+ if (!originalMessage) return;
+ if ($messageCellElement.data('messageTruncated') === 'true') {
+ $messageCellElement.data('messageTruncated', 'false');
+ $messageCellElement.text(originalMessage);
+ } else {
+ $messageCellElement.data('messageTruncated', 'true');
+ $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
new file mode 100644
index 00000000000..d76b1f174fc
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -0,0 +1,3 @@
+import AbuseReports from './abuse_reports';
+
+document.addEventListener('DOMContentLoaded', () => new AbuseReports());
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
new file mode 100644
index 00000000000..91f154b7ecd
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import { refreshCurrentPage } from '../../lib/utils/url_utility';
+
+function showBlacklistType() {
+ if ($('input[name="blacklist_type"]:checked').val() === 'file') {
+ $('.blacklist-file').show();
+ $('.blacklist-raw').hide();
+ } else {
+ $('.blacklist-file').hide();
+ $('.blacklist-raw').show();
+ }
+}
+
+export default function adminInit() {
+ const modal = $('.change-owner-holder');
+
+ $('input#user_force_random_password').on('change', function randomPasswordClick() {
+ const $elems = $('#user_password, #user_password_confirmation');
+ if ($(this).attr('checked')) {
+ $elems.val('').prop('disabled', true);
+ } else {
+ $elems.prop('disabled', false);
+ }
+ });
+
+ $('body').on('click', '.js-toggle-colors-link', (e) => {
+ e.preventDefault();
+ $('.js-toggle-colors-container').toggle();
+ });
+
+ $('.log-tabs a').on('click', function logTabsClick(e) {
+ e.preventDefault();
+ $(this).tab('show');
+ });
+
+ $('.log-bottom').on('click', (e) => {
+ e.preventDefault();
+ const $visibleLog = $('.file-content:visible');
+ $visibleLog.animate({
+ scrollTop: $visibleLog.find('ol').height(),
+ }, 'fast');
+ });
+
+ $('.change-owner-link').on('click', function changeOwnerLinkClick(e) {
+ e.preventDefault();
+ $(this).hide();
+ modal.show();
+ });
+
+ $('.change-owner-cancel-link').on('click', (e) => {
+ e.preventDefault();
+ modal.hide();
+ $('.change-owner-link').show();
+ });
+
+ $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
+
+ $("input[name='blacklist_type']").on('click', showBlacklistType);
+ showBlacklistType();
+}
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
new file mode 100644
index 00000000000..e7ceccb6f47
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+export default () => {
+ $('input#broadcast_message_color').on('input', function onMessageColorInput() {
+ const previewColor = $(this).val();
+ $('div.broadcast-message-preview').css('background-color', previewColor);
+ });
+
+ $('input#broadcast_message_font').on('input', function onMessageFontInput() {
+ const previewColor = $(this).val();
+ $('div.broadcast-message-preview').css('color', previewColor);
+ });
+
+ const previewPath = $('textarea#broadcast_message_message').data('previewPath');
+
+ $('textarea#broadcast_message_message').on('input', _.debounce(function onMessageInput() {
+ const message = $(this).val();
+ if (message === '') {
+ $('.js-broadcast-message-preview').text('Your message here');
+ } else {
+ axios.post(previewPath, {
+ broadcast_message: {
+ message,
+ },
+ })
+ .then(({ data }) => {
+ $('.js-broadcast-message-preview').html(data.message);
+ })
+ .catch(() => flash(__('An error occurred while rendering preview broadcast message')));
+ }
+ }, 250));
+};
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
new file mode 100644
index 00000000000..d6cc6a850eb
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
@@ -0,0 +1,3 @@
+import initBroadcastMessagesForm from './broadcast_message';
+
+document.addEventListener('DOMContentLoaded', initBroadcastMessagesForm);
diff --git a/app/assets/javascripts/pages/admin/cohorts/index.js b/app/assets/javascripts/pages/admin/cohorts/index.js
new file mode 100644
index 00000000000..2d5020dbef4
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/cohorts/index.js
@@ -0,0 +1,3 @@
+import initUsagePing from './usage_ping';
+
+document.addEventListener('DOMContentLoaded', initUsagePing);
diff --git a/app/assets/javascripts/pages/admin/cohorts/usage_ping.js b/app/assets/javascripts/pages/admin/cohorts/usage_ping.js
new file mode 100644
index 00000000000..914a9661c27
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/cohorts/usage_ping.js
@@ -0,0 +1,13 @@
+import axios from '../../../lib/utils/axios_utils';
+import { __ } from '../../../locale';
+import flash from '../../../flash';
+
+export default function UsagePing() {
+ const el = document.querySelector('.usage-data');
+
+ axios.get(el.dataset.endpoint, {
+ responseType: 'text',
+ }).then(({ data }) => {
+ el.innerHTML = data;
+ }).catch(() => flash(__('Error fetching usage ping data.')));
+}
diff --git a/app/assets/javascripts/pages/admin/conversational_development_index/show/index.js b/app/assets/javascripts/pages/admin/conversational_development_index/show/index.js
new file mode 100644
index 00000000000..c1056537f90
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/conversational_development_index/show/index.js
@@ -0,0 +1,3 @@
+import UserCallout from '~/user_callout';
+
+document.addEventListener('DOMContentLoaded', () => new UserCallout());
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
new file mode 100644
index 00000000000..d3d125a1859
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/groups/edit/index.js
@@ -0,0 +1,3 @@
+import groupAvatar from '~/group_avatar';
+
+document.addEventListener('DOMContentLoaded', groupAvatar);
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
new file mode 100644
index 00000000000..21f1ce222ac
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -0,0 +1,9 @@
+import BindInOut from '../../../../behaviors/bind_in_out';
+import Group from '../../../../group';
+import groupAvatar from '../../../../group_avatar';
+
+document.addEventListener('DOMContentLoaded', () => {
+ BindInOut.initAll();
+ new Group(); // eslint-disable-line no-new
+ groupAvatar();
+});
diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js
new file mode 100644
index 00000000000..b0cdad627a6
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/groups/show/index.js
@@ -0,0 +1,3 @@
+import UsersSelect from '../../../../users_select';
+
+document.addEventListener('DOMContentLoaded', () => new UsersSelect());
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
new file mode 100644
index 00000000000..78a5c4c27be
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -0,0 +1,3 @@
+import DueDateSelectors from '~/due_date_select';
+
+document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js
new file mode 100644
index 00000000000..e50b61f09e2
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/index.js
@@ -0,0 +1,3 @@
+import initAdmin from './admin';
+
+document.addEventListener('DOMContentLoaded', initAdmin);
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
new file mode 100644
index 00000000000..ba1d8e4d8db
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
@@ -0,0 +1,49 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+ import createFlash from '~/flash';
+ import GlModal from '~/vue_shared/components/gl_modal.vue';
+ import { redirectTo } from '~/lib/utils/url_utility';
+ import { s__ } from '~/locale';
+
+ export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return s__('AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running.');
+ },
+ },
+ methods: {
+ onSubmit() {
+ return axios.post(this.url)
+ .then((response) => {
+ // follow the rediect to refresh the page
+ redirectTo(response.request.responseURL);
+ })
+ .catch((error) => {
+ createFlash(s__('AdminArea|Stopping jobs failed'));
+ throw error;
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <gl-modal
+ id="stop-jobs-modal"
+ :header-title-text="s__('AdminArea|Stop all jobs?')"
+ footer-primary-button-variant="danger"
+ :footer-primary-button-text="s__('AdminArea|Stop jobs')"
+ @submit="onSubmit"
+ >
+ {{ text }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
new file mode 100644
index 00000000000..5a4f8c6e745
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import stopJobsModal from './components/stop_jobs_modal.vue';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const stopJobsButton = document.getElementById('stop-jobs-button');
+ if (stopJobsButton) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#stop-jobs-modal',
+ components: {
+ stopJobsModal,
+ },
+ mounted() {
+ stopJobsButton.classList.remove('disabled');
+ },
+ render(createElement) {
+ return createElement('stop-jobs-modal', {
+ props: {
+ url: stopJobsButton.dataset.url,
+ },
+ });
+ },
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js
new file mode 100644
index 00000000000..5de1d4d6344
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/labels/edit/index.js
@@ -0,0 +1,3 @@
+import Labels from '../../../../labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js
new file mode 100644
index 00000000000..5de1d4d6344
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/labels/new/index.js
@@ -0,0 +1,3 @@
+import Labels from '../../../../labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
new file mode 100644
index 00000000000..31c96eb87af
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -0,0 +1,9 @@
+import ProjectsList from '../../../projects_list';
+import NamespaceSelect from '../../../namespace_select';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectsList(); // eslint-disable-line no-new
+
+ document.querySelectorAll('.js-namespace-select')
+ .forEach(dropdown => new NamespaceSelect({ dropdown }));
+});
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
new file mode 100644
index 00000000000..343c65edb37
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -0,0 +1,125 @@
+<script>
+ import _ from 'underscore';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { s__, sprintf } from '~/locale';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ deleteProjectUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ csrfToken: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ enteredProjectName: '',
+ };
+ },
+ computed: {
+ title() {
+ return sprintf(s__('AdminProjects|Delete Project %{projectName}?'),
+ {
+ projectName: `'${_.escape(this.projectName)}'`,
+ },
+ false,
+ );
+ },
+ text() {
+ return sprintf(s__(`AdminProjects|
+ You’re about to permanently delete the project %{projectName}, its repository,
+ and all related resources including issues, merge requests, etc.. Once you confirm and press
+ %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`),
+ {
+ projectName: `<strong>${_.escape(this.projectName)}</strong>`,
+ strong_start: '<strong>',
+ strong_end: '</strong>',
+ },
+ false,
+ );
+ },
+ confirmationTextLabel() {
+ return sprintf(s__('AdminUsers|To confirm, type %{projectName}'),
+ {
+ projectName: `<code>${_.escape(this.projectName)}</code>`,
+ },
+ false,
+ );
+ },
+ primaryButtonLabel() {
+ return s__('AdminProjects|Delete project');
+ },
+ canSubmit() {
+ return this.enteredProjectName === this.projectName;
+ },
+ },
+ methods: {
+ onCancel() {
+ this.enteredProjectName = '';
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ this.enteredProjectName = '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-project-modal"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="primaryButtonLabel"
+ :submit-disabled="!canSubmit"
+ @submit="onSubmit"
+ @cancel="onCancel"
+ >
+ <template
+ slot="body"
+ slot-scope="props"
+ >
+ <p v-html="props.text"></p>
+ <p v-html="confirmationTextLabel"></p>
+ <form
+ ref="form"
+ :action="deleteProjectUrl"
+ method="post"
+ >
+ <input
+ ref="method"
+ type="hidden"
+ name="_method"
+ value="delete"
+ />
+ <input
+ type="hidden"
+ name="authenticity_token"
+ :value="csrfToken"
+ />
+ <input
+ name="projectName"
+ class="form-control"
+ type="text"
+ v-model="enteredProjectName"
+ aria-labelledby="input-label"
+ autocomplete="off"
+ />
+ </form>
+ </template>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js
new file mode 100644
index 00000000000..ddbefec87b6
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index/index.js
@@ -0,0 +1,38 @@
+import $ from 'jquery';
+import Vue from 'vue';
+
+import Translate from '~/vue_shared/translate';
+import csrf from '~/lib/utils/csrf';
+
+import deleteProjectModal from './components/delete_project_modal.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ Vue.use(Translate);
+
+ const deleteProjectModalEl = document.getElementById('delete-project-modal');
+
+ const deleteModal = new Vue({
+ el: deleteProjectModalEl,
+ data: {
+ deleteProjectUrl: '',
+ projectName: '',
+ },
+ render(createElement) {
+ return createElement(deleteProjectModal, {
+ props: {
+ deleteProjectUrl: this.deleteProjectUrl,
+ projectName: this.projectName,
+ csrfToken: csrf.token,
+ },
+ });
+ },
+ });
+
+ $(document).on('shown.bs.modal', (event) => {
+ if (event.relatedTarget.classList.contains('delete-project-button')) {
+ const buttonProps = event.relatedTarget.dataset;
+ deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl;
+ deleteModal.projectName = buttonProps.projectName;
+ }
+ });
+});
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
new file mode 100644
index 00000000000..0e3ac636661
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -0,0 +1,174 @@
+<script>
+ import _ from 'underscore';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { s__, sprintf } from '~/locale';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ deleteUserUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ blockUserUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ deleteContributions: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ csrfToken: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ enteredUsername: '',
+ };
+ },
+ computed: {
+ title() {
+ const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?');
+ const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?');
+
+ return sprintf(
+ this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle, {
+ username: `'${_.escape(this.username)}'`,
+ }, false);
+ },
+ text() {
+ const keepContributionsText = s__(`AdminArea|
+ You are about to permanently delete the user %{username}.
+ This will delete all of the issues, merge requests, and groups linked to them.
+ To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
+ Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
+
+ const deleteContributionsText = s__(`AdminArea|
+ You are about to permanently delete the user %{username}.
+ Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user".
+ To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
+ Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
+
+ return sprintf(this.deleteContributions ? deleteContributionsText : keepContributionsText,
+ {
+ username: `<strong>${_.escape(this.username)}</strong>`,
+ strong_start: '<strong>',
+ strong_end: '</strong>',
+ },
+ false,
+ );
+ },
+ confirmationTextLabel() {
+ return sprintf(s__('AdminUsers|To confirm, type %{username}'),
+ {
+ username: `<code>${_.escape(this.username)}</code>`,
+ },
+ false,
+ );
+ },
+ primaryButtonLabel() {
+ const keepContributionsLabel = s__('AdminUsers|Delete user');
+ const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions');
+
+ return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel;
+ },
+ secondaryButtonLabel() {
+ return s__('AdminUsers|Block user');
+ },
+ canSubmit() {
+ return this.enteredUsername === this.username;
+ },
+ },
+ methods: {
+ onCancel() {
+ this.enteredUsername = '';
+ },
+ onSecondaryAction() {
+ const form = this.$refs.form;
+
+ form.action = this.blockUserUrl;
+ this.$refs.method.value = 'put';
+
+ form.submit();
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ this.enteredUsername = '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-user-modal"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="primaryButtonLabel"
+ :secondary-button-label="secondaryButtonLabel"
+ :submit-disabled="!canSubmit"
+ @submit="onSubmit"
+ @cancel="onCancel"
+ >
+ <template
+ slot="body"
+ slot-scope="props"
+ >
+ <p v-html="props.text"></p>
+ <p v-html="confirmationTextLabel"></p>
+ <form
+ ref="form"
+ :action="deleteUserUrl"
+ method="post"
+ >
+ <input
+ ref="method"
+ type="hidden"
+ name="_method"
+ value="delete"
+ />
+ <input
+ type="hidden"
+ name="authenticity_token"
+ :value="csrfToken"
+ />
+ <input
+ type="text"
+ name="username"
+ class="form-control"
+ v-model="enteredUsername"
+ aria-labelledby="input-label"
+ autocomplete="off"
+ />
+ </form>
+ </template>
+ <template
+ slot="secondary-button"
+ slot-scope="props"
+ >
+ <button
+ type="button"
+ class="btn js-secondary-button btn-warning"
+ :disabled="!canSubmit"
+ @click="onSecondaryAction"
+ data-dismiss="modal"
+ >
+ {{ secondaryButtonLabel }}
+ </button>
+ </template>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
new file mode 100644
index 00000000000..06599c3fd5f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -0,0 +1,44 @@
+import $ from 'jquery';
+import Vue from 'vue';
+
+import Translate from '~/vue_shared/translate';
+import csrf from '~/lib/utils/csrf';
+
+import deleteUserModal from './components/delete_user_modal.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ Vue.use(Translate);
+
+ const deleteUserModalEl = document.getElementById('delete-user-modal');
+
+ const deleteModal = new Vue({
+ el: deleteUserModalEl,
+ data: {
+ deleteUserUrl: '',
+ blockUserUrl: '',
+ deleteContributions: '',
+ username: '',
+ },
+ render(createElement) {
+ return createElement(deleteUserModal, {
+ props: {
+ deleteUserUrl: this.deleteUserUrl,
+ blockUserUrl: this.blockUserUrl,
+ deleteContributions: this.deleteContributions,
+ username: this.username,
+ csrfToken: csrf.token,
+ },
+ });
+ },
+ });
+
+ $(document).on('shown.bs.modal', (event) => {
+ if (event.relatedTarget.classList.contains('delete-user-button')) {
+ const buttonProps = event.relatedTarget.dataset;
+ deleteModal.deleteUserUrl = buttonProps.deleteUserUrl;
+ deleteModal.blockUserUrl = buttonProps.blockUserUrl;
+ deleteModal.deleteContributions = event.relatedTarget.hasAttribute('data-delete-contributions');
+ deleteModal.username = buttonProps.username;
+ }
+ });
+});
diff --git a/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js
new file mode 100644
index 00000000000..9ab73be80a0
--- /dev/null
+++ b/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js
@@ -0,0 +1,19 @@
+export default class CILintEditor {
+ constructor() {
+ this.editor = window.ace.edit('ci-editor');
+ this.textarea = document.querySelector('#content');
+ this.clearYml = document.querySelector('.clear-yml');
+
+ this.editor.getSession().setMode('ace/mode/yaml');
+ this.editor.on('input', () => {
+ const content = this.editor.getSession().getValue();
+ this.textarea.value = content;
+ });
+
+ this.clearYml.addEventListener('click', this.clear.bind(this));
+ }
+
+ clear() {
+ this.editor.setValue('');
+ }
+}
diff --git a/app/assets/javascripts/pages/ci/lints/new/index.js b/app/assets/javascripts/pages/ci/lints/new/index.js
new file mode 100644
index 00000000000..8e8a843da0b
--- /dev/null
+++ b/app/assets/javascripts/pages/ci/lints/new/index.js
@@ -0,0 +1,3 @@
+import CILintEditor from '../ci_lint_editor';
+
+document.addEventListener('DOMContentLoaded', () => new CILintEditor());
diff --git a/app/assets/javascripts/pages/ci/lints/show/index.js b/app/assets/javascripts/pages/ci/lints/show/index.js
new file mode 100644
index 00000000000..8e8a843da0b
--- /dev/null
+++ b/app/assets/javascripts/pages/ci/lints/show/index.js
@@ -0,0 +1,3 @@
+import CILintEditor from '../ci_lint_editor';
+
+document.addEventListener('DOMContentLoaded', () => new CILintEditor());
diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js
new file mode 100644
index 00000000000..328b6541636
--- /dev/null
+++ b/app/assets/javascripts/pages/constants.js
@@ -0,0 +1,6 @@
+/* eslint-disable import/prefer-default-export */
+
+export const FILTERED_SEARCH = {
+ MERGE_REQUESTS: 'merge_requests',
+ ISSUES: 'issues',
+};
diff --git a/app/assets/javascripts/pages/dashboard/activity/index.js b/app/assets/javascripts/pages/dashboard/activity/index.js
new file mode 100644
index 00000000000..1b887cad496
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/activity/index.js
@@ -0,0 +1,3 @@
+import Activities from '~/activities';
+
+document.addEventListener('DOMContentLoaded', () => new Activities());
diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js
new file mode 100644
index 00000000000..79987642796
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js
@@ -0,0 +1,3 @@
+import initGroupsList from '~/groups';
+
+document.addEventListener('DOMContentLoaded', initGroupsList);
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
new file mode 100644
index 00000000000..c4901dd1cb6
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -0,0 +1,7 @@
+import projectSelect from '~/project_select';
+import initLegacyFilters from '~/init_legacy_filters';
+
+document.addEventListener('DOMContentLoaded', () => {
+ projectSelect();
+ initLegacyFilters();
+});
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
new file mode 100644
index 00000000000..c4901dd1cb6
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -0,0 +1,7 @@
+import projectSelect from '~/project_select';
+import initLegacyFilters from '~/init_legacy_filters';
+
+document.addEventListener('DOMContentLoaded', () => {
+ projectSelect();
+ initLegacyFilters();
+});
diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js
new file mode 100644
index 00000000000..38ddebe30d9
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js
@@ -0,0 +1,3 @@
+import projectSelect from '~/project_select';
+
+document.addEventListener('DOMContentLoaded', projectSelect);
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
new file mode 100644
index 00000000000..397149aaa9e
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -0,0 +1,9 @@
+import Milestone from '~/milestone';
+import Sidebar from '~/right_sidebar';
+import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Milestone(); // eslint-disable-line no-new
+ new Sidebar(); // eslint-disable-line no-new
+ new MountMilestoneSidebar(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js
new file mode 100644
index 00000000000..0c585e162cb
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/projects/index.js
@@ -0,0 +1,3 @@
+import ProjectsList from '~/projects_list';
+
+document.addEventListener('DOMContentLoaded', () => new ProjectsList());
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/index.js b/app/assets/javascripts/pages/dashboard/todos/index/index.js
new file mode 100644
index 00000000000..9d2c2f2994f
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/todos/index/index.js
@@ -0,0 +1,3 @@
+import Todos from './todos';
+
+document.addEventListener('DOMContentLoaded', () => new Todos());
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
new file mode 100644
index 00000000000..c334eaa90f8
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -0,0 +1,151 @@
+/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
+
+import $ from 'jquery';
+import { visitUrl } from '~/lib/utils/url_utility';
+import UsersSelect from '~/users_select';
+import { isMetaClick } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import flash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+
+export default class Todos {
+ constructor() {
+ this.initFilters();
+ this.bindEvents();
+ this.todo_ids = [];
+
+ this.cleanupWrapper = this.cleanup.bind(this);
+ document.addEventListener('beforeunload', this.cleanupWrapper);
+ }
+
+ cleanup() {
+ this.unbindEvents();
+ document.removeEventListener('beforeunload', this.cleanupWrapper);
+ }
+
+ unbindEvents() {
+ $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
+ $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
+ $('.todo').off('click', this.goToTodoUrl);
+ }
+
+ bindEvents() {
+ this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this);
+ this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this);
+
+ $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
+ $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
+ $('.todo').on('click', this.goToTodoUrl);
+ }
+
+ initFilters() {
+ this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
+ this.initFilterDropdown($('.js-type-search'), 'type');
+ this.initFilterDropdown($('.js-action-search'), 'action_id');
+
+ return new UsersSelect();
+ }
+
+ initFilterDropdown($dropdown, fieldName, searchFields) {
+ $dropdown.glDropdown({
+ fieldName,
+ selectable: true,
+ filterable: searchFields ? true : false,
+ search: { fields: searchFields },
+ data: $dropdown.data('data'),
+ clicked: () => $dropdown.closest('form.filter-form').submit(),
+ });
+ }
+
+ updateRowStateClicked(e) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const target = e.target;
+ target.setAttribute('disabled', true);
+ target.classList.add('disabled');
+
+ axios[target.dataset.method](target.dataset.href)
+ .then(({ data }) => {
+ this.updateRowState(target);
+ this.updateBadges(data);
+ }).catch(() => flash(__('Error updating todo status.')));
+ }
+
+ updateRowState(target) {
+ const row = target.closest('li');
+ const restoreBtn = row.querySelector('.js-undo-todo');
+ const doneBtn = row.querySelector('.js-done-todo');
+
+ target.classList.add('hidden');
+ target.removeAttribute('disabled');
+ target.classList.remove('disabled');
+
+ if (target === doneBtn) {
+ row.classList.add('done-reversible');
+ restoreBtn.classList.remove('hidden');
+ } else if (target === restoreBtn) {
+ row.classList.remove('done-reversible');
+ doneBtn.classList.remove('hidden');
+ } else {
+ row.parentNode.removeChild(row);
+ }
+ }
+
+ updateAllStateClicked(e) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const target = e.currentTarget;
+ target.setAttribute('disabled', true);
+ target.classList.add('disabled');
+
+ axios[target.dataset.method](target.dataset.href, {
+ ids: this.todo_ids,
+ }).then(({ data }) => {
+ this.updateAllState(target, data);
+ this.updateBadges(data);
+ }).catch(() => flash(__('Error updating status for all todos.')));
+ }
+
+ updateAllState(target, data) {
+ const markAllDoneBtn = document.querySelector('.js-todos-mark-all');
+ const undoAllBtn = document.querySelector('.js-todos-undo-all');
+ const todoListContainer = document.querySelector('.js-todos-list-container');
+ const nothingHereContainer = document.querySelector('.js-nothing-here-container');
+
+ target.removeAttribute('disabled');
+ target.classList.remove('disabled');
+
+ this.todo_ids = (target === markAllDoneBtn) ? data.updated_ids : [];
+ undoAllBtn.classList.toggle('hidden');
+ markAllDoneBtn.classList.toggle('hidden');
+ todoListContainer.classList.toggle('hidden');
+ nothingHereContainer.classList.toggle('hidden');
+ }
+
+ updateBadges(data) {
+ $(document).trigger('todo:toggle', data.count);
+ document.querySelector('.todos-pending .badge').innerHTML = data.count;
+ document.querySelector('.todos-done .badge').innerHTML = data.done_count;
+ }
+
+ goToTodoUrl(e) {
+ const todoLink = this.dataset.url;
+
+ if (!todoLink || e.target.tagName === 'A' || e.target.tagName === 'IMG') {
+ return;
+ }
+
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (isMetaClick(e)) {
+ const windowTarget = '_blank';
+
+ window.open(todoLink, windowTarget);
+ } else {
+ visitUrl(todoLink);
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js
new file mode 100644
index 00000000000..3c7edbdd7c7
--- /dev/null
+++ b/app/assets/javascripts/pages/explore/groups/index.js
@@ -0,0 +1,16 @@
+import GroupsList from '~/groups_list';
+import Landing from '~/landing';
+import initGroupsList from '../../../groups';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new GroupsList(); // eslint-disable-line no-new
+ initGroupsList();
+ const landingElement = document.querySelector('.js-explore-groups-landing');
+ if (!landingElement) return;
+ const exploreGroupsLanding = new Landing(
+ landingElement,
+ landingElement.querySelector('.dismiss-button'),
+ 'explore_groups_landing_dismissed',
+ );
+ exploreGroupsLanding.toggle();
+});
diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js
new file mode 100644
index 00000000000..0c585e162cb
--- /dev/null
+++ b/app/assets/javascripts/pages/explore/projects/index.js
@@ -0,0 +1,3 @@
+import ProjectsList from '~/projects_list';
+
+document.addEventListener('DOMContentLoaded', () => new ProjectsList());
diff --git a/app/assets/javascripts/pages/groups/activity/index.js b/app/assets/javascripts/pages/groups/activity/index.js
new file mode 100644
index 00000000000..1b887cad496
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/activity/index.js
@@ -0,0 +1,3 @@
+import Activities from '~/activities';
+
+document.addEventListener('DOMContentLoaded', () => new Activities());
diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js
new file mode 100644
index 00000000000..5cfe8723204
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/boards/index.js
@@ -0,0 +1,9 @@
+import UsersSelect from '~/users_select';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import initBoards from '~/boards';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsersSelect(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ initBoards();
+});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
new file mode 100644
index 00000000000..bb91ac84ffb
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -0,0 +1,9 @@
+import groupAvatar from '~/group_avatar';
+import TransferDropdown from '~/groups/transfer_dropdown';
+import initConfirmDangerModal from '~/confirm_danger_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ groupAvatar();
+ new TransferDropdown(); // eslint-disable-line no-new
+ initConfirmDangerModal();
+});
diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js
new file mode 100644
index 00000000000..c22a164cd4e
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/group_members/index/index.js
@@ -0,0 +1,11 @@
+/* eslint-disable no-new */
+
+import memberExpirationDate from '~/member_expiration_date';
+import Members from '~/members';
+import UsersSelect from '~/users_select';
+
+document.addEventListener('DOMContentLoaded', () => {
+ memberExpirationDate();
+ new Members();
+ new UsersSelect();
+});
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
new file mode 100644
index 00000000000..d149b307e7f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -0,0 +1,10 @@
+import projectSelect from '~/project_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ });
+ projectSelect();
+});
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js
new file mode 100644
index 00000000000..6e45de2a724
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/labels/index/index.js
@@ -0,0 +1,3 @@
+import initLabels from '~/init_labels';
+
+document.addEventListener('DOMContentLoaded', initLabels);
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
new file mode 100644
index 00000000000..a5cc1f34b63
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -0,0 +1,10 @@
+import projectSelect from '~/project_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.MERGE_REQUESTS,
+ });
+ projectSelect();
+});
diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js
new file mode 100644
index 00000000000..ddd10fe5062
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm(false));
diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js
new file mode 100644
index 00000000000..ddd10fe5062
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/milestones/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm(false));
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
new file mode 100644
index 00000000000..88f40b5278e
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -0,0 +1,3 @@
+import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+
+document.addEventListener('DOMContentLoaded', initMilestonesShow);
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
new file mode 100644
index 00000000000..b2f275dc5ea
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -0,0 +1,9 @@
+import BindInOut from '~/behaviors/bind_in_out';
+import Group from '~/group';
+import groupAvatar from '~/group_avatar';
+
+document.addEventListener('DOMContentLoaded', () => {
+ BindInOut.initAll();
+ new Group(); // eslint-disable-line no-new
+ groupAvatar();
+});
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
new file mode 100644
index 00000000000..04a0d8117cc
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -0,0 +1,12 @@
+import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const variableListEl = document.querySelector('.js-ci-variable-list-section');
+ // eslint-disable-next-line no-new
+ new AjaxVariableList({
+ container: variableListEl,
+ saveButton: variableListEl.querySelector('.js-secret-variables-save-button'),
+ errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
+ saveEndpoint: variableListEl.dataset.saveEndpoint,
+ });
+});
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
new file mode 100644
index 00000000000..d7b35d2b26b
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -0,0 +1,22 @@
+/* eslint-disable no-new */
+
+import NewGroupChild from '~/groups/new_group_child';
+import notificationsDropdown from '~/notifications_dropdown';
+import NotificationsForm from '~/notifications_form';
+import ProjectsList from '~/projects_list';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import initGroupsList from '~/groups';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
+ new ShortcutsNavigation();
+ new NotificationsForm();
+ notificationsDropdown();
+ new ProjectsList();
+
+ if (newGroupChildWrapper) {
+ new NewGroupChild(newGroupChildWrapper);
+ }
+
+ initGroupsList();
+});
diff --git a/app/assets/javascripts/pages/help/index/index.js b/app/assets/javascripts/pages/help/index/index.js
new file mode 100644
index 00000000000..1bafe564a37
--- /dev/null
+++ b/app/assets/javascripts/pages/help/index/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import VersionCheckImage from '~/version_check_image';
+import docs from '~/docs/docs_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ docs();
+ VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
+});
diff --git a/app/assets/javascripts/pages/help/show/index.js b/app/assets/javascripts/pages/help/show/index.js
new file mode 100644
index 00000000000..ec426a850b6
--- /dev/null
+++ b/app/assets/javascripts/pages/help/show/index.js
@@ -0,0 +1,3 @@
+import initHelp from '~/help/help';
+
+document.addEventListener('DOMContentLoaded', initHelp);
diff --git a/app/assets/javascripts/pages/help/ui/index.js b/app/assets/javascripts/pages/help/ui/index.js
new file mode 100644
index 00000000000..709ca2f3828
--- /dev/null
+++ b/app/assets/javascripts/pages/help/ui/index.js
@@ -0,0 +1,3 @@
+import initUIKit from '~/ui_development_kit';
+
+document.addEventListener('DOMContentLoaded', initUIKit);
diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js
new file mode 100644
index 00000000000..68d4c1f049f
--- /dev/null
+++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js
@@ -0,0 +1,3 @@
+import UsersSelect from '~/users_select';
+
+document.addEventListener('DOMContentLoaded', () => new UsersSelect());
diff --git a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js
new file mode 100644
index 00000000000..bb86f72b95b
--- /dev/null
+++ b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js
@@ -0,0 +1,3 @@
+import initGitLabImportProject from '~/projects/project_import_gitlab_project';
+
+document.addEventListener('DOMContentLoaded', initGitLabImportProject);
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
new file mode 100644
index 00000000000..16f792d635a
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -0,0 +1,110 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+
+ import Flash from '~/flash';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { n__, s__, sprintf } from '~/locale';
+ import { redirectTo } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ issueCount: {
+ type: Number,
+ required: true,
+ },
+ mergeRequestCount: {
+ type: Number,
+ required: true,
+ },
+ milestoneId: {
+ type: Number,
+ required: true,
+ },
+ milestoneTitle: {
+ type: String,
+ required: true,
+ },
+ milestoneUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', { milestoneTitle: this.milestoneTitle });
+
+ if (this.issueCount === 0 && this.mergeRequestCount === 0) {
+ return sprintf(
+ s__(`Milestones|
+You’re about to permanently delete the milestone %{milestoneTitle} from this project.
+%{milestoneTitle} is not currently used in any issues or merge requests.`),
+ {
+ milestoneTitle,
+ },
+ false,
+ );
+ }
+
+ return sprintf(
+ s__(`Milestones|
+You’re about to permanently delete the milestone %{milestoneTitle} from this project and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}.
+Once deleted, it cannot be undone or recovered.`),
+ {
+ milestoneTitle,
+ issuesWithCount: n__('%d issue', '%d issues', this.issueCount),
+ mergeRequestsWithCount: n__('%d merge request', '%d merge requests', this.mergeRequestCount),
+ },
+ false,
+ );
+ },
+ title() {
+ return sprintf(s__('Milestones|Delete milestone %{milestoneTitle}?'), { milestoneTitle: this.milestoneTitle });
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('deleteMilestoneModal.requestStarted', this.milestoneUrl);
+
+ return axios.delete(this.milestoneUrl)
+ .then((response) => {
+ eventHub.$emit('deleteMilestoneModal.requestFinished', { milestoneUrl: this.milestoneUrl, successful: true });
+
+ // follow the rediect to milestones overview page
+ redirectTo(response.request.responseURL);
+ })
+ .catch((error) => {
+ eventHub.$emit('deleteMilestoneModal.requestFinished', { milestoneUrl: this.milestoneUrl, successful: false });
+
+ if (error.response && error.response.status === 404) {
+ Flash(sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle }));
+ } else {
+ Flash(sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { milestoneTitle: this.milestoneTitle }));
+ }
+ throw error;
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-milestone-modal"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="s__('Milestones|Delete milestone')"
+ @submit="onSubmit">
+
+ <template
+ slot="body"
+ slot-scope="props">
+ <p v-html="props.text"></p>
+ </template>
+
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
new file mode 100644
index 00000000000..22248418c41
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -0,0 +1,64 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+ import createFlash from '~/flash';
+ import GlModal from '~/vue_shared/components/gl_modal.vue';
+ import { s__, sprintf } from '~/locale';
+ import { visitUrl } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ milestoneTitle: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { milestoneTitle: this.milestoneTitle });
+ },
+ text() {
+ return s__(`Milestones|Promoting this milestone will make it available for all projects inside the group.
+ Existing project milestones with the same title will be merged.
+ This action cannot be reversed.`);
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('promoteMilestoneModal.requestStarted', this.url);
+ return axios.post(this.url, { params: { format: 'json' } })
+ .then((response) => {
+ eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: true });
+ visitUrl(response.data.url);
+ })
+ .catch((error) => {
+ eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: false });
+ createFlash(error);
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <gl-modal
+ id="promote-milestone-modal"
+ footer-primary-button-variant="warning"
+ :footer-primary-button-text="s__('Milestones|Promote Milestone')"
+ @submit="onSubmit"
+ >
+ <template
+ slot="title"
+ >
+ {{ title }}
+ </template>
+ {{ text }}
+ </gl-modal>
+</template>
+
diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
new file mode 100644
index 00000000000..d51b5c221e3
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import deleteMilestoneModal from './components/delete_milestone_modal.vue';
+import eventHub from './event_hub';
+
+export default () => {
+ Vue.use(Translate);
+
+ const onRequestFinished = ({ milestoneUrl, successful }) => {
+ const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+
+ button.querySelector('.js-loading-icon').classList.add('hidden');
+ };
+
+ const onRequestStarted = (milestoneUrl) => {
+ const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`);
+ button.setAttribute('disabled', '');
+ button.querySelector('.js-loading-icon').classList.remove('hidden');
+ eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ milestoneId: parseInt(button.dataset.milestoneId, 10),
+ milestoneTitle: button.dataset.milestoneTitle,
+ milestoneUrl: button.dataset.milestoneUrl,
+ issueCount: parseInt(button.dataset.milestoneIssueCount, 10),
+ mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10),
+ };
+ eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted);
+ eventHub.$emit('deleteMilestoneModal.props', modalProps);
+ };
+
+ const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
+ deleteMilestoneButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('deleteMilestoneModal.mounted', () => {
+ deleteMilestoneButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ return new Vue({
+ el: '#delete-milestone-modal',
+ components: {
+ deleteMilestoneModal,
+ },
+ data() {
+ return {
+ modalProps: {
+ milestoneId: -1,
+ milestoneTitle: '',
+ milestoneUrl: '',
+ issueCount: -1,
+ mergeRequestCount: -1,
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('deleteMilestoneModal.props', this.setModalProps);
+ eventHub.$emit('deleteMilestoneModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('deleteMilestoneModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement(deleteMilestoneModal, {
+ props: this.modalProps,
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/pages/milestones/shared/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js
new file mode 100644
index 00000000000..dabfe32848b
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/index.js
@@ -0,0 +1,7 @@
+import initDeleteMilestoneModal from './delete_milestone_modal_init';
+import initPromoteMilestoneModal from './promote_milestone_modal_init';
+
+export default () => {
+ initDeleteMilestoneModal();
+ initPromoteMilestoneModal();
+};
diff --git a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
new file mode 100644
index 00000000000..b2a896a3265
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
@@ -0,0 +1,11 @@
+/* eslint-disable no-new */
+
+import Milestone from '~/milestone';
+import Sidebar from '~/right_sidebar';
+import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
+
+export default () => {
+ new Milestone();
+ new Sidebar();
+ new MountMilestoneSidebar();
+};
diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js
new file mode 100644
index 00000000000..d00f81c9094
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
+import eventHub from './event_hub';
+
+Vue.use(Translate);
+
+export default () => {
+ const onRequestFinished = ({ milestoneUrl, successful }) => {
+ const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+ };
+
+ const onRequestStarted = (milestoneUrl) => {
+ const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`);
+ button.setAttribute('disabled', '');
+ eventHub.$once('promoteMilestoneModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ milestoneTitle: button.dataset.milestoneTitle,
+ url: button.dataset.url,
+ };
+ eventHub.$once('promoteMilestoneModal.requestStarted', onRequestStarted);
+ eventHub.$emit('promoteMilestoneModal.props', modalProps);
+ };
+
+ const promoteMilestoneButtons = document.querySelectorAll('.js-promote-project-milestone-button');
+ promoteMilestoneButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('promoteMilestoneModal.mounted', () => {
+ promoteMilestoneButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ const promoteMilestoneModal = document.getElementById('promote-milestone-modal');
+ let promoteMilestoneComponent;
+
+ if (promoteMilestoneModal) {
+ promoteMilestoneComponent = new Vue({
+ el: promoteMilestoneModal,
+ components: {
+ PromoteMilestoneModal,
+ },
+ data() {
+ return {
+ modalProps: {
+ milestoneTitle: '',
+ url: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteMilestoneModal.props', this.setModalProps);
+ eventHub.$emit('promoteMilestoneModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteMilestoneModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement('promote-milestone-modal', {
+ props: this.modalProps,
+ });
+ },
+ });
+ }
+
+ return promoteMilestoneComponent;
+};
diff --git a/app/assets/javascripts/pages/omniauth_callbacks/index.js b/app/assets/javascripts/pages/omniauth_callbacks/index.js
new file mode 100644
index 00000000000..c2c069d1ca8
--- /dev/null
+++ b/app/assets/javascripts/pages/omniauth_callbacks/index.js
@@ -0,0 +1,3 @@
+import initU2F from '../../shared/sessions/u2f';
+
+document.addEventListener('DOMContentLoaded', initU2F);
diff --git a/app/assets/javascripts/pages/profiles/accounts/show/index.js b/app/assets/javascripts/pages/profiles/accounts/show/index.js
new file mode 100644
index 00000000000..96c3d725780
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/accounts/show/index.js
@@ -0,0 +1,3 @@
+import initProfileAccount from '~/profile/account';
+
+document.addEventListener('DOMContentLoaded', initProfileAccount);
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
new file mode 100644
index 00000000000..04e50963699
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import '~/profile/gl_crop';
+import Profile from '~/profile/profile';
+
+document.addEventListener('DOMContentLoaded', () => {
+ $(document).on('input.ssh_key', '#key_key', function () { // eslint-disable-line func-names
+ const $title = $('#key_title');
+ const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
+
+ // Extract the SSH Key title from its comment
+ if (comment && comment.length > 1) {
+ $title.val(comment[1]).change();
+ }
+ });
+
+ new Profile(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/profiles/index/index.js b/app/assets/javascripts/pages/profiles/index/index.js
new file mode 100644
index 00000000000..9bd430f4f11
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/index/index.js
@@ -0,0 +1,7 @@
+import NotificationsForm from '../../../notifications_form';
+import notificationsDropdown from '../../../notifications_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new NotificationsForm(); // eslint-disable-line no-new
+ notificationsDropdown();
+});
diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js
new file mode 100644
index 00000000000..2e24a10fa5c
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js
@@ -0,0 +1,7 @@
+import NotificationsForm from '../../../../notifications_form';
+import notificationsDropdown from '../../../../notifications_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new NotificationsForm(); // eslint-disable-line no-new
+ notificationsDropdown();
+});
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
new file mode 100644
index 00000000000..78a5c4c27be
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -0,0 +1,3 @@
+import DueDateSelectors from '~/due_date_select';
+
+document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
new file mode 100644
index 00000000000..fbdef329ab2
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+import U2FRegister from '~/u2f/register';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const twoFactorNode = document.querySelector('.js-two-factor-auth');
+ const skippable = twoFactorNode.dataset.twoFactorSkippable === 'true';
+ if (skippable) {
+ const button = `<a class="btn btn-xs btn-warning pull-right" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
+ const flashAlert = document.querySelector('.flash-alert .container-fluid');
+ if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button);
+ }
+
+ const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f);
+ u2fRegister.start();
+});
diff --git a/app/assets/javascripts/pages/projects/activity/index.js b/app/assets/javascripts/pages/projects/activity/index.js
new file mode 100644
index 00000000000..5543ad82428
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/activity/index.js
@@ -0,0 +1,7 @@
+import Activities from '~/activities';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Activities(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/artifacts/browse/index.js b/app/assets/javascripts/pages/projects/artifacts/browse/index.js
new file mode 100644
index 00000000000..ea7458fe9b8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/artifacts/browse/index.js
@@ -0,0 +1,7 @@
+import BuildArtifacts from '~/build_artifacts';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new BuildArtifacts(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/artifacts/file/index.js b/app/assets/javascripts/pages/projects/artifacts/file/index.js
new file mode 100644
index 00000000000..8484e5e9848
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/artifacts/file/index.js
@@ -0,0 +1,7 @@
+import BlobViewer from '~/blob/viewer/index';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js
new file mode 100644
index 00000000000..80d0bff92fa
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blame/show/index.js
@@ -0,0 +1,3 @@
+import initBlob from '~/pages/projects/init_blob';
+
+document.addEventListener('DOMContentLoaded', initBlob);
diff --git a/app/assets/javascripts/pages/projects/blob/edit/index.js b/app/assets/javascripts/pages/projects/blob/edit/index.js
new file mode 100644
index 00000000000..189053f3ed7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/edit/index.js
@@ -0,0 +1,3 @@
+import initBlobBundle from '~/blob_edit/blob_bundle';
+
+document.addEventListener('DOMContentLoaded', initBlobBundle);
diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js
new file mode 100644
index 00000000000..189053f3ed7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/new/index.js
@@ -0,0 +1,3 @@
+import initBlobBundle from '~/blob_edit/blob_bundle';
+
+document.addEventListener('DOMContentLoaded', initBlobBundle);
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
new file mode 100644
index 00000000000..85c6862d629
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import BlobViewer from '~/blob/viewer/index';
+import initBlob from '~/pages/projects/init_blob';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new BlobViewer(); // eslint-disable-line no-new
+ initBlob();
+
+ const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
+ const statusLink = document.querySelector('.commit-actions .ci-status-link');
+ if (statusLink) {
+ statusLink.remove();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: CommitPipelineStatusEl,
+ components: {
+ commitPipelineStatus,
+ },
+ render(createElement) {
+ return createElement('commit-pipeline-status', {
+ props: {
+ endpoint: CommitPipelineStatusEl.dataset.endpoint,
+ },
+ });
+ },
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js
new file mode 100644
index 00000000000..5cfe8723204
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/boards/index.js
@@ -0,0 +1,9 @@
+import UsersSelect from '~/users_select';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import initBoards from '~/boards';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsersSelect(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ initBoards();
+});
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
new file mode 100644
index 00000000000..8fa266a37ce
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -0,0 +1,7 @@
+import AjaxLoadingSpinner from '~/ajax_loading_spinner';
+import DeleteModal from '~/branches/branches_delete_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AjaxLoadingSpinner.init();
+ new DeleteModal(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js
new file mode 100644
index 00000000000..a9658fd1eb4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -0,0 +1,6 @@
+import $ from 'jquery';
+import NewBranchForm from '~/new_branch_form';
+
+document.addEventListener('DOMContentLoaded', () => (
+ new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML))
+));
diff --git a/app/assets/javascripts/pages/projects/clusters/destroy/index.js b/app/assets/javascripts/pages/projects/clusters/destroy/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/destroy/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
new file mode 100644
index 00000000000..e4b8baede58
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -0,0 +1,5 @@
+import ClustersIndex from '~/clusters/clusters_index';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersIndex(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/update/index.js b/app/assets/javascripts/pages/projects/clusters/update/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/update/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
new file mode 100644
index 00000000000..8cc3cb0a57c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -0,0 +1,11 @@
+import $ from 'jquery';
+import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import initPipelines from '~/commit/pipelines/pipelines_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+ $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
+ initPipelines();
+});
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
new file mode 100644
index 00000000000..2e23cce11ce
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -0,0 +1,26 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import Diff from '~/diff';
+import ZenMode from '~/zen_mode';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import initNotes from '~/init_notes';
+import initChangesDropdown from '~/init_changes_dropdown';
+import initDiffNotes from '~/diff_notes/diff_notes_bundle';
+import { fetchCommitMergeRequests } from '~/commit_merge_requests';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Diff();
+ new ZenMode();
+ new ShortcutsNavigation();
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+ initNotes();
+ const stickyBarPaddingTop = 16;
+ initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - stickyBarPaddingTop);
+ $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
+ fetchCommitMergeRequests();
+ initDiffNotes();
+});
diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js
new file mode 100644
index 00000000000..3682020579b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commits/show/index.js
@@ -0,0 +1,9 @@
+import CommitsList from '~/commits';
+import GpgBadges from '~/gpg_badges';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ GpgBadges.fetch();
+});
diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js
new file mode 100644
index 00000000000..d1c78bd61db
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/compare/index.js
@@ -0,0 +1,3 @@
+import initCompareAutocomplete from '~/compare_autocomplete';
+
+document.addEventListener('DOMContentLoaded', initCompareAutocomplete);
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
new file mode 100644
index 00000000000..2b4fd3c47c0
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -0,0 +1,8 @@
+import Diff from '~/diff';
+import initChangesDropdown from '~/init_changes_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Diff(); // eslint-disable-line no-new
+ const paddingTop = 16;
+ initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+});
diff --git a/app/assets/javascripts/pages/projects/constants.js b/app/assets/javascripts/pages/projects/constants.js
new file mode 100644
index 00000000000..9efbf7cd36e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/constants.js
@@ -0,0 +1,6 @@
+/* eslint-disable import/prefer-default-export */
+
+export const ISSUABLE_INDEX = {
+ MERGE_REQUEST: 'merge_request_',
+ ISSUE: 'issue_',
+};
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
new file mode 100644
index 00000000000..df58e9dd072
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -0,0 +1,3 @@
+import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
+
+document.addEventListener('DOMContentLoaded', initCycleAnalytics);
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
new file mode 100644
index 00000000000..be37df36be8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -0,0 +1,16 @@
+import initSettingsPanels from '~/settings_panels';
+import setupProjectEdit from '~/project_edit';
+import initConfirmDangerModal from '~/confirm_danger_modal';
+import ProjectNew from '../shared/project_new';
+import projectAvatar from '../shared/project_avatar';
+import initProjectPermissionsSettings from '../shared/permissions';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectNew(); // eslint-disable-line no-new
+ setupProjectEdit();
+ // Initialize expandable settings panels
+ initSettingsPanels();
+ projectAvatar();
+ initProjectPermissionsSettings();
+ initConfirmDangerModal();
+});
diff --git a/app/assets/javascripts/pages/projects/environments/folder/index.js b/app/assets/javascripts/pages/projects/environments/folder/index.js
new file mode 100644
index 00000000000..5feaf944038
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/folder/index.js
@@ -0,0 +1,3 @@
+import initEnvironmentsFolderBundle from '~/environments/folder/environments_folder_bundle';
+
+document.addEventListener('DOMContentLoaded', initEnvironmentsFolderBundle);
diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js
new file mode 100644
index 00000000000..ace8af00ece
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/index/index.js
@@ -0,0 +1,3 @@
+import initEnviroments from '~/environments/';
+
+document.addEventListener('DOMContentLoaded', initEnviroments);
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
new file mode 100644
index 00000000000..0b644780ad4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js
@@ -0,0 +1,3 @@
+import monitoringBundle from '~/monitoring/monitoring_bundle';
+
+document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/environments/terminal/index.js b/app/assets/javascripts/pages/projects/environments/terminal/index.js
new file mode 100644
index 00000000000..7129e24cee1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/terminal/index.js
@@ -0,0 +1,3 @@
+import initTerminal from '~/terminal/';
+
+document.addEventListener('DOMContentLoaded', initTerminal);
diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js
new file mode 100644
index 00000000000..24630c2aa05
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/find_file/show/index.js
@@ -0,0 +1,13 @@
+import $ from 'jquery';
+import ProjectFindFile from '~/project_find_file';
+import ShortcutsFindFile from '~/shortcuts_find_file';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const findElement = document.querySelector('.js-file-finder');
+ const projectFindFile = new ProjectFindFile($('.file-finder-holder'), {
+ url: findElement.dataset.fileFindUrl,
+ treeUrl: findElement.dataset.findTreeUrl,
+ blobUrlTemplate: findElement.dataset.blobUrlTemplate,
+ });
+ new ShortcutsFindFile(projectFindFile); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
new file mode 100644
index 00000000000..d80e27e9156
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -0,0 +1,3 @@
+import ProjectFork from '~/project_fork';
+
+document.addEventListener('DOMContentLoaded', () => new ProjectFork());
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
new file mode 100644
index 00000000000..80159a82bd4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -0,0 +1,62 @@
+import $ from 'jquery';
+import Chart from 'chart.js';
+import _ from 'underscore';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML);
+
+ const responsiveChart = (selector, data) => {
+ const options = {
+ scaleOverlay: true,
+ responsive: true,
+ pointHitDetectionRadius: 2,
+ maintainAspectRatio: false,
+ };
+ // get selector by context
+ const ctx = selector.get(0).getContext('2d');
+ // pointing parent container to make chart.js inherit its width
+ const container = $(selector).parent();
+ const generateChart = () => {
+ selector.attr('width', $(container).width());
+ if (window.innerWidth < 768) {
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ options.scaleFontSize = 8;
+ }
+ return new Chart(ctx).Bar(data, options);
+ };
+ // enabling auto-resizing
+ $(window).resize(generateChart);
+ return generateChart();
+ };
+
+ const chartData = data => ({
+ labels: Object.keys(data),
+ datasets: [{
+ fillColor: 'rgba(220,220,220,0.5)',
+ strokeColor: 'rgba(220,220,220,1)',
+ barStrokeWidth: 1,
+ barValueSpacing: 1,
+ barDatasetSpacing: 1,
+ data: _.values(data),
+ }],
+ });
+
+ const hourData = chartData(projectChartData.hour);
+ responsiveChart($('#hour-chart'), hourData);
+
+ const dayData = chartData(projectChartData.weekDays);
+ responsiveChart($('#weekday-chart'), dayData);
+
+ const monthData = chartData(projectChartData.month);
+ responsiveChart($('#month-chart'), monthData);
+
+ const data = projectChartData.languages;
+ const ctx = $('#languages-chart').get(0).getContext('2d');
+ const options = {
+ scaleOverlay: true,
+ responsive: true,
+ maintainAspectRatio: false,
+ };
+
+ new Chart(ctx).Pie(data, options);
+});
diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js
new file mode 100644
index 00000000000..71f629fbc13
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/index.js
@@ -0,0 +1,24 @@
+import $ from 'jquery';
+import flash from '~/flash';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import ContributorsStatGraph from './stat_graph_contributors';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath;
+
+ axios.get(url)
+ .then(({ data }) => {
+ const graph = new ContributorsStatGraph();
+ graph.init(data);
+
+ $('#brush_change').change(() => {
+ graph.change_date_header();
+ graph.redraw_authors();
+ });
+
+ $('.stat-graph').fadeIn();
+ $('.loading-graph').hide();
+ })
+ .catch(() => flash(__('Error fetching contributors data.')));
+});
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
new file mode 100644
index 00000000000..653e2502d01
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
@@ -0,0 +1,126 @@
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, camelcase, one-var-declaration-per-line, quotes, no-param-reassign, quote-props, comma-dangle, prefer-template, max-len, no-return-assign, no-shadow */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import { n__, s__, createDateTimeFormat, sprintf } from '~/locale';
+import { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph';
+import ContributorsStatGraphUtil from './stat_graph_contributors_util';
+
+export default (function() {
+ function ContributorsStatGraph() {
+ this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
+ }
+
+ ContributorsStatGraph.prototype.init = function(log) {
+ var author_commits, total_commits;
+ this.parsed_log = ContributorsStatGraphUtil.parse_log(log);
+ this.set_current_field("commits");
+ total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
+ author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field);
+ this.add_master_graph(total_commits);
+ this.add_authors_graph(author_commits);
+ return this.change_date_header();
+ };
+
+ ContributorsStatGraph.prototype.add_master_graph = function(total_data) {
+ this.master_graph = new ContributorsMasterGraph(total_data);
+ return this.master_graph.draw();
+ };
+
+ ContributorsStatGraph.prototype.add_authors_graph = function(author_data) {
+ var limited_author_data;
+ this.authors = [];
+ limited_author_data = author_data.slice(0, 100);
+ return _.each(limited_author_data, (function(_this) {
+ return function(d) {
+ var author_graph, author_header;
+ author_header = _this.create_author_header(d);
+ $(".contributors-list").append(author_header);
+ _this.authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates);
+ return author_graph.draw();
+ };
+ })(this));
+ };
+
+ ContributorsStatGraph.prototype.format_author_commit_info = function(author) {
+ var commits;
+ commits = $('<span/>', {
+ "class": 'graph-author-commits-count'
+ });
+ commits.text(n__('%d commit', '%d commits', author.commits));
+ return $('<span/>').append(commits);
+ };
+
+ ContributorsStatGraph.prototype.create_author_header = function(author) {
+ var author_commit_info, author_commit_info_span, author_email, author_name, list_item;
+ list_item = $('<li/>', {
+ "class": 'person',
+ style: 'display: block;'
+ });
+ author_name = $('<h4>' + author.author_name + '</h4>');
+ author_email = $('<p class="graph-author-email">' + author.author_email + '</p>');
+ author_commit_info_span = $('<span/>', {
+ "class": 'commits'
+ });
+ author_commit_info = this.format_author_commit_info(author);
+ author_commit_info_span.html(author_commit_info);
+ list_item.append(author_name);
+ list_item.append(author_email);
+ list_item.append(author_commit_info_span);
+ return list_item;
+ };
+
+ ContributorsStatGraph.prototype.redraw_master = function() {
+ var total_data;
+ total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
+ this.master_graph.set_data(total_data);
+ return this.master_graph.redraw();
+ };
+
+ ContributorsStatGraph.prototype.redraw_authors = function() {
+ var author_commits, x_domain;
+ $("ol").html("");
+ x_domain = ContributorsGraph.prototype.x_domain;
+ author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain);
+ return _.each(author_commits, (function(_this) {
+ return function(d) {
+ _this.redraw_author_commit_info(d);
+ if (_this.authors[d.author_name] != null) {
+ $(_this.authors[d.author_name].list_item).appendTo("ol");
+ _this.authors[d.author_name].set_data(d.dates);
+ return _this.authors[d.author_name].redraw();
+ }
+ return '';
+ };
+ })(this));
+ };
+
+ ContributorsStatGraph.prototype.set_current_field = function(field) {
+ return this.field = field;
+ };
+
+ ContributorsStatGraph.prototype.change_date_header = function() {
+ const x_domain = ContributorsGraph.prototype.x_domain;
+ const formattedDateRange = sprintf(
+ s__('ContributorsPage|%{startDate} – %{endDate}'),
+ {
+ startDate: this.dateFormat.format(new Date(x_domain[0])),
+ endDate: this.dateFormat.format(new Date(x_domain[1])),
+ },
+ );
+ return $('#date_header').text(formattedDateRange);
+ };
+
+ ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
+ var author_commit_info, author_list_item, $author;
+ $author = this.authors[author.author_name];
+ if ($author != null) {
+ author_list_item = $(this.authors[author.author_name].list_item);
+ author_commit_info = this.format_author_commit_info(author);
+ return author_list_item.find("span").html(author_commit_info);
+ }
+ return '';
+ };
+
+ return ContributorsStatGraph;
+})();
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
new file mode 100644
index 00000000000..a99ce0f1c36
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
@@ -0,0 +1,296 @@
+/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import { extent, max } from 'd3-array';
+import { select, event as d3Event } from 'd3-selection';
+import { scaleTime, scaleLinear } from 'd3-scale';
+import { axisLeft, axisBottom } from 'd3-axis';
+import { area } from 'd3-shape';
+import { brushX } from 'd3-brush';
+import { timeParse } from 'd3-time-format';
+import { dateTickFormat } from '~/lib/utils/tick_formats';
+
+const d3 = { extent, max, select, scaleTime, scaleLinear, axisLeft, axisBottom, area, brushX, timeParse };
+
+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;
+
+export const ContributorsGraph = (function() {
+ function ContributorsGraph() {}
+
+ ContributorsGraph.prototype.MARGIN = {
+ top: 20,
+ right: 20,
+ bottom: 30,
+ left: 50
+ };
+
+ ContributorsGraph.prototype.x_domain = null;
+
+ ContributorsGraph.prototype.y_domain = null;
+
+ ContributorsGraph.prototype.dates = [];
+
+ ContributorsGraph.set_x_domain = function(data) {
+ return ContributorsGraph.prototype.x_domain = data;
+ };
+
+ ContributorsGraph.set_y_domain = function(data) {
+ return ContributorsGraph.prototype.y_domain = [
+ 0, d3.max(data, function(d) {
+ return d.commits = d.commits || d.additions || d.deletions;
+ })
+ ];
+ };
+
+ ContributorsGraph.init_x_domain = function(data) {
+ return ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) {
+ return d.date;
+ });
+ };
+
+ ContributorsGraph.init_y_domain = function(data) {
+ return ContributorsGraph.prototype.y_domain = [
+ 0, d3.max(data, function(d) {
+ return d.commits = d.commits || d.additions || d.deletions;
+ })
+ ];
+ };
+
+ ContributorsGraph.init_domain = function(data) {
+ ContributorsGraph.init_x_domain(data);
+ return ContributorsGraph.init_y_domain(data);
+ };
+
+ ContributorsGraph.set_dates = function(data) {
+ return ContributorsGraph.prototype.dates = data;
+ };
+
+ ContributorsGraph.prototype.set_x_domain = function() {
+ return this.x.domain(this.x_domain);
+ };
+
+ ContributorsGraph.prototype.set_y_domain = function() {
+ return this.y.domain(this.y_domain);
+ };
+
+ ContributorsGraph.prototype.set_domain = function() {
+ this.set_x_domain();
+ return this.set_y_domain();
+ };
+
+ ContributorsGraph.prototype.create_scale = function(width, height) {
+ this.x = d3.scaleTime().range([0, width]).clamp(true);
+ return this.y = d3.scaleLinear().range([height, 0]).nice();
+ };
+
+ ContributorsGraph.prototype.draw_x_axis = function() {
+ return this.svg.append("g").attr("class", "x axis").attr("transform", "translate(0, " + this.height + ")").call(this.x_axis);
+ };
+
+ ContributorsGraph.prototype.draw_y_axis = function() {
+ return this.svg.append("g").attr("class", "y axis").call(this.y_axis);
+ };
+
+ ContributorsGraph.prototype.set_data = function(data) {
+ return this.data = data;
+ };
+
+ return ContributorsGraph;
+})();
+
+export const ContributorsMasterGraph = (function(superClass) {
+ extend(ContributorsMasterGraph, superClass);
+
+ function ContributorsMasterGraph(data1) {
+ const $parentElement = $('#contributors-master');
+ const parentPadding = parseFloat($parentElement.css('padding-left')) + parseFloat($parentElement.css('padding-right'));
+
+ this.data = data1;
+ this.update_content = this.update_content.bind(this);
+ this.width = $('.content').width() - parentPadding - (this.MARGIN.left + this.MARGIN.right);
+ this.height = 200;
+ this.x = null;
+ this.y = null;
+ this.x_axis = null;
+ this.y_axis = null;
+ this.area = null;
+ this.svg = null;
+ this.brush = null;
+ this.x_max_domain = null;
+ }
+
+ ContributorsMasterGraph.prototype.process_dates = function(data) {
+ var dates;
+ dates = this.get_dates(data);
+ this.parse_dates(data);
+ return ContributorsGraph.set_dates(dates);
+ };
+
+ ContributorsMasterGraph.prototype.get_dates = function(data) {
+ return _.pluck(data, 'date');
+ };
+
+ ContributorsMasterGraph.prototype.parse_dates = function(data) {
+ var parseDate;
+ parseDate = d3.timeParse("%Y-%m-%d");
+ return data.forEach(function(d) {
+ return d.date = parseDate(d.date);
+ });
+ };
+
+ ContributorsMasterGraph.prototype.create_scale = function() {
+ return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height);
+ };
+
+ ContributorsMasterGraph.prototype.create_axes = function() {
+ this.x_axis = d3.axisBottom()
+ .scale(this.x)
+ .tickFormat(dateTickFormat);
+ return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
+ };
+
+ ContributorsMasterGraph.prototype.create_svg = function() {
+ return this.svg = d3.select("#contributors-master").append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "tint-box").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
+ };
+
+ ContributorsMasterGraph.prototype.create_area = function(x, y) {
+ return this.area = d3.area().x(function(d) {
+ return x(d.date);
+ }).y0(this.height).y1(function(d) {
+ d.commits = d.commits || d.additions || d.deletions;
+ return y(d.commits);
+ });
+ };
+
+ ContributorsMasterGraph.prototype.create_brush = function() {
+ return this.brush = d3.brushX(this.x).extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]).on("end", this.update_content);
+ };
+
+ ContributorsMasterGraph.prototype.draw_path = function(data) {
+ return this.svg.append("path").datum(data).attr("class", "area").attr("d", this.area);
+ };
+
+ ContributorsMasterGraph.prototype.add_brush = function() {
+ return this.svg.append("g").attr("class", "selection").call(this.brush).selectAll("rect").attr("height", this.height);
+ };
+
+ ContributorsMasterGraph.prototype.update_content = function() {
+ // d3Event.selection replaces the function brush.empty() calls
+ if (d3Event.selection != null) {
+ ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert));
+ } else {
+ ContributorsGraph.set_x_domain(this.x_max_domain);
+ }
+ return $("#brush_change").trigger('change');
+ };
+
+ ContributorsMasterGraph.prototype.draw = function() {
+ this.process_dates(this.data);
+ this.create_scale();
+ this.create_axes();
+ ContributorsGraph.init_domain(this.data);
+ this.x_max_domain = this.x_domain;
+ this.set_domain();
+ this.create_area(this.x, this.y);
+ this.create_svg();
+ this.create_brush();
+ this.draw_path(this.data);
+ this.draw_x_axis();
+ this.draw_y_axis();
+ return this.add_brush();
+ };
+
+ ContributorsMasterGraph.prototype.redraw = function() {
+ this.process_dates(this.data);
+ ContributorsGraph.set_y_domain(this.data);
+ this.set_y_domain();
+ this.svg.select("path").datum(this.data);
+ this.svg.select("path").attr("d", this.area);
+ return this.svg.select(".y.axis").call(this.y_axis);
+ };
+
+ return ContributorsMasterGraph;
+})(ContributorsGraph);
+
+export const ContributorsAuthorGraph = (function(superClass) {
+ extend(ContributorsAuthorGraph, superClass);
+
+ function ContributorsAuthorGraph(data1) {
+ this.data = data1;
+ // Don't split graph size in half for mobile devices.
+ if ($(window).width() < 768) {
+ this.width = $('.content').width() - 80;
+ } else {
+ this.width = ($('.content').width() / 2) - 100;
+ }
+ this.height = 200;
+ this.x = null;
+ this.y = null;
+ this.x_axis = null;
+ this.y_axis = null;
+ this.area = null;
+ this.svg = null;
+ this.list_item = null;
+ }
+
+ ContributorsAuthorGraph.prototype.create_scale = function() {
+ return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height);
+ };
+
+ ContributorsAuthorGraph.prototype.create_axes = function() {
+ this.x_axis = d3.axisBottom()
+ .scale(this.x)
+ .ticks(8)
+ .tickFormat(dateTickFormat);
+ return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
+ };
+
+ ContributorsAuthorGraph.prototype.create_area = function(x, y) {
+ return this.area = d3.area().x(function(d) {
+ var parseDate;
+ parseDate = d3.timeParse("%Y-%m-%d");
+ return x(parseDate(d));
+ }).y0(this.height).y1((function(_this) {
+ return function(d) {
+ if (_this.data[d] != null) {
+ return y(_this.data[d]);
+ } else {
+ return y(0);
+ }
+ };
+ })(this));
+ };
+
+ ContributorsAuthorGraph.prototype.create_svg = function() {
+ var persons = document.querySelectorAll('.person');
+ this.list_item = persons[persons.length - 1];
+ return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
+ };
+
+ ContributorsAuthorGraph.prototype.draw_path = function(data) {
+ return this.svg.append("path").datum(data).attr("class", "area-contributor").attr("d", this.area);
+ };
+
+ ContributorsAuthorGraph.prototype.draw = function() {
+ this.create_scale();
+ this.create_axes();
+ this.set_domain();
+ this.create_area(this.x, this.y);
+ this.create_svg();
+ this.draw_path(this.dates);
+ this.draw_x_axis();
+ return this.draw_y_axis();
+ };
+
+ ContributorsAuthorGraph.prototype.redraw = function() {
+ this.set_domain();
+ this.svg.select("path").datum(this.dates);
+ this.svg.select("path").attr("d", this.area);
+ this.svg.select(".x.axis").call(this.x_axis);
+ return this.svg.select(".y.axis").call(this.y_axis);
+ };
+
+ return ContributorsAuthorGraph;
+})(ContributorsGraph);
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
new file mode 100644
index 00000000000..77135ad1f0e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
@@ -0,0 +1,138 @@
+/* eslint-disable func-names, space-before-function-paren, object-shorthand, no-var, one-var, camelcase, one-var-declaration-per-line, comma-dangle, no-param-reassign, no-return-assign, quotes, prefer-arrow-callback, wrap-iife, consistent-return, no-unused-vars, max-len, no-cond-assign, no-else-return, max-len */
+import _ from 'underscore';
+
+export default {
+ parse_log: function(log) {
+ var by_author, by_email, data, entry, i, len, total, normalized_email;
+ total = {};
+ by_author = {};
+ by_email = {};
+ for (i = 0, len = log.length; i < len; i += 1) {
+ entry = log[i];
+ if (total[entry.date] == null) {
+ this.add_date(entry.date, total);
+ }
+ normalized_email = entry.author_email.toLowerCase();
+ data = by_author[entry.author_name] || by_email[normalized_email];
+ if (data == null) {
+ data = this.add_author(entry, by_author, by_email);
+ }
+ if (!data[entry.date]) {
+ this.add_date(entry.date, data);
+ }
+ this.store_data(entry, total[entry.date], data[entry.date]);
+ }
+ total = _.toArray(total);
+ by_author = _.toArray(by_author);
+ return {
+ total: total,
+ by_author: by_author
+ };
+ },
+ add_date: function(date, collection) {
+ collection[date] = {};
+ return collection[date].date = date;
+ },
+ add_author: function(author, by_author, by_email) {
+ var data, normalized_email;
+ data = {};
+ data.author_name = author.author_name;
+ data.author_email = author.author_email;
+ normalized_email = author.author_email.toLowerCase();
+ by_author[author.author_name] = data;
+ by_email[normalized_email] = data;
+ return data;
+ },
+ store_data: function(entry, total, by_author) {
+ this.store_commits(total, by_author);
+ this.store_additions(entry, total, by_author);
+ return this.store_deletions(entry, total, by_author);
+ },
+ store_commits: function(total, by_author) {
+ this.add(total, "commits", 1);
+ return this.add(by_author, "commits", 1);
+ },
+ add: function(collection, field, value) {
+ if (collection[field] == null) {
+ collection[field] = 0;
+ }
+ return collection[field] += value;
+ },
+ store_additions: function(entry, total, by_author) {
+ if (entry.additions == null) {
+ entry.additions = 0;
+ }
+ this.add(total, "additions", entry.additions);
+ return this.add(by_author, "additions", entry.additions);
+ },
+ store_deletions: function(entry, total, by_author) {
+ if (entry.deletions == null) {
+ entry.deletions = 0;
+ }
+ this.add(total, "deletions", entry.deletions);
+ return this.add(by_author, "deletions", entry.deletions);
+ },
+ get_total_data: function(parsed_log, field) {
+ var log, total_data;
+ log = parsed_log.total;
+ total_data = this.pick_field(log, field);
+ return _.sortBy(total_data, function(d) {
+ return d.date;
+ });
+ },
+ pick_field: function(log, field) {
+ var total_data;
+ total_data = [];
+ _.each(log, function(d) {
+ return total_data.push(_.pick(d, [field, 'date']));
+ });
+ return total_data;
+ },
+ get_author_data: function(parsed_log, field, date_range) {
+ var author_data, log;
+ if (date_range == null) {
+ date_range = null;
+ }
+ log = parsed_log.by_author;
+ author_data = [];
+ _.each(log, (function(_this) {
+ return function(log_entry) {
+ var parsed_log_entry;
+ parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range);
+ if (!_.isEmpty(parsed_log_entry.dates)) {
+ return author_data.push(parsed_log_entry);
+ }
+ };
+ })(this));
+ return _.sortBy(author_data, function(d) {
+ return d[field];
+ }).reverse();
+ },
+ parse_log_entry: function(log_entry, field, date_range) {
+ var parsed_entry;
+ parsed_entry = {};
+ parsed_entry.author_name = log_entry.author_name;
+ parsed_entry.author_email = log_entry.author_email;
+ parsed_entry.dates = {};
+ parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0;
+ _.each(_.omit(log_entry, 'author_name', 'author_email'), (function(_this) {
+ return function(value, key) {
+ if (_this.in_range(value.date, date_range)) {
+ parsed_entry.dates[value.date] = value[field];
+ parsed_entry.commits += value.commits;
+ parsed_entry.additions += value.additions;
+ return parsed_entry.deletions += value.deletions;
+ }
+ };
+ })(this));
+ return parsed_entry;
+ },
+ in_range: function(date, date_range) {
+ var ref;
+ if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+};
diff --git a/app/assets/javascripts/pages/projects/imports/show/index.js b/app/assets/javascripts/pages/projects/imports/show/index.js
new file mode 100644
index 00000000000..d5f92baf054
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/imports/show/index.js
@@ -0,0 +1,5 @@
+import ProjectImport from '~/project_import';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectImport(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
new file mode 100644
index 00000000000..de1e13de7e9
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -0,0 +1,7 @@
+import Project from './project';
+import ShortcutsNavigation from '../../shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Project(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
new file mode 100644
index 00000000000..82143fa875a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -0,0 +1,36 @@
+import LineHighlighter from '~/line_highlighter';
+import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import ShortcutsBlob from '~/shortcuts_blob';
+import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
+import initBlobBundle from '~/blob_edit/blob_bundle';
+
+export default () => {
+ new LineHighlighter(); // eslint-disable-line no-new
+
+ new BlobLinePermalinkUpdater( // eslint-disable-line no-new
+ document.querySelector('#blob-content-holder'),
+ '.diff-line-num[data-line-number]',
+ document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
+ );
+
+ const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
+ const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
+
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+
+ new ShortcutsBlob({ // eslint-disable-line no-new
+ skipResetBindings: true,
+ fileBlobPermalinkUrl,
+ });
+
+ new BlobForkSuggestion({ // eslint-disable-line no-new
+ openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),
+ forkButtons: document.querySelectorAll('.js-fork-suggestion-button'),
+ cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'),
+ suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
+ actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
+ }).init();
+
+ initBlobBundle();
+};
diff --git a/app/assets/javascripts/pages/projects/init_form.js b/app/assets/javascripts/pages/projects/init_form.js
new file mode 100644
index 00000000000..0b6c5c1d30b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/init_form.js
@@ -0,0 +1,7 @@
+import ZenMode from '~/zen_mode';
+import GLForm from '~/gl_form';
+
+export default function ($formEl) {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($formEl, true); // eslint-disable-line no-new
+}
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
new file mode 100644
index 00000000000..14fddbc9a05
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import IssuableForm from '~/issuable_form';
+import LabelsSelect from '~/labels_select';
+import MilestoneSelect from '~/milestone_select';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+
+export default () => {
+ new ShortcutsNavigation();
+ new GLForm($('.issue-form'), true);
+ new IssuableForm($('.issue-form'));
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssuableTemplateSelectors();
+};
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
new file mode 100644
index 00000000000..70fdb0ef40d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+
+import IssuableIndex from '~/issuable_index';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import UsersSelect from '~/users_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ });
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+
+ new ShortcutsNavigation();
+ new UsersSelect();
+});
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
new file mode 100644
index 00000000000..500fbd27340
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -0,0 +1,13 @@
+import initIssuableSidebar from '~/init_issuable_sidebar';
+import Issue from '~/issue';
+import ShortcutsIssuable from '~/shortcuts_issuable';
+import ZenMode from '~/zen_mode';
+import '~/notes/index';
+import '~/issue_show/index';
+
+export default function () {
+ new Issue(); // eslint-disable-line no-new
+ new ShortcutsIssuable(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+ initIssuableSidebar();
+}
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
new file mode 100644
index 00000000000..7968dfd7a12
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -0,0 +1,7 @@
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initShow from '../show';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initShow();
+ initSidebarBundle();
+});
diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js
new file mode 100644
index 00000000000..3626f3ffec6
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/jobs/show/index.js
@@ -0,0 +1,3 @@
+import initJobDetails from '~/jobs/job_details_bundle';
+
+document.addEventListener('DOMContentLoaded', initJobDetails);
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
new file mode 100644
index 00000000000..54695dfeb99
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -0,0 +1,79 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+ import createFlash from '~/flash';
+ import GlModal from '~/vue_shared/components/gl_modal.vue';
+ import { s__, sprintf } from '~/locale';
+ import { visitUrl } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ labelTitle: {
+ type: String,
+ required: true,
+ },
+ labelColor: {
+ type: String,
+ required: true,
+ },
+ labelTextColor: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return s__(`Milestones|Promoting this label will make it available for all projects inside the group.
+ Existing project labels with the same title will be merged. This action cannot be reversed.`);
+ },
+ title() {
+ const label = `<span
+ class="label color-label"
+ style="background-color: ${this.labelColor}; color: ${this.labelTextColor};"
+ >${this.labelTitle}</span>`;
+
+ return sprintf(s__('Labels|Promote label %{labelTitle} to Group Label?'), {
+ labelTitle: label,
+ }, false);
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('promoteLabelModal.requestStarted', this.url);
+ return axios.post(this.url, { params: { format: 'json' } })
+ .then((response) => {
+ eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: true });
+ visitUrl(response.data.url);
+ })
+ .catch((error) => {
+ eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: false });
+ createFlash(error);
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <gl-modal
+ id="promote-label-modal"
+ footer-primary-button-variant="warning"
+ :footer-primary-button-text="s__('Labels|Promote Label')"
+ @submit="onSubmit"
+ >
+ <div
+ slot="title"
+ v-html="title"
+ >
+ {{ title }}
+ </div>
+
+ {{ text }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
new file mode 100644
index 00000000000..2abcbfab1ed
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -0,0 +1,91 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import initLabels from '~/init_labels';
+import eventHub from '../event_hub';
+import PromoteLabelModal from '../components/promote_label_modal.vue';
+
+Vue.use(Translate);
+
+const initLabelIndex = () => {
+ initLabels();
+
+ const onRequestFinished = ({ labelUrl, successful }) => {
+ const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+ };
+
+ const onRequestStarted = (labelUrl) => {
+ const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`);
+ button.setAttribute('disabled', '');
+ eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ labelTitle: button.dataset.labelTitle,
+ labelColor: button.dataset.labelColor,
+ labelTextColor: button.dataset.labelTextColor,
+ url: button.dataset.url,
+ };
+ eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
+ eventHub.$emit('promoteLabelModal.props', modalProps);
+ };
+
+ const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button');
+ promoteLabelButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('promoteLabelModal.mounted', () => {
+ promoteLabelButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ const promoteLabelModal = document.getElementById('promote-label-modal');
+ let promoteLabelModalComponent;
+
+ if (promoteLabelModal) {
+ promoteLabelModalComponent = new Vue({
+ el: promoteLabelModal,
+ components: {
+ PromoteLabelModal,
+ },
+ data() {
+ return {
+ modalProps: {
+ labelTitle: '',
+ labelColor: '',
+ labelTextColor: '',
+ url: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteLabelModal.props', this.setModalProps);
+ eventHub.$emit('promoteLabelModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteLabelModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement('promote-label-modal', {
+ props: this.modalProps,
+ });
+ },
+ });
+ }
+
+ return promoteLabelModalComponent;
+};
+
+document.addEventListener('DOMContentLoaded', initLabelIndex);
diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/new/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
new file mode 100644
index 00000000000..28641104c58
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
@@ -0,0 +1,7 @@
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSidebarBundle();
+ initMergeConflicts();
+});
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/index.js
new file mode 100644
index 00000000000..febfecebbd2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/index.js
@@ -0,0 +1,3 @@
+import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+
+document.addEventListener('DOMContentLoaded', initMergeRequest);
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
new file mode 100644
index 00000000000..6c9afddefac
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -0,0 +1,20 @@
+import Compare from '~/compare';
+import MergeRequest from '~/merge_request';
+import initPipelines from '~/commit/pipelines/pipelines_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
+ if (mrNewCompareNode) {
+ new Compare({ // eslint-disable-line no-new
+ targetProjectUrl: mrNewCompareNode.dataset.targetProjectUrl,
+ sourceBranchUrl: mrNewCompareNode.dataset.sourceBranchUrl,
+ targetBranchUrl: mrNewCompareNode.dataset.targetBranchUrl,
+ });
+ } else {
+ const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit');
+ new MergeRequest({ // eslint-disable-line no-new
+ action: mrNewSubmitNode.dataset.mrSubmitAction,
+ });
+ initPipelines();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
new file mode 100644
index 00000000000..febfecebbd2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -0,0 +1,3 @@
+import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+
+document.addEventListener('DOMContentLoaded', initMergeRequest);
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
new file mode 100644
index 00000000000..a7aa616319f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -0,0 +1,15 @@
+import IssuableIndex from '~/issuable_index';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import UsersSelect from '~/users_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.MERGE_REQUESTS,
+ });
+ new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new UsersSelect(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
new file mode 100644
index 00000000000..406fc32f9a2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -0,0 +1,20 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import Diff from '~/diff';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import GLForm from '~/gl_form';
+import IssuableForm from '~/issuable_form';
+import LabelsSelect from '~/labels_select';
+import MilestoneSelect from '~/milestone_select';
+import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+
+export default () => {
+ new Diff();
+ new ShortcutsNavigation();
+ new GLForm($('.merge-request-form'), true);
+ new IssuableForm($('.merge-request-form'));
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssuableTemplateSelectors();
+};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
new file mode 100644
index 00000000000..28d8761b502
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -0,0 +1,32 @@
+import MergeRequest from '~/merge_request';
+import ZenMode from '~/zen_mode';
+import initNotes from '~/init_notes';
+import initIssuableSidebar from '~/init_issuable_sidebar';
+import initDiffNotes from '~/diff_notes/diff_notes_bundle';
+import ShortcutsIssuable from '~/shortcuts_issuable';
+import Diff from '~/diff';
+import { handleLocationHash } from '~/lib/utils/common_utils';
+import howToMerge from '~/how_to_merge';
+import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import initWidget from '../../../vue_merge_request_widget';
+
+export default function () {
+ new Diff(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+
+ initIssuableSidebar();
+ initNotes();
+ initDiffNotes();
+ initPipelines();
+
+ const mrShowNode = document.querySelector('.merge-request');
+
+ window.mergeRequest = new MergeRequest({
+ action: mrShowNode.dataset.mrAction,
+ });
+
+ new ShortcutsIssuable(true); // eslint-disable-line no-new
+ handleLocationHash();
+ howToMerge();
+ initWidget();
+}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
new file mode 100644
index 00000000000..e5b2827b50c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -0,0 +1,13 @@
+import { hasVueMRDiscussionsCookie } from '~/lib/utils/common_utils';
+import initMrNotes from '~/mr_notes';
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initShow from '../init_merge_request_show';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initShow();
+ initSidebarBundle();
+
+ if (hasVueMRDiscussionsCookie()) {
+ initMrNotes();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js
new file mode 100644
index 00000000000..9a4ebf9890d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm());
diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js
new file mode 100644
index 00000000000..38789365a67
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/index/index.js
@@ -0,0 +1,3 @@
+import milestones from '~/pages/milestones/shared';
+
+document.addEventListener('DOMContentLoaded', milestones);
diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js
new file mode 100644
index 00000000000..9a4ebf9890d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm());
diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js
new file mode 100644
index 00000000000..84a52421598
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/show/index.js
@@ -0,0 +1,7 @@
+import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+import milestones from '~/pages/milestones/shared';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initMilestonesShow();
+ milestones();
+});
diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
new file mode 100644
index 00000000000..aa50dd4bb25
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -0,0 +1,20 @@
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */
+
+import $ from 'jquery';
+import BranchGraph from '../../../network/branch_graph';
+
+export default (function() {
+ function Network(opts) {
+ var vph;
+ $("#filter_ref").click(function() {
+ return $(this).closest('form').submit();
+ });
+ this.branch_graph = new BranchGraph($(".network-graph"), opts);
+ vph = $(window).height() - 250;
+ $('.network-graph').css({
+ 'height': vph + 'px'
+ });
+ }
+
+ return Network;
+})();
diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js
new file mode 100644
index 00000000000..a0b14fed10f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import ShortcutsNetwork from '../../../../shortcuts_network';
+import Network from '../network';
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (!$('.network-graph').length) return;
+
+ const networkGraph = new Network({
+ url: $('.network-graph').attr('data-url'),
+ commit_url: $('.network-graph').attr('data-commit-url'),
+ ref: $('.network-graph').attr('data-ref'),
+ commit_id: $('.network-graph').attr('data-commit-id'),
+ });
+
+ // eslint-disable-next-line no-new
+ new ShortcutsNetwork(networkGraph.branch_graph);
+});
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
new file mode 100644
index 00000000000..ea6fd961393
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -0,0 +1,9 @@
+import ProjectNew from '../shared/project_new';
+import initProjectVisibilitySelector from '../../../project_visibility';
+import initProjectNew from '../../../projects/project_new';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectNew(); // eslint-disable-line no-new
+ initProjectVisibilitySelector();
+ initProjectNew.bindEvents();
+});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
new file mode 100644
index 00000000000..544360dcd51
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#pipeline-schedules-callout',
+ components: {
+ 'pipeline-schedules-callout': PipelineSchedulesCallout,
+ },
+ render(createElement) {
+ return createElement('pipeline-schedules-callout');
+ },
+}));
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
new file mode 100644
index 00000000000..2d18fa2044b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -0,0 +1,160 @@
+<script>
+ import _ from 'underscore';
+
+ export default {
+ props: {
+ initialCronInterval: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ inputNameAttribute: 'schedule[cron]',
+ cronInterval: this.initialCronInterval,
+ cronIntervalPresets: {
+ everyDay: '0 4 * * *',
+ everyWeek: '0 4 * * 0',
+ everyMonth: '0 4 1 * *',
+ },
+ cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
+ customInputEnabled: false,
+ };
+ },
+ computed: {
+ intervalIsPreset() {
+ return _.contains(this.cronIntervalPresets, this.cronInterval);
+ },
+ // The text input is editable when there's a custom interval, or when it's
+ // a preset interval and the user clicks the 'custom' radio button
+ isEditable() {
+ return !!(this.customInputEnabled || !this.intervalIsPreset);
+ },
+ },
+ watch: {
+ cronInterval() {
+ // updates field validation state when model changes, as
+ // glFieldError only updates on input.
+ this.$nextTick(() => {
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ });
+ },
+ },
+ created() {
+ if (this.intervalIsPreset) {
+ this.enableCustomInput = false;
+ }
+ },
+ methods: {
+ toggleCustomInput(shouldEnable) {
+ this.customInputEnabled = shouldEnable;
+
+ if (shouldEnable) {
+ // We need to change the value so other radios don't remain selected
+ // because the model (cronInterval) hasn't changed. The server trims it.
+ this.cronInterval = `${this.cronInterval} `;
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="interval-pattern-form-group">
+ <div class="cron-preset-radio-input">
+ <input
+ id="custom"
+ class="label-light"
+ type="radio"
+ :name="inputNameAttribute"
+ :value="cronInterval"
+ :checked="isEditable"
+ @click="toggleCustomInput(true)"
+ />
+
+ <label for="custom">
+ {{ s__('PipelineSheduleIntervalPattern|Custom') }}
+ </label>
+
+ <span class="cron-syntax-link-wrap">
+ (<a
+ :href="cronSyntaxUrl"
+ target="_blank"
+ >
+ {{ __('Cron syntax') }}
+ </a>)
+ </span>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-day"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyDay"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label
+ class="label-light"
+ for="every-day"
+ >
+ {{ __('Every day (at 4:00am)') }}
+ </label>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-week"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyWeek"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label
+ class="label-light"
+ for="every-week"
+ >
+ {{ __('Every week (Sundays at 4:00am)') }}
+ </label>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-month"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyMonth"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label
+ class="label-light"
+ for="every-month"
+ >
+ {{ __('Every month (on the 1st at 4:00am)') }}
+ </label>
+ </div>
+
+ <div class="cron-interval-input-wrapper">
+ <input
+ id="schedule_cron"
+ class="form-control inline cron-interval-input"
+ type="text"
+ :placeholder="__('Define a custom pattern with cron syntax')"
+ required="true"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :disabled="!isEditable"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
new file mode 100644
index 00000000000..77508e62cef
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -0,0 +1,67 @@
+<script>
+ import Vue from 'vue';
+ import Cookies from 'js-cookie';
+ import Translate from '../../../../../vue_shared/translate';
+ import illustrationSvg from '../icons/intro_illustration.svg';
+
+ Vue.use(Translate);
+
+ const cookieKey = 'pipeline_schedules_callout_dismissed';
+
+ export default {
+ name: 'PipelineSchedulesCallout',
+ data() {
+ return {
+ docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
+ calloutDismissed: Cookies.get(cookieKey) === 'true',
+ };
+ },
+ created() {
+ this.illustrationSvg = illustrationSvg;
+ },
+ methods: {
+ dismissCallout() {
+ this.calloutDismissed = true;
+ Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
+ },
+ },
+ };
+</script>
+<template>
+ <div
+ v-if="!calloutDismissed"
+ class="pipeline-schedules-user-callout user-callout">
+ <div class="bordered-box landing content-block">
+ <button
+ id="dismiss-callout-btn"
+ class="btn btn-default close"
+ @click="dismissCallout">
+ <i
+ aria-hidden="true"
+ class="fa fa-times">
+ </i>
+ </button>
+ <div
+ class="svg-container"
+ v-html="illustrationSvg">
+ </div>
+ <div class="user-callout-copy">
+ <h4>{{ __('Scheduling Pipelines') }}</h4>
+ <p>
+ {{ __(`The pipelines schedule runs pipelines in the future,
+repeatedly, for specific branches or tags.
+Those scheduled pipelines will inherit limited project access based on their associated user.`) }}
+ </p>
+ <p> {{ __('Learn more in the') }}
+ <a
+ :href="docsUrl"
+ target="_blank"
+ rel="nofollow"
+ >
+ {{ s__('Learn more in the|pipeline schedules documentation') }}</a>.
+ <!-- oneline to prevent extra space before period -->
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
new file mode 100644
index 00000000000..4ef0d11dd36
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
@@ -0,0 +1,54 @@
+import $ from 'jquery';
+
+export default class TargetBranchDropdown {
+ constructor() {
+ this.$dropdown = $('.js-target-branch-dropdown');
+ this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
+ this.$input = $('#schedule_ref');
+ this.initDefaultBranch();
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.formatBranchesList(),
+ filterable: true,
+ selectable: true,
+ toggleLabel: item => item.name,
+ search: {
+ fields: ['name'],
+ },
+ clicked: cfg => this.updateInputValue(cfg),
+ text: item => item.name,
+ });
+
+ this.setDropdownToggle();
+ }
+
+ formatBranchesList() {
+ return this.$dropdown.data('data')
+ .map(val => ({ name: val }));
+ }
+
+ setDropdownToggle() {
+ const initialValue = this.$input.val();
+
+ this.$dropdownToggle.text(initialValue);
+ }
+
+ initDefaultBranch() {
+ const initialValue = this.$input.val();
+ const defaultBranch = this.$dropdown.data('defaultBranch');
+
+ if (!initialValue) {
+ this.$input.val(defaultBranch);
+ }
+ }
+
+ updateInputValue({ selectedObj, e }) {
+ e.preventDefault();
+
+ this.$input.val(selectedObj.name);
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
new file mode 100644
index 00000000000..95b57d5e048
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -0,0 +1,68 @@
+/* eslint-disable class-methods-use-this */
+
+import $ from 'jquery';
+
+const defaultTimezone = 'UTC';
+
+export default class TimezoneDropdown {
+ constructor() {
+ this.$dropdown = $('.js-timezone-dropdown');
+ this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
+ this.$input = $('#schedule_cron_timezone');
+ this.timezoneData = this.$dropdown.data('data');
+ this.initDefaultTimezone();
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.timezoneData,
+ filterable: true,
+ selectable: true,
+ toggleLabel: item => item.name,
+ search: {
+ fields: ['name'],
+ },
+ clicked: cfg => this.updateInputValue(cfg),
+ text: item => this.formatTimezone(item),
+ });
+
+ this.setDropdownToggle();
+ }
+
+ formatUtcOffset(offset) {
+ let prefix = '';
+
+ if (offset > 0) {
+ prefix = '+';
+ } else if (offset < 0) {
+ prefix = '-';
+ }
+
+ return `${prefix} ${Math.abs(offset / 3600)}`;
+ }
+
+ formatTimezone(item) {
+ return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ }
+
+ initDefaultTimezone() {
+ const initialValue = this.$input.val();
+
+ if (!initialValue) {
+ this.$input.val(defaultTimezone);
+ }
+ }
+
+ setDropdownToggle() {
+ const initialValue = this.$input.val();
+
+ this.$dropdownToggle.text(initialValue);
+ }
+
+ updateInputValue({ selectedObj, e }) {
+ e.preventDefault();
+ this.$input.val(selectedObj.identifier);
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
index 26d1ff97b3e..26d1ff97b3e 100644
--- a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
new file mode 100644
index 00000000000..c3ac54733a3
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -0,0 +1,50 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import Translate from '../../../../vue_shared/translate';
+import GlFieldErrors from '../../../../gl_field_errors';
+import intervalPatternInput from './components/interval_pattern_input.vue';
+import TimezoneDropdown from './components/timezone_dropdown';
+import TargetBranchDropdown from './components/target_branch_dropdown';
+import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list';
+
+Vue.use(Translate);
+
+function initIntervalPatternInput() {
+ const intervalPatternMount = document.getElementById('interval-pattern-input');
+ const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : '';
+
+ return new Vue({
+ el: intervalPatternMount,
+ components: {
+ intervalPatternInput,
+ },
+ render(createElement) {
+ return createElement('interval-pattern-input', {
+ props: {
+ initialCronInterval,
+ },
+ });
+ },
+ });
+}
+
+export default () => {
+ /* Most of the form is written in haml, but for fields with more complex behaviors,
+ * you should mount individual Vue components here. If at some point components need
+ * to share state, it may make sense to refactor the whole form to Vue */
+
+ initIntervalPatternInput();
+
+ // Initialize non-Vue JS components in the form
+
+ const formElement = document.getElementById('new-pipeline-schedule-form');
+
+ gl.timezoneDropdown = new TimezoneDropdown();
+ gl.targetBranchDropdown = new TargetBranchDropdown();
+ gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
+
+ setupNativeFormVariableList({
+ container: $('.js-ci-variable-list-section'),
+ formField: 'schedule',
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipelines/builds/index.js b/app/assets/javascripts/pages/projects/pipelines/builds/index.js
new file mode 100644
index 00000000000..7a57e417b41
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/builds/index.js
@@ -0,0 +1,7 @@
+import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
+import initPipelines from '../init_pipelines';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initPipelines();
+ initPipelineDetails();
+});
diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
new file mode 100644
index 00000000000..07b6992eba1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
@@ -0,0 +1,57 @@
+import $ from 'jquery';
+import Chart from 'chart.js';
+
+const options = {
+ scaleOverlay: true,
+ responsive: true,
+ maintainAspectRatio: false,
+};
+
+const buildChart = (chartScope) => {
+ const data = {
+ labels: chartScope.labels,
+ datasets: [{
+ fillColor: '#707070',
+ strokeColor: '#707070',
+ pointColor: '#707070',
+ pointStrokeColor: '#EEE',
+ data: chartScope.totalValues,
+ },
+ {
+ fillColor: '#1aaa55',
+ strokeColor: '#1aaa55',
+ pointColor: '#1aaa55',
+ pointStrokeColor: '#fff',
+ data: chartScope.successValues,
+ },
+ ],
+ };
+ const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d');
+
+ new Chart(ctx).Line(data, options);
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
+ const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
+ const data = {
+ labels: chartTimesData.labels,
+ datasets: [{
+ fillColor: 'rgba(220,220,220,0.5)',
+ strokeColor: 'rgba(220,220,220,1)',
+ barStrokeWidth: 1,
+ barValueSpacing: 1,
+ barDatasetSpacing: 1,
+ data: chartTimesData.values,
+ }],
+ };
+
+ if (window.innerWidth < 768) {
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ options.scaleFontSize = 8;
+ }
+
+ new Chart($('#build_timesChart').get(0).getContext('2d')).Bar(data, options);
+
+ chartsData.forEach(scope => buildChart(scope));
+});
diff --git a/app/assets/javascripts/pages/projects/pipelines/failures/index.js b/app/assets/javascripts/pages/projects/pipelines/failures/index.js
new file mode 100644
index 00000000000..fbe9824c34b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/failures/index.js
@@ -0,0 +1,3 @@
+import initPipelines from '../init_pipelines';
+
+document.addEventListener('DOMContentLoaded', initPipelines);
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
new file mode 100644
index 00000000000..a84e2790680
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
+import pipelinesComponent from '../../../../pipelines/components/pipelines.vue';
+import Translate from '../../../../vue_shared/translate';
+import { convertPermissionToBoolean } from '../../../../lib/utils/common_utils';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#pipelines-list-vue',
+ components: {
+ pipelinesComponent,
+ },
+ data() {
+ return {
+ store: new PipelinesStore(),
+ };
+ },
+ created() {
+ this.dataset = document.querySelector(this.$options.el).dataset;
+ },
+ render(createElement) {
+ return createElement('pipelines-component', {
+ props: {
+ store: this.store,
+ endpoint: this.dataset.endpoint,
+ helpPagePath: this.dataset.helpPagePath,
+ emptyStateSvgPath: this.dataset.emptyStateSvgPath,
+ errorStateSvgPath: this.dataset.errorStateSvgPath,
+ noPipelinesSvgPath: this.dataset.noPipelinesSvgPath,
+ autoDevopsPath: this.dataset.helpAutoDevopsPath,
+ newPipelinePath: this.dataset.newPipelinePath,
+ canCreatePipeline: convertPermissionToBoolean(this.dataset.canCreatePipeline),
+ hasGitlabCi: convertPermissionToBoolean(this.dataset.hasGitlabCi),
+ ciLintPath: this.dataset.ciLintPath,
+ resetCachePath: this.dataset.resetCachePath,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
new file mode 100644
index 00000000000..94dfeb96e8c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
@@ -0,0 +1,16 @@
+import Pipelines from '~/pipelines';
+
+export default () => {
+ const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
+ const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
+
+ new Pipelines({ // eslint-disable-line no-new
+ initTabs: true,
+ pipelineStatusUrl,
+ tabsOptions: {
+ action: controllerAction,
+ defaultAction: 'pipelines',
+ parentEl: '.pipelines-tabs',
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js
new file mode 100644
index 00000000000..9aa8945e268
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js
@@ -0,0 +1,6 @@
+import $ from 'jquery';
+import NewBranchForm from '~/new_branch_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js
new file mode 100644
index 00000000000..7a57e417b41
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js
@@ -0,0 +1,7 @@
+import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
+import initPipelines from '../init_pipelines';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initPipelines();
+ initPipelineDetails();
+});
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
new file mode 100644
index 00000000000..c1e3425ec75
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -0,0 +1,135 @@
+/* eslint-disable func-names, space-before-function-paren, no-var, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */
+
+import $ from 'jquery';
+import Cookies from 'js-cookie';
+import { __ } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import projectSelect from '../../project_select';
+
+export default class Project {
+ constructor() {
+ const $cloneOptions = $('ul.clone-options-dropdown');
+ const $projectCloneField = $('#project_clone');
+ const $cloneBtnText = $('a.clone-dropdown-btn span');
+
+ const selectedCloneOption = $cloneBtnText.text().trim();
+ if (selectedCloneOption.length > 0) {
+ $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active');
+ }
+
+ $('a', $cloneOptions).on('click', (e) => {
+ const $this = $(e.currentTarget);
+ const url = $this.attr('href');
+ const activeText = $this.find('.dropdown-menu-inner-title').text();
+
+ e.preventDefault();
+
+ $('.is-active', $cloneOptions).not($this).removeClass('is-active');
+ $this.toggleClass('is-active');
+ $projectCloneField.val(url);
+ $cloneBtnText.text(activeText);
+
+ return $('.clone').text(url);
+ });
+ // Ref switcher
+ Project.initRefSwitcher();
+ $('.project-refs-select').on('change', function() {
+ return $(this).parents('form').submit();
+ });
+ $('.hide-no-ssh-message').on('click', function(e) {
+ Cookies.set('hide_no_ssh_message', 'false');
+ $(this).parents('.no-ssh-key-message').remove();
+ return e.preventDefault();
+ });
+ $('.hide-no-password-message').on('click', function(e) {
+ Cookies.set('hide_no_password_message', 'false');
+ $(this).parents('.no-password-message').remove();
+ return e.preventDefault();
+ });
+ Project.projectSelectDropdown();
+ }
+
+ static projectSelectDropdown() {
+ projectSelect();
+ $('.project-item-select').on('click', e => Project.changeProject($(e.currentTarget).val()));
+ }
+
+ static changeProject(url) {
+ return window.location = url;
+ }
+
+ static initRefSwitcher() {
+ var refListItem = document.createElement('li');
+ var refLink = document.createElement('a');
+
+ refLink.href = '#';
+
+ return $('.js-project-refs-dropdown').each(function() {
+ var $dropdown, selected;
+ $dropdown = $(this);
+ selected = $dropdown.data('selected');
+ return $dropdown.glDropdown({
+ data(term, callback) {
+ axios.get($dropdown.data('refsUrl'), {
+ params: {
+ ref: $dropdown.data('ref'),
+ search: term,
+ },
+ })
+ .then(({ data }) => callback(data))
+ .catch(() => flash(__('An error occurred while getting projects')));
+ },
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ filterByText: true,
+ inputFieldName: $dropdown.data('inputFieldName'),
+ fieldName: $dropdown.data('fieldName'),
+ renderRow: function(ref) {
+ var li = refListItem.cloneNode(false);
+
+ if (ref.header != null) {
+ li.className = 'dropdown-header';
+ li.textContent = ref.header;
+ } else {
+ var link = refLink.cloneNode(false);
+
+ if (ref === selected) {
+ link.className = 'is-active';
+ }
+
+ link.textContent = ref;
+ link.dataset.ref = ref;
+
+ li.appendChild(link);
+ }
+
+ return li;
+ },
+ id: function(obj, $el) {
+ return $el.attr('data-ref');
+ },
+ toggleLabel: function(obj, $el) {
+ return $el.text().trim();
+ },
+ clicked: function(options) {
+ const { e } = options;
+ e.preventDefault();
+ if ($('input[name="ref"]').length) {
+ var $form = $dropdown.closest('form');
+
+ var $visit = $dropdown.data('visit');
+ var shouldVisit = $visit ? true : $visit;
+ var action = $form.attr('action');
+ var divider = action.indexOf('?') === -1 ? '?' : '&';
+ if (shouldVisit) {
+ visitUrl(`${action}${divider}${$form.serialize()}`);
+ }
+ }
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
new file mode 100644
index 00000000000..adbe744290a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -0,0 +1,12 @@
+import memberExpirationDate from '../../../member_expiration_date';
+import UsersSelect from '../../../users_select';
+import groupsSelect from '../../../groups_select';
+import Members from '../../../members';
+
+document.addEventListener('DOMContentLoaded', () => {
+ memberExpirationDate('.js-access-expiration-date-groups');
+ groupsSelect();
+ memberExpirationDate();
+ new Members(); // eslint-disable-line no-new
+ new UsersSelect(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js
new file mode 100644
index 00000000000..35564754ee0
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js
@@ -0,0 +1,3 @@
+import initRegistryImages from '~/registry/index';
+
+document.addEventListener('DOMContentLoaded', initRegistryImages);
diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js
new file mode 100644
index 00000000000..c70271b09c4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/releases/edit/index.js
@@ -0,0 +1,4 @@
+import $ from 'jquery';
+import initForm from '~/pages/projects/init_form';
+
+document.addEventListener('DOMContentLoaded', () => initForm($('.release-form')));
diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js
new file mode 100644
index 00000000000..ba4b271f09e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/services/edit/index.js
@@ -0,0 +1,13 @@
+import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
+ const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ integrationSettingsForm.init();
+
+ if (prometheusSettingsWrapper) {
+ const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ prometheusMetrics.loadActiveMetrics();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
new file mode 100644
index 00000000000..6c2a785c0af
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -0,0 +1,25 @@
+import initSettingsPanels from '~/settings_panels';
+import SecretValues from '~/behaviors/secret_values';
+import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize expandable settings panels
+ initSettingsPanels();
+
+ const runnerToken = document.querySelector('.js-secret-runner-token');
+ if (runnerToken) {
+ const runnerTokenSecretValue = new SecretValues({
+ container: runnerToken,
+ });
+ runnerTokenSecretValue.init();
+ }
+
+ const variableListEl = document.querySelector('.js-ci-variable-list-section');
+ // eslint-disable-next-line no-new
+ new AjaxVariableList({
+ container: variableListEl,
+ saveButton: variableListEl.querySelector('.js-secret-variables-save-button'),
+ errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
+ saveEndpoint: variableListEl.dataset.saveEndpoint,
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
new file mode 100644
index 00000000000..788d86d1192
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -0,0 +1,17 @@
+/* eslint-disable no-new */
+
+import ProtectedTagCreate from '~/protected_tags/protected_tag_create';
+import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list';
+import initSettingsPanels from '~/settings_panels';
+import initDeployKeys from '~/deploy_keys';
+import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
+import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProtectedTagCreate();
+ new ProtectedTagEditList();
+ initDeployKeys();
+ initSettingsPanels();
+ new ProtectedBranchCreate(); // eslint-disable-line no-new
+ new ProtectedBranchEditList(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
new file mode 100644
index 00000000000..9b13b2a524f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -0,0 +1,111 @@
+<script>
+ import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+
+ export default {
+ components: {
+ projectFeatureToggle,
+ },
+
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ options: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ value: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ disabledInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ featureEnabled() {
+ return this.value !== 0;
+ },
+
+ displayOptions() {
+ if (this.featureEnabled) {
+ return this.options;
+ }
+ return [
+ [0, 'Enable feature to choose access level'],
+ ];
+ },
+
+ displaySelectInput() {
+ return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
+ },
+ },
+
+ methods: {
+ toggleFeature(featureEnabled) {
+ if (featureEnabled === false || this.options.length < 1) {
+ this.$emit('change', 0);
+ } else {
+ const [firstOptionValue] = this.options[this.options.length - 1];
+ this.$emit('change', firstOptionValue);
+ }
+ },
+
+ selectOption(e) {
+ this.$emit('change', Number(e.target.value));
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="project-feature-controls"
+ :data-for="name"
+ >
+ <input
+ v-if="name"
+ type="hidden"
+ :name="name"
+ :value="value"
+ />
+ <project-feature-toggle
+ :value="featureEnabled"
+ @change="toggleFeature"
+ :disabled-input="disabledInput"
+ />
+ <div class="select-wrapper">
+ <select
+ class="form-control project-repo-select select-control"
+ @change="selectOption"
+ :disabled="displaySelectInput"
+ >
+ <option
+ v-for="[optionValue, optionName] in displayOptions"
+ :key="optionValue"
+ :value="optionValue"
+ :selected="optionValue === value"
+ >
+ {{ optionName }}
+ </option>
+ </select>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down"
+ >
+ </i>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
new file mode 100644
index 00000000000..25a88f846eb
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
@@ -0,0 +1,51 @@
+<script>
+ export default {
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="project-feature-row">
+ <label
+ v-if="label"
+ class="label-light"
+ >
+ {{ label }}
+ <a
+ v-if="helpPath"
+ :href="helpPath"
+ target="_blank"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-question-circle"
+ >
+ </i>
+ </a>
+ </label>
+ <span
+ v-if="helpText"
+ class="help-block"
+ >
+ {{ helpText }}
+ </span>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
new file mode 100644
index 00000000000..755a34b7348
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -0,0 +1,328 @@
+<script>
+ import projectFeatureSetting from './project_feature_setting.vue';
+ import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+ import projectSettingRow from './project_setting_row.vue';
+ import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
+ import { toggleHiddenClassBySelector } from '../external';
+
+ export default {
+ components: {
+ projectFeatureSetting,
+ projectFeatureToggle,
+ projectSettingRow,
+ },
+
+ props: {
+ currentSettings: {
+ type: Object,
+ required: true,
+ },
+ canChangeVisibilityLevel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowedVisibilityOptions: {
+ type: Array,
+ required: false,
+ default: () => [0, 10, 20],
+ },
+ lfsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ registryAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visibilityHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lfsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ registryHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ data() {
+ const defaults = {
+ visibilityOptions,
+ visibilityLevel: visibilityOptions.PUBLIC,
+ issuesAccessLevel: 20,
+ repositoryAccessLevel: 20,
+ mergeRequestsAccessLevel: 20,
+ buildsAccessLevel: 20,
+ wikiAccessLevel: 20,
+ snippetsAccessLevel: 20,
+ containerRegistryEnabled: true,
+ lfsEnabled: true,
+ requestAccessEnabled: true,
+ highlightChangesClass: false,
+ };
+
+ return { ...defaults, ...this.currentSettings };
+ },
+
+ computed: {
+ featureAccessLevelOptions() {
+ const options = [
+ [10, 'Only Project Members'],
+ ];
+ if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ options.push([20, 'Everyone With Access']);
+ }
+ return options;
+ },
+
+ repoFeatureAccessLevelOptions() {
+ return this.featureAccessLevelOptions.filter(
+ ([value]) => value <= this.repositoryAccessLevel,
+ );
+ },
+
+ repositoryEnabled() {
+ return this.repositoryAccessLevel > 0;
+ },
+
+ visibilityLevelDescription() {
+ return visibilityLevelDescriptions[this.visibilityLevel];
+ },
+ },
+
+ watch: {
+ visibilityLevel(value, oldValue) {
+ if (value === visibilityOptions.PRIVATE) {
+ // when private, features are restricted to "only team members"
+ this.issuesAccessLevel = Math.min(10, this.issuesAccessLevel);
+ this.repositoryAccessLevel = Math.min(10, this.repositoryAccessLevel);
+ this.mergeRequestsAccessLevel = Math.min(10, this.mergeRequestsAccessLevel);
+ this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
+ this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
+ this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
+ this.highlightChanges();
+ } else if (oldValue === visibilityOptions.PRIVATE) {
+ // if changing away from private, make enabled features more permissive
+ if (this.issuesAccessLevel > 0) this.issuesAccessLevel = 20;
+ if (this.repositoryAccessLevel > 0) this.repositoryAccessLevel = 20;
+ if (this.mergeRequestsAccessLevel > 0) this.mergeRequestsAccessLevel = 20;
+ if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20;
+ if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
+ if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
+ this.highlightChanges();
+ }
+ },
+
+ repositoryAccessLevel(value, oldValue) {
+ if (value < oldValue) {
+ // sub-features cannot have more premissive access level
+ this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value);
+ this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value);
+
+ if (value === 0) {
+ this.containerRegistryEnabled = false;
+ this.lfsEnabled = false;
+ }
+ } else if (oldValue === 0) {
+ this.mergeRequestsAccessLevel = value;
+ this.buildsAccessLevel = value;
+ this.containerRegistryEnabled = true;
+ this.lfsEnabled = true;
+ }
+ },
+
+ issuesAccessLevel(value, oldValue) {
+ if (value === 0) toggleHiddenClassBySelector('.issues-feature', true);
+ else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false);
+ },
+
+ mergeRequestsAccessLevel(value, oldValue) {
+ if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true);
+ else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false);
+ },
+
+ buildsAccessLevel(value, oldValue) {
+ if (value === 0) toggleHiddenClassBySelector('.builds-feature', true);
+ else if (oldValue === 0) toggleHiddenClassBySelector('.builds-feature', false);
+ },
+ },
+
+ methods: {
+ highlightChanges() {
+ this.highlightChangesClass = true;
+ this.$nextTick(() => {
+ this.highlightChangesClass = false;
+ });
+ },
+
+ visibilityAllowed(option) {
+ return this.allowedVisibilityOptions.includes(option);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="project-visibility-setting">
+ <project-setting-row
+ label="Project visibility"
+ :help-path="visibilityHelpPath"
+ >
+ <div class="project-feature-controls">
+ <div class="select-wrapper">
+ <select
+ name="project[visibility_level]"
+ v-model="visibilityLevel"
+ class="form-control select-control"
+ :disabled="!canChangeVisibilityLevel"
+ >
+ <option
+ :value="visibilityOptions.PRIVATE"
+ :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ >
+ Private
+ </option>
+ <option
+ :value="visibilityOptions.INTERNAL"
+ :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
+ >
+ Internal
+ </option>
+ <option
+ :value="visibilityOptions.PUBLIC"
+ :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ >
+ Public
+ </option>
+ </select>
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-chevron-down"
+ >
+ </i>
+ </div>
+ </div>
+ <span class="help-block">{{ visibilityLevelDescription }}</span>
+ <label
+ v-if="visibilityLevel !== visibilityOptions.PRIVATE"
+ class="request-access"
+ >
+ <input
+ type="hidden"
+ name="project[request_access_enabled]"
+ :value="requestAccessEnabled"
+ />
+ <input
+ type="checkbox"
+ v-model="requestAccessEnabled"
+ />
+ Allow users to request access
+ </label>
+ </project-setting-row>
+ </div>
+ <div
+ class="project-feature-settings"
+ :class="{ 'highlight-changes': highlightChangesClass }"
+ >
+ <project-setting-row
+ label="Issues"
+ help-text="Lightweight issue tracking system for this project"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][issues_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="issuesAccessLevel"
+ />
+ </project-setting-row>
+ <project-setting-row
+ label="Repository"
+ help-text="View and edit files in this project"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][repository_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="repositoryAccessLevel"
+ />
+ </project-setting-row>
+ <div class="project-feature-setting-group">
+ <project-setting-row
+ label="Merge requests"
+ help-text="Submit changes to be merged upstream"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][merge_requests_access_level]"
+ :options="repoFeatureAccessLevelOptions"
+ v-model="mergeRequestsAccessLevel"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ <project-setting-row
+ label="Pipelines"
+ help-text="Build, test, and deploy your changes"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][builds_access_level]"
+ :options="repoFeatureAccessLevelOptions"
+ v-model="buildsAccessLevel"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ <project-setting-row
+ v-if="registryAvailable"
+ label="Container registry"
+ :help-path="registryHelpPath"
+ help-text="Every project can have its own space to store its Docker images"
+ >
+ <project-feature-toggle
+ name="project[container_registry_enabled]"
+ v-model="containerRegistryEnabled"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ <project-setting-row
+ v-if="lfsAvailable"
+ label="Git Large File Storage"
+ :help-path="lfsHelpPath"
+ help-text="Manages large files such as audio, video, and graphics files"
+ >
+ <project-feature-toggle
+ name="project[lfs_enabled]"
+ v-model="lfsEnabled"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ </div>
+ <project-setting-row
+ label="Wiki"
+ help-text="Pages for project documentation"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][wiki_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="wikiAccessLevel"
+ />
+ </project-setting-row>
+ <project-setting-row
+ label="Snippets"
+ help-text="Share code pastes with others out of Git repository"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][snippets_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="snippetsAccessLevel"
+ />
+ </project-setting-row>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
new file mode 100644
index 00000000000..ce47562f259
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -0,0 +1,11 @@
+export const visibilityOptions = {
+ PRIVATE: 0,
+ INTERNAL: 10,
+ PUBLIC: 20,
+};
+
+export const visibilityLevelDescriptions = {
+ [visibilityOptions.PRIVATE]: 'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
+ [visibilityOptions.INTERNAL]: 'The project can be accessed by any user who is logged in.',
+ [visibilityOptions.PUBLIC]: 'The project can be accessed by anyone, regardless of authentication.',
+};
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/external.js b/app/assets/javascripts/pages/projects/shared/permissions/external.js
new file mode 100644
index 00000000000..460af4a2111
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/external.js
@@ -0,0 +1,18 @@
+const selectorCache = [];
+
+// workaround since we don't have a polyfill for classList.toggle 2nd parameter
+export function toggleHiddenClass(element, hidden) {
+ if (hidden) {
+ element.classList.add('hidden');
+ } else {
+ element.classList.remove('hidden');
+ }
+}
+
+// hide external feature-specific settings when a given feature is disabled
+export function toggleHiddenClassBySelector(selector, hidden) {
+ if (!selectorCache[selector]) {
+ selectorCache[selector] = document.querySelectorAll(selector);
+ }
+ selectorCache[selector].forEach(elm => toggleHiddenClass(elm, hidden));
+}
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js
new file mode 100644
index 00000000000..dbde8dda634
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import settingsPanel from './components/settings_panel.vue';
+
+export default function initProjectPermissionsSettings() {
+ const mountPoint = document.querySelector('.js-project-permissions-form');
+ const componentPropsEl = document.querySelector('.js-project-permissions-form-data');
+ const componentProps = JSON.parse(componentPropsEl.innerHTML);
+
+ return new Vue({
+ el: mountPoint,
+ render: createElement => createElement(settingsPanel, { props: { ...componentProps } }),
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/shared/project_avatar.js b/app/assets/javascripts/pages/projects/shared/project_avatar.js
new file mode 100644
index 00000000000..447877752fe
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/project_avatar.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+
+export default function projectAvatar() {
+ $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() {
+ const form = $(this).closest('form');
+ return form.find('.js-project-avatar-input').click();
+ });
+
+ $('.js-project-avatar-input').bind('change', function onClickAvatarInput() {
+ const form = $(this).closest('form');
+ // eslint-disable-next-line no-useless-escape
+ const filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-avatar-filename').text(filename);
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/shared/project_new.js b/app/assets/javascripts/pages/projects/shared/project_new.js
new file mode 100644
index 00000000000..56d5574aa2f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/project_new.js
@@ -0,0 +1,152 @@
+/* eslint-disable func-names, no-var, no-underscore-dangle, prefer-template, prefer-arrow-callback*/
+
+import $ from 'jquery';
+import VisibilitySelect from '../../../visibility_select';
+
+function highlightChanges($elm) {
+ $elm.addClass('highlight-changes');
+ setTimeout(() => $elm.removeClass('highlight-changes'), 10);
+}
+
+export default class ProjectNew {
+ constructor() {
+ 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', () => {
+ $('.project-edit-container').hide();
+ return $('.save-project-loader').show();
+ });
+
+ this.initVisibilitySelect();
+
+ this.toggleSettings();
+ this.toggleSettingsOnclick();
+ this.toggleRepoVisibility();
+ }
+
+ initVisibilitySelect() {
+ const visibilityContainer = document.querySelector('.js-visibility-select');
+ if (!visibilityContainer) return;
+ const visibilitySelect = new 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;
+ }
+ });
+ }
+
+ toggleSettings() {
+ this.$selects.each(function () {
+ var $select = $(this);
+ var className = $select.data('field')
+ .replace(/_/g, '-')
+ .replace('access-level', 'feature');
+ ProjectNew._showOrHide($select, '.' + className);
+ });
+ }
+
+ toggleSettingsOnclick() {
+ this.$selects.on('change', this.toggleSettings);
+ }
+
+ static _showOrHide(checkElement, container) {
+ const $container = $(container);
+
+ if ($(checkElement).val() !== '0') {
+ return $container.show();
+ }
+ return $container.hide();
+ }
+
+ toggleRepoVisibility() {
+ 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()
+ .hide();
+
+ $repoAccessLevel
+ .off('change')
+ .on('change', function () {
+ var selectedVal = parseInt($repoAccessLevel.val(), 10);
+
+ this.$repoSelects.each(function () {
+ var $this = $(this);
+ var repoSelectVal = parseInt($this.val(), 10);
+
+ $this.find('option').enable();
+
+ if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
+ $this.val(selectedVal).trigger('change');
+ highlightChanges($this);
+ }
+
+ $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/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
new file mode 100644
index 00000000000..3b0f0f960b8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import initBlob from '~/blob_edit/blob_bundle';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import NotificationsForm from '~/notifications_form';
+import UserCallout from '~/user_callout';
+import TreeView from '~/tree';
+import BlobViewer from '~/blob/viewer/index';
+import Activities from '~/activities';
+import { ajaxGet } from '~/lib/utils/common_utils';
+import Star from '../../../star';
+import notificationsDropdown from '../../../notifications_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Star(); // eslint-disable-line no-new
+ notificationsDropdown();
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new NotificationsForm(); // eslint-disable-line no-new
+ new UserCallout({ // eslint-disable-line no-new
+ setCalloutPerProject: true,
+ className: 'js-autodevops-banner',
+ });
+
+ // Project show page loads different overview content based on user preferences
+ const treeSlider = document.querySelector('#tree-slider');
+ if (treeSlider) {
+ new TreeView(); // eslint-disable-line no-new
+ initBlob();
+ }
+
+ if (document.querySelector('.blob-viewer')) {
+ new BlobViewer(); // eslint-disable-line no-new
+ }
+
+ if (document.querySelector('.project-show-activity')) {
+ new Activities(); // eslint-disable-line no-new
+ }
+
+ $(treeSlider).waitForImages(() => {
+ ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/snippets/edit/index.js b/app/assets/javascripts/pages/projects/snippets/edit/index.js
new file mode 100644
index 00000000000..53606acc508
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/edit/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import initSnippet from '~/snippet/snippet_bundle';
+import initForm from '~/pages/projects/init_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ initForm($('.snippet-form'));
+});
diff --git a/app/assets/javascripts/pages/projects/snippets/new/index.js b/app/assets/javascripts/pages/projects/snippets/new/index.js
new file mode 100644
index 00000000000..53606acc508
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/new/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import initSnippet from '~/snippet/snippet_bundle';
+import initForm from '~/pages/projects/init_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ initForm($('.snippet-form'));
+});
diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js
new file mode 100644
index 00000000000..a134599cb04
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/show/index.js
@@ -0,0 +1,11 @@
+import initNotes from '~/init_notes';
+import ZenMode from '~/zen_mode';
+import LineHighlighter from '../../../../line_highlighter';
+import BlobViewer from '../../../../blob/viewer';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ initNotes();
+ new ZenMode(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js
new file mode 100644
index 00000000000..8d0edf7e06c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/new/index.js
@@ -0,0 +1,10 @@
+import $ from 'jquery';
+import RefSelectDropdown from '../../../../ref_select_dropdown';
+import ZenMode from '../../../../zen_mode';
+import GLForm from '../../../../gl_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.tag-form'), true); // eslint-disable-line no-new
+ new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
new file mode 100644
index 00000000000..7ad082a5e61
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -0,0 +1,39 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import initBlob from '~/blob_edit/blob_bundle';
+import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import TreeView from '../../../../tree';
+import ShortcutsNavigation from '../../../../shortcuts_navigation';
+import BlobViewer from '../../../../blob/viewer';
+import NewCommitForm from '../../../../new_commit_form';
+import { ajaxGet } from '../../../../lib/utils/common_utils';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new TreeView(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
+ $('#tree-slider').waitForImages(() =>
+ ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath));
+
+ initBlob();
+ const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
+ const statusLink = document.querySelector('.commit-actions .ci-status-link');
+ if (statusLink != null) {
+ statusLink.remove();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: commitPipelineStatusEl,
+ components: {
+ commitPipelineStatus,
+ },
+ render(createElement) {
+ return createElement('commit-pipeline-status', {
+ props: {
+ endpoint: commitPipelineStatusEl.dataset.endpoint,
+ },
+ });
+ },
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js
new file mode 100644
index 00000000000..ec01c66ffda
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -0,0 +1,12 @@
+import $ from 'jquery';
+import Wikis from './wikis';
+import ShortcutsWiki from '../../../shortcuts_wiki';
+import ZenMode from '../../../zen_mode';
+import GLForm from '../../../gl_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Wikis(); // eslint-disable-line no-new
+ new ShortcutsWiki(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.wiki-form'), true); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
new file mode 100644
index 00000000000..34a12ef76a1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -0,0 +1,60 @@
+import bp from '../../../breakpoints';
+import { slugify } from '../../../lib/utils/text_utility';
+
+export default class Wikis {
+ constructor() {
+ this.sidebarEl = document.querySelector('.js-wiki-sidebar');
+ this.sidebarExpanded = false;
+
+ const sidebarToggles = document.querySelectorAll('.js-sidebar-wiki-toggle');
+ for (let i = 0; i < sidebarToggles.length; i += 1) {
+ sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e));
+ }
+
+ this.newWikiForm = document.querySelector('form.new-wiki-page');
+ if (this.newWikiForm) {
+ this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e));
+ }
+
+ window.addEventListener('resize', () => this.renderSidebar());
+ this.renderSidebar();
+ }
+
+ handleNewWikiSubmit(e) {
+ if (!this.newWikiForm) return;
+
+ const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
+ const slug = slugify(slugInput.value);
+
+ if (slug.length > 0) {
+ const wikisPath = slugInput.getAttribute('data-wikis-path');
+ window.location.href = `${wikisPath}/${slug}`;
+ e.preventDefault();
+ }
+ }
+
+ handleToggleSidebar(e) {
+ e.preventDefault();
+ this.sidebarExpanded = !this.sidebarExpanded;
+ this.renderSidebar();
+ }
+
+ static sidebarCanCollapse() {
+ const bootstrapBreakpoint = bp.getBreakpointSize();
+ return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
+ }
+
+ renderSidebar() {
+ if (!this.sidebarEl) return;
+ const { classList } = this.sidebarEl;
+ if (this.sidebarExpanded || !Wikis.sidebarCanCollapse()) {
+ if (!classList.contains('right-sidebar-expanded')) {
+ classList.remove('right-sidebar-collapsed');
+ classList.add('right-sidebar-expanded');
+ }
+ } else if (classList.contains('right-sidebar-expanded')) {
+ classList.add('right-sidebar-collapsed');
+ classList.remove('right-sidebar-expanded');
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js
new file mode 100644
index 00000000000..7fdf4ee0bf3
--- /dev/null
+++ b/app/assets/javascripts/pages/search/init_filtered_search.js
@@ -0,0 +1,23 @@
+import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
+
+export default ({
+ page,
+ filteredSearchTokenKeys,
+ isGroup,
+ isGroupAncestor,
+ isGroupDecendent,
+ stateFiltersSelector,
+}) => {
+ const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search');
+ if (filteredSearchEnabled) {
+ const filteredSearchManager = new FilteredSearchManager({
+ page,
+ isGroup,
+ isGroupAncestor,
+ isGroupDecendent,
+ filteredSearchTokenKeys,
+ stateFiltersSelector,
+ });
+ filteredSearchManager.setup();
+ }
+};
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
new file mode 100644
index 00000000000..85aaaa2c9da
--- /dev/null
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -0,0 +1,3 @@
+import Search from './search';
+
+document.addEventListener('DOMContentLoaded', () => new Search());
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
new file mode 100644
index 00000000000..2e1fe78b3fa
--- /dev/null
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -0,0 +1,116 @@
+import $ from 'jquery';
+import Flash from '~/flash';
+import Api from '~/api';
+
+export default class Search {
+ constructor() {
+ const $groupDropdown = $('.js-search-group-dropdown');
+ const $projectDropdown = $('.js-search-project-dropdown');
+
+ this.searchInput = '.js-search-input';
+ this.searchClear = '.js-search-clear';
+
+ this.groupId = $groupDropdown.data('groupId');
+ this.eventListeners();
+
+ $groupDropdown.glDropdown({
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ fieldName: 'group_id',
+ search: {
+ fields: ['full_name'],
+ },
+ data(term, callback) {
+ return Api.groups(term, {}, (data) => {
+ data.unshift({
+ full_name: 'Any',
+ });
+ data.splice(1, 0, 'divider');
+ return callback(data);
+ });
+ },
+ id(obj) {
+ return obj.id;
+ },
+ text(obj) {
+ return obj.full_name;
+ },
+ toggleLabel(obj) {
+ return `${($groupDropdown.data('defaultLabel'))} ${obj.full_name}`;
+ },
+ clicked: () => Search.submitSearch(),
+ });
+
+ $projectDropdown.glDropdown({
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ fieldName: 'project_id',
+ search: {
+ fields: ['name'],
+ },
+ 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(obj) {
+ return obj.id;
+ },
+ text(obj) {
+ return obj.name_with_namespace;
+ },
+ toggleLabel(obj) {
+ return `${($projectDropdown.data('defaultLabel'))} ${obj.name_with_namespace}`;
+ },
+ clicked: () => Search.submitSearch(),
+ });
+ }
+
+ eventListeners() {
+ $(document)
+ .off('keyup', this.searchInput)
+ .on('keyup', this.searchInput, this.searchKeyUp);
+ $(document)
+ .off('click', this.searchClear)
+ .on('click', this.searchClear, this.clearSearchField.bind(this));
+ }
+
+ static submitSearch() {
+ return $('.js-search-form').submit();
+ }
+
+ searchKeyUp() {
+ const $input = $(this);
+ if ($input.val() === '') {
+ $('.js-search-clear').addClass('hidden');
+ } else {
+ $('.js-search-clear').removeClass('hidden');
+ }
+ }
+
+ clearSearchField() {
+ return $(this.searchInput).val('').trigger('keyup').focus();
+ }
+
+ getProjectsData(term) {
+ return new Promise((resolve) => {
+ if (this.groupId) {
+ Api.groupProjects(this.groupId, term, resolve);
+ } else {
+ Api.projects(term, {
+ order_by: 'id',
+ }, resolve);
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js
new file mode 100644
index 00000000000..c2c069d1ca8
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/index.js
@@ -0,0 +1,3 @@
+import initU2F from '../../shared/sessions/u2f';
+
+document.addEventListener('DOMContentLoaded', initU2F);
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
new file mode 100644
index 00000000000..80a7114f94d
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -0,0 +1,12 @@
+import $ from 'jquery';
+import UsernameValidator from './username_validator';
+import SigninTabsMemoizer from './signin_tabs_memoizer';
+import OAuthRememberMe from './oauth_remember_me';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsernameValidator(); // eslint-disable-line no-new
+ new SigninTabsMemoizer(); // eslint-disable-line no-new
+ new OAuthRememberMe({ // eslint-disable-line no-new
+ container: $('.omniauth-container'),
+ }).bindEvents();
+});
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
new file mode 100644
index 00000000000..53030045292
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -0,0 +1,34 @@
+import $ from 'jquery';
+
+/**
+ * OAuth-based login buttons have a separate "remember me" checkbox.
+ *
+ * Toggling this checkbox adds/removes a `remember_me` parameter to the
+ * login buttons' href, which is passed on to the omniauth callback.
+ **/
+
+export default class OAuthRememberMe {
+ constructor(opts = {}) {
+ this.container = opts.container || '';
+ this.loginLinkSelector = '.oauth-login';
+ }
+
+ bindEvents() {
+ $('#remember_me', this.container).on('click', this.toggleRememberMe);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ toggleRememberMe(event) {
+ const rememberMe = $(event.target).is(':checked');
+
+ $('.oauth-login', this.container).each((i, element) => {
+ const href = $(element).attr('href');
+
+ if (rememberMe) {
+ $(element).attr('href', `${href}?remember_me=1`);
+ } else {
+ $(element).attr('href', href.replace('?remember_me=1', ''));
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
new file mode 100644
index 00000000000..08f0afdcce3
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
@@ -0,0 +1,55 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+
+/**
+ * Memorize the last selected tab after reloading a page.
+ * Does that setting the current selected tab in the localStorage
+ */
+export default class SigninTabsMemoizer {
+ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
+ this.currentTabKey = currentTabKey;
+ this.tabSelector = tabSelector;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ // sets selected tab if given as hash tag
+ if (window.location.hash) {
+ this.saveData(window.location.hash);
+ }
+
+ this.bootstrap();
+ }
+
+ bootstrap() {
+ const tabs = document.querySelectorAll(this.tabSelector);
+ if (tabs.length > 0) {
+ tabs[0].addEventListener('click', (e) => {
+ if (e.target && e.target.nodeName === 'A') {
+ const anchorName = e.target.getAttribute('href');
+ this.saveData(anchorName);
+ }
+ });
+ }
+
+ this.showTab();
+ }
+
+ showTab() {
+ const anchorName = this.readData();
+ if (anchorName) {
+ const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
+ if (tab) {
+ tab.click();
+ }
+ }
+ }
+
+ saveData(val) {
+ if (!this.isLocalStorageAvailable) return undefined;
+
+ return window.localStorage.setItem(this.currentTabKey, val);
+ }
+
+ readData() {
+ if (!this.isLocalStorageAvailable) return null;
+
+ return window.localStorage.getItem(this.currentTabKey);
+ }
+}
diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
new file mode 100644
index 00000000000..825de01b5a2
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -0,0 +1,134 @@
+/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+const debounceTimeoutDuration = 1000;
+const invalidInputClass = 'gl-field-error-outline';
+const successInputClass = 'gl-field-success-outline';
+const unavailableMessageSelector = '.username .validation-error';
+const successMessageSelector = '.username .validation-success';
+const pendingMessageSelector = '.username .validation-pending';
+const invalidMessageSelector = '.username .gl-field-error';
+
+export default class UsernameValidator {
+ constructor() {
+ this.inputElement = $('#new_user_username');
+ this.inputDomElement = this.inputElement.get(0);
+ this.state = {
+ available: false,
+ valid: false,
+ pending: false,
+ empty: true
+ };
+
+ const debounceTimeout = _.debounce((username) => {
+ this.validateUsername(username);
+ }, debounceTimeoutDuration);
+
+ this.inputElement.on('keyup.username_check', () => {
+ const username = this.inputElement.val();
+
+ this.state.valid = this.inputDomElement.validity.valid;
+ this.state.empty = !username.length;
+
+ if (this.state.valid) {
+ return debounceTimeout(username);
+ }
+
+ this.renderState();
+ });
+
+ // Override generic field validation
+ this.inputElement.on('invalid', this.interceptInvalid.bind(this));
+ }
+
+ renderState() {
+ // Clear all state
+ this.clearFieldValidationState();
+
+ if (this.state.valid && this.state.available) {
+ return this.setSuccessState();
+ }
+
+ if (this.state.empty) {
+ return this.clearFieldValidationState();
+ }
+
+ if (this.state.pending) {
+ return this.setPendingState();
+ }
+
+ if (!this.state.available) {
+ return this.setUnavailableState();
+ }
+
+ if (!this.state.valid) {
+ return this.setInvalidState();
+ }
+ }
+
+ interceptInvalid(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ validateUsername(username) {
+ if (this.state.valid) {
+ this.state.pending = true;
+ this.state.available = false;
+ this.renderState();
+ axios.get(`${gon.relative_url_root}/users/${username}/exists`)
+ .then(({ data }) => this.setAvailabilityState(data.exists))
+ .catch(() => flash(__('An error occurred while validating username')));
+ }
+ }
+
+ setAvailabilityState(usernameTaken) {
+ if (usernameTaken) {
+ this.state.valid = false;
+ this.state.available = false;
+ } else {
+ this.state.available = true;
+ }
+ this.state.pending = false;
+ this.renderState();
+ }
+
+ clearFieldValidationState() {
+ this.inputElement.siblings('p').hide();
+
+ this.inputElement.removeClass(invalidInputClass)
+ .removeClass(successInputClass);
+ }
+
+ setUnavailableState() {
+ const $usernameUnavailableMessage = this.inputElement.siblings(unavailableMessageSelector);
+ this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
+ $usernameUnavailableMessage.show();
+ }
+
+ setSuccessState() {
+ const $usernameSuccessMessage = this.inputElement.siblings(successMessageSelector);
+ this.inputElement.addClass(successInputClass).removeClass(invalidInputClass);
+ $usernameSuccessMessage.show();
+ }
+
+ setPendingState() {
+ const $usernamePendingMessage = $(pendingMessageSelector);
+ if (this.state.pending) {
+ $usernamePendingMessage.show();
+ } else {
+ $usernamePendingMessage.hide();
+ }
+ }
+
+ setInvalidState() {
+ const $inputErrorMessage = $(invalidMessageSelector);
+ this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
+ $inputErrorMessage.show();
+ }
+}
diff --git a/app/assets/javascripts/pages/snippets/edit/index.js b/app/assets/javascripts/pages/snippets/edit/index.js
new file mode 100644
index 00000000000..d86e1632ae5
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/edit/index.js
@@ -0,0 +1,7 @@
+import initSnippet from '~/snippet/snippet_bundle';
+import form from '../form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ form();
+});
diff --git a/app/assets/javascripts/pages/snippets/form.js b/app/assets/javascripts/pages/snippets/form.js
new file mode 100644
index 00000000000..72d05da1069
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/form.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import ZenMode from '~/zen_mode';
+
+export default () => {
+ new GLForm($('.snippet-form'), false); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+};
diff --git a/app/assets/javascripts/pages/snippets/new/index.js b/app/assets/javascripts/pages/snippets/new/index.js
new file mode 100644
index 00000000000..d86e1632ae5
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/new/index.js
@@ -0,0 +1,7 @@
+import initSnippet from '~/snippet/snippet_bundle';
+import form from '../form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ form();
+});
diff --git a/app/assets/javascripts/pages/snippets/show/index.js b/app/assets/javascripts/pages/snippets/show/index.js
new file mode 100644
index 00000000000..f548b9fad65
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/show/index.js
@@ -0,0 +1,11 @@
+import LineHighlighter from '../../../line_highlighter';
+import BlobViewer from '../../../blob/viewer';
+import ZenMode from '../../../zen_mode';
+import initNotes from '../../../init_notes';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ initNotes();
+ new ZenMode(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
new file mode 100644
index 00000000000..8ce938c958b
--- /dev/null
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -0,0 +1,243 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import { scaleLinear, scaleThreshold } from 'd3-scale';
+import { select } from 'd3-selection';
+import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+const d3 = { select, scaleLinear, scaleThreshold };
+
+const LOADING_HTML = `
+ <div class="text-center">
+ <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
+ </div>
+`;
+
+function getSystemDate(systemUtcOffsetSeconds) {
+ const date = new Date();
+ const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
+ const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60;
+ date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes);
+ return date;
+}
+
+function formatTooltipText({ date, count }) {
+ const dateObject = new Date(date);
+ const dateDayName = getDayName(dateObject);
+ const dateText = dateObject.format('mmm d, yyyy');
+
+ let contribText = 'No contributions';
+ if (count > 0) {
+ contribText = `${count} contribution${count > 1 ? 's' : ''}`;
+ }
+ return `${contribText}<br />${dateDayName} ${dateText}`;
+}
+
+const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]);
+
+export default class ActivityCalendar {
+ constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) {
+ this.calendarActivitiesPath = calendarActivitiesPath;
+ this.clickDay = this.clickDay.bind(this);
+ this.currentSelectedDate = '';
+ this.daySpace = 1;
+ this.daySize = 15;
+ this.daySizeWithSpace = this.daySize + (this.daySpace * 2);
+ this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ this.months = [];
+
+ // Loop through the timestamps to create a group of objects
+ // The group of objects will be grouped based on the day of the week they are
+ this.timestampsTmp = [];
+ let group = 0;
+
+ const today = getSystemDate(utcOffset);
+ today.setHours(0, 0, 0, 0, 0);
+
+ const oneYearAgo = new Date(today);
+ oneYearAgo.setFullYear(today.getFullYear() - 1);
+
+ const days = getDayDifference(oneYearAgo, today);
+
+ for (let i = 0; i <= days; i += 1) {
+ const date = new Date(oneYearAgo);
+ date.setDate(date.getDate() + i);
+
+ const day = date.getDay();
+ const count = timestamps[date.format('yyyy-mm-dd')] || 0;
+
+ // Create a new group array if this is the first day of the week
+ // or if is first object
+ if ((day === 0 && i !== 0) || i === 0) {
+ this.timestampsTmp.push([]);
+ group += 1;
+ }
+
+ // Push to the inner array the values that will be used to render map
+ const innerArray = this.timestampsTmp[group - 1];
+ innerArray.push({ count, date, day });
+ }
+
+ // Init color functions
+ this.colorKey = initColorKey();
+ this.color = this.initColor();
+
+ // Init the svg element
+ this.svg = this.renderSvg(container, group);
+ this.renderDays();
+ this.renderMonths();
+ this.renderDayTitles();
+ this.renderKey();
+
+ // Init tooltips
+ $(`${container} .js-tooltip`).tooltip({ html: true });
+ }
+
+ // Add extra padding for the last month label if it is also the last column
+ getExtraWidthPadding(group) {
+ let extraWidthPadding = 0;
+ const lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth();
+ const secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth();
+
+ if (lastColMonth !== secondLastColMonth) {
+ extraWidthPadding = 6;
+ }
+
+ return extraWidthPadding;
+ }
+
+ renderSvg(container, group) {
+ const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group);
+ return d3.select(container)
+ .append('svg')
+ .attr('width', width)
+ .attr('height', 167)
+ .attr('class', 'contrib-calendar');
+ }
+
+ renderDays() {
+ this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g')
+ .attr('transform', (group, i) => {
+ _.each(group, (stamp, a) => {
+ if (a === 0 && stamp.day === 0) {
+ const month = stamp.date.getMonth();
+ const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace;
+ const lastMonth = _.last(this.months);
+ if (
+ lastMonth == null ||
+ (month !== lastMonth.month && x - this.daySizeWithSpace !== lastMonth.x)
+ ) {
+ this.months.push({ month, x });
+ }
+ }
+ });
+ return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`;
+ })
+ .selectAll('rect')
+ .data(stamp => stamp)
+ .enter()
+ .append('rect')
+ .attr('x', '0')
+ .attr('y', stamp => this.daySizeWithSpace * stamp.day)
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('fill', stamp => (
+ stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'
+ ))
+ .attr('title', stamp => formatTooltipText(stamp))
+ .attr('class', 'user-contrib-cell js-tooltip')
+ .attr('data-container', 'body')
+ .on('click', this.clickDay);
+ }
+
+ renderDayTitles() {
+ const days = [
+ {
+ text: 'M',
+ y: 29 + (this.daySizeWithSpace * 1),
+ }, {
+ text: 'W',
+ y: 29 + (this.daySizeWithSpace * 3),
+ }, {
+ text: 'F',
+ y: 29 + (this.daySizeWithSpace * 5),
+ },
+ ];
+ this.svg.append('g')
+ .selectAll('text')
+ .data(days)
+ .enter()
+ .append('text')
+ .attr('text-anchor', 'middle')
+ .attr('x', 8)
+ .attr('y', day => day.y)
+ .text(day => day.text)
+ .attr('class', 'user-contrib-text');
+ }
+
+ renderMonths() {
+ this.svg.append('g')
+ .attr('direction', 'ltr')
+ .selectAll('text')
+ .data(this.months)
+ .enter()
+ .append('text')
+ .attr('x', date => date.x)
+ .attr('y', 10)
+ .attr('class', 'user-contrib-text')
+ .text(date => this.monthNames[date.month]);
+ }
+
+ renderKey() {
+ const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions'];
+ const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+
+ this.svg.append('g')
+ .attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`)
+ .selectAll('rect')
+ .data(keyColors)
+ .enter()
+ .append('rect')
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('x', (color, i) => this.daySizeWithSpace * i)
+ .attr('y', 0)
+ .attr('fill', color => color)
+ .attr('class', 'js-tooltip')
+ .attr('title', (color, i) => keyValues[i])
+ .attr('data-container', 'body');
+ }
+
+ initColor() {
+ const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+ return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange);
+ }
+
+ clickDay(stamp) {
+ if (this.currentSelectedDate !== stamp.date) {
+ this.currentSelectedDate = stamp.date;
+
+ const date = [
+ this.currentSelectedDate.getFullYear(),
+ this.currentSelectedDate.getMonth() + 1,
+ this.currentSelectedDate.getDate(),
+ ].join('-');
+
+ $('.user-calendar-activities').html(LOADING_HTML);
+
+ axios.get(this.calendarActivitiesPath, {
+ params: {
+ date,
+ },
+ responseType: 'text',
+ })
+ .then(({ data }) => $('.user-calendar-activities').html(data))
+ .catch(() => flash(__('An error occurred while retrieving calendar activity')));
+ } else {
+ this.currentSelectedDate = '';
+ $('.user-calendar-activities').html('');
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
new file mode 100644
index 00000000000..6b1626b0161
--- /dev/null
+++ b/app/assets/javascripts/pages/users/index.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+import UserCallout from '~/user_callout';
+import Cookies from 'js-cookie';
+import UserTabs from './user_tabs';
+
+function initUserProfile(action) {
+ // place profile avatars to top
+ $('.profile-groups-avatars').tooltip({
+ placement: 'top',
+ });
+
+ // eslint-disable-next-line no-new
+ new UserTabs({ parentEl: '.user-profile', action });
+
+ // hide project limit message
+ $('.hide-project-limit-message').on('click', (e) => {
+ e.preventDefault();
+ Cookies.set('hide_project_limit_message', 'false');
+ $(this).parents('.project-limit-message').remove();
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const page = $('body').attr('data-page');
+ const action = page.split(':')[1];
+ initUserProfile(action);
+ new UserCallout(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
new file mode 100644
index 00000000000..124bc2ba710
--- /dev/null
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -0,0 +1,200 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import Activities from '~/activities';
+import { localTimeAgo } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
+import flash from '~/flash';
+import ActivityCalendar from './activity_calendar';
+
+/**
+ * UserTabs
+ *
+ * Handles persisting and restoring the current tab selection and lazily-loading
+ * content on the Users#show page.
+ *
+ * ### Example Markup
+ *
+ * <ul class="nav-links">
+ * <li class="activity-tab active">
+ * <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username">
+ * Activity
+ * </a>
+ * </li>
+ * <li class="groups-tab">
+ * <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups">
+ * Groups
+ * </a>
+ * </li>
+ * <li class="contributed-tab">
+ * ...
+ * </li>
+ * <li class="projects-tab">
+ * ...
+ * </li>
+ * <li class="snippets-tab">
+ * ...
+ * </li>
+ * </ul>
+ *
+ * <div class="tab-content">
+ * <div class="tab-pane" id="activity">
+ * Activity Content
+ * </div>
+ * <div class="tab-pane" id="groups">
+ * Groups Content
+ * </div>
+ * <div class="tab-pane" id="contributed">
+ * Contributed projects content
+ * </div>
+ * <div class="tab-pane" id="projects">
+ * Projects content
+ * </div>
+ * <div class="tab-pane" id="snippets">
+ * Snippets content
+ * </div>
+ * </div>
+ *
+ * <div class="loading-status">
+ * <div class="loading">
+ * Loading Animation
+ * </div>
+ * </div>
+ */
+
+const CALENDAR_TEMPLATE = `
+ <div class="clearfix calendar">
+ <div class="js-contrib-calendar"></div>
+ <div class="calendar-hint">
+ Summary of issues, merge requests, push events, and comments
+ </div>
+ </div>
+`;
+
+export default class UserTabs {
+ constructor({ defaultAction, action, parentEl }) {
+ this.loaded = {};
+ this.defaultAction = defaultAction || 'activity';
+ this.action = action || this.defaultAction;
+ this.$parentEl = $(parentEl) || $(document);
+ this.windowLocation = window.location;
+ this.$parentEl.find('.nav-links a')
+ .each((i, navLink) => {
+ this.loaded[$(navLink).attr('data-action')] = false;
+ });
+ this.actions = Object.keys(this.loaded);
+ this.bindEvents();
+
+ if (this.action === 'show') {
+ this.action = this.defaultAction;
+ }
+
+ this.activateTab(this.action);
+ }
+
+ bindEvents() {
+ this.$parentEl
+ .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
+ .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event))
+ .on('click', '.gl-pagination a', event => this.changeProjectsPage(event));
+ }
+
+ changeProjectsPage(e) {
+ e.preventDefault();
+
+ $('.tab-pane.active').empty();
+ const endpoint = $(e.target).attr('href');
+ this.loadTab(this.getCurrentAction(), endpoint);
+ }
+
+ tabShown(event) {
+ const $target = $(event.target);
+ const action = $target.data('action');
+ const source = $target.attr('href');
+ const endpoint = $target.data('endpoint');
+ this.setTab(action, endpoint);
+ return this.setCurrentAction(source);
+ }
+
+ activateTab(action) {
+ return this.$parentEl.find(`.nav-links .js-${action}-tab a`)
+ .tab('show');
+ }
+
+ setTab(action, endpoint) {
+ if (this.loaded[action]) {
+ return;
+ }
+ if (action === 'activity') {
+ this.loadActivities();
+ }
+
+ const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
+ if (loadableActions.indexOf(action) > -1) {
+ this.loadTab(action, endpoint);
+ }
+ }
+
+ loadTab(action, endpoint) {
+ this.toggleLoading(true);
+
+ return axios.get(endpoint)
+ .then(({ data }) => {
+ const tabSelector = `div#${action}`;
+ this.$parentEl.find(tabSelector).html(data.html);
+ this.loaded[action] = true;
+ localTimeAgo($('.js-timeago', tabSelector));
+
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ });
+ }
+
+ loadActivities() {
+ if (this.loaded.activity) {
+ return;
+ }
+ const $calendarWrap = this.$parentEl.find('.user-calendar');
+ const calendarPath = $calendarWrap.data('calendarPath');
+ const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath');
+ const utcOffset = $calendarWrap.data('utcOffset');
+ let utcFormatted = 'UTC';
+ if (utcOffset !== 0) {
+ utcFormatted = `UTC${utcOffset > 0 ? '+' : ''}${(utcOffset / 3600)}`;
+ }
+
+ axios.get(calendarPath)
+ .then(({ data }) => {
+ $calendarWrap.html(CALENDAR_TEMPLATE);
+ $calendarWrap.find('.calendar-hint').append(`(Timezone: ${utcFormatted})`);
+
+ // eslint-disable-next-line no-new
+ new ActivityCalendar('.js-contrib-calendar', data, calendarActivitiesPath, utcOffset);
+ })
+ .catch(() => flash(__('There was an error loading users activity calendar.')));
+
+ // eslint-disable-next-line no-new
+ new Activities();
+ this.loaded.activity = true;
+ }
+
+ toggleLoading(status) {
+ return this.$parentEl.find('.loading-status .loading')
+ .toggle(status);
+ }
+
+ setCurrentAction(source) {
+ let newState = source;
+ newState = newState.replace(/\/+$/, '');
+ newState += this.windowLocation.search + this.windowLocation.hash;
+ history.replaceState({
+ url: newState,
+ }, document.title, newState);
+ return newState;
+ }
+
+ getCurrentAction() {
+ return this.$parentEl.find('.nav-links .active a').data('action');
+ }
+}
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index 4603859d7b0..00f32d9de78 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -1,20 +1,11 @@
-<template>
- <div class="pdf-viewer" v-if="hasPDF">
- <page v-for="(page, index) in pages"
- :key="index"
- :v-if="!loading"
- :page="page"
- :number="index + 1" />
- </div>
-</template>
-
<script>
- import pdfjsLib from 'pdfjs-dist';
- import workerSrc from 'vendor/pdf.worker';
+ import pdfjsLib from 'vendor/pdf';
+ import workerSrc from 'vendor/pdf.worker.min';
import page from './page/index.vue';
export default {
+ components: { page },
props: {
pdf: {
type: [String, Uint8Array],
@@ -27,8 +18,6 @@
pages: [],
};
},
- components: { page },
- watch: { pdf: 'load' },
computed: {
document() {
return typeof this.pdf === 'string' ? this.pdf : { data: this.pdf };
@@ -37,6 +26,11 @@
return this.pdf && this.pdf.length > 0;
},
},
+ watch: { pdf: 'load' },
+ mounted() {
+ pdfjsLib.PDFJS.workerSrc = workerSrc;
+ if (this.hasPDF) this.load();
+ },
methods: {
load() {
this.pages = [];
@@ -57,13 +51,23 @@
return Promise.all(pagePromises);
},
},
- mounted() {
- pdfjsLib.PDFJS.workerSrc = workerSrc;
- if (this.hasPDF) this.load();
- },
};
</script>
+<template>
+ <div
+ class="pdf-viewer"
+ v-if="hasPDF">
+ <page
+ v-for="(page, index) in pages"
+ :key="index"
+ :v-if="!loading"
+ :page="page"
+ :number="index + 1"
+ />
+ </div>
+</template>
+
<style>
.pdf-viewer {
background: url('./assets/img/bg.gif');
diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue
index 7b74ee4eb2e..fcba819beba 100644
--- a/app/assets/javascripts/pdf/page/index.vue
+++ b/app/assets/javascripts/pdf/page/index.vue
@@ -1,10 +1,3 @@
-<template>
- <canvas
- class="pdf-page"
- ref="canvas"
- :data-page="number" />
-</template>
-
<script>
export default {
props: {
@@ -48,21 +41,30 @@
};
</script>
+<template>
+ <canvas
+ class="pdf-page"
+ ref="canvas"
+ :data-page="number"
+ >
+ </canvas>
+</template>
+
<style>
-.pdf-page {
- margin: 8px auto 0 auto;
- border-top: 1px #ddd solid;
- border-bottom: 1px #ddd solid;
- width: 100%;
-}
+ .pdf-page {
+ margin: 8px auto 0 auto;
+ border-top: 1px #ddd solid;
+ border-bottom: 1px #ddd solid;
+ width: 100%;
+ }
-.pdf-page:first-child {
- margin-top: 0px;
- border-top: 0px;
-}
+ .pdf-page:first-child {
+ margin-top: 0px;
+ border-top: 0px;
+ }
-.pdf-page:last-child {
- margin-bottom: 0px;
- border-bottom: 0px;
-}
+ .pdf-page:last-child {
+ margin-bottom: 0px;
+ border-bottom: 0px;
+ }
</style>
diff --git a/app/assets/javascripts/peek.js b/app/assets/javascripts/peek.js
deleted file mode 100644
index de1a99fa3bd..00000000000
--- a/app/assets/javascripts/peek.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import 'vendor/peek';
-import 'vendor/peek.performance_bar';
-
-$(document).on('click', '#peek-show-queries', (e) => {
- e.preventDefault();
- $('.peek-rblineprof-modal').hide();
- const $modal = $('#modal-peek-pg-queries');
- if ($modal.length) {
- $modal.modal('toggle');
- }
-});
-
-$(document).on('click', '.js-lineprof-file', (e) => {
- e.preventDefault();
- $(e.target).parents('.peek-rblineprof-file').find('.data').toggle();
-});
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
new file mode 100644
index 00000000000..db8a0055acd
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -0,0 +1,93 @@
+<script>
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ metric: {
+ type: String,
+ required: true,
+ },
+ header: {
+ type: String,
+ required: true,
+ },
+ details: {
+ type: String,
+ required: true,
+ },
+ keys: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ metricDetails() {
+ return this.currentRequest.details[this.metric];
+ },
+ detailsList() {
+ return this.metricDetails[this.details];
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`peek-view-${metric}`"
+ class="view"
+ v-if="currentRequest.details"
+ >
+ <button
+ :data-target="`#modal-peek-${metric}-details`"
+ class="btn-blank btn-link bold"
+ type="button"
+ data-toggle="modal"
+ >
+ {{ metricDetails.duration }}
+ /
+ {{ metricDetails.calls }}
+ </button>
+ <gl-modal
+ :id="`modal-peek-${metric}-details`"
+ :header-title-text="header"
+ class="performance-bar-modal"
+ >
+ <table
+ class="table"
+ >
+ <template v-if="detailsList.length">
+ <tr
+ v-for="(item, index) in detailsList"
+ :key="index"
+ >
+ <td><strong>{{ item.duration }}ms</strong></td>
+ <td
+ v-for="key in keys"
+ :key="key"
+ class="break-word"
+ >
+ {{ item[key] }}
+ </td>
+ </tr>
+ </template>
+ <template v-else>
+ <tr>
+ <td>
+ No {{ header.toLowerCase() }} for this request.
+ </td>
+ </tr>
+ </template>
+ </table>
+
+ <div slot="footer">
+ </div>
+ </gl-modal>
+ {{ metric }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
new file mode 100644
index 00000000000..2fd1715ee79
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -0,0 +1,191 @@
+<script>
+import $ from 'jquery';
+
+import PerformanceBarService from '../services/performance_bar_service';
+import detailedMetric from './detailed_metric.vue';
+import requestSelector from './request_selector.vue';
+import simpleMetric from './simple_metric.vue';
+import upstreamPerformanceBar from './upstream_performance_bar.vue';
+
+import Flash from '../../flash';
+
+export default {
+ components: {
+ detailedMetric,
+ requestSelector,
+ simpleMetric,
+ upstreamPerformanceBar,
+ },
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ env: {
+ type: String,
+ required: true,
+ },
+ requestId: {
+ type: String,
+ required: true,
+ },
+ peekUrl: {
+ type: String,
+ required: true,
+ },
+ profileUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ detailedMetrics: [
+ { metric: 'pg', header: 'SQL queries', details: 'queries', keys: ['sql'] },
+ {
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ },
+ ],
+ simpleMetrics: ['redis', 'sidekiq'],
+ data() {
+ return { currentRequestId: '' };
+ },
+ computed: {
+ requests() {
+ return this.store.requestsWithDetails();
+ },
+ currentRequest: {
+ get() {
+ return this.store.findRequest(this.currentRequestId);
+ },
+ set(requestId) {
+ this.currentRequestId = requestId;
+ },
+ },
+ initialRequest() {
+ return this.currentRequestId === this.requestId;
+ },
+ lineProfileModal() {
+ return $('#modal-peek-line-profile');
+ },
+ },
+ mounted() {
+ this.interceptor = PerformanceBarService.registerInterceptor(
+ this.peekUrl,
+ this.loadRequestDetails,
+ );
+
+ this.loadRequestDetails(this.requestId, window.location.href);
+ this.currentRequest = this.requestId;
+
+ if (this.lineProfileModal.length) {
+ this.lineProfileModal.modal('toggle');
+ }
+ },
+ beforeDestroy() {
+ PerformanceBarService.removeInterceptor(this.interceptor);
+ },
+ methods: {
+ loadRequestDetails(requestId, requestUrl) {
+ if (!this.store.canTrackRequest(requestUrl)) {
+ return;
+ }
+
+ this.store.addRequest(requestId, requestUrl);
+
+ PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
+ .then(res => {
+ this.store.addRequestDetails(requestId, res.data.data);
+ })
+ .catch(() =>
+ Flash(`Error getting performance bar results for ${requestId}`),
+ );
+ },
+ changeCurrentRequest(newRequestId) {
+ this.currentRequest = newRequestId;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ id="js-peek"
+ :class="env"
+ >
+ <div
+ v-if="currentRequest"
+ class="container-fluid container-limited"
+ >
+ <div
+ id="peek-view-host"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="current-host"
+ >
+ {{ currentRequest.details.host.hostname }}
+ </span>
+ </div>
+ <upstream-performance-bar
+ v-if="initialRequest && currentRequest.details"
+ />
+ <detailed-metric
+ v-for="metric in $options.detailedMetrics"
+ :key="metric.metric"
+ :current-request="currentRequest"
+ :metric="metric.metric"
+ :header="metric.header"
+ :details="metric.details"
+ :keys="metric.keys"
+ />
+ <div
+ v-if="initialRequest"
+ id="peek-view-rblineprof"
+ class="view"
+ >
+ <button
+ v-if="lineProfileModal.length"
+ class="btn-link btn-blank"
+ data-toggle="modal"
+ data-target="#modal-peek-line-profile"
+ >
+ profile
+ </button>
+ <a
+ v-else
+ :href="profileUrl"
+ >
+ profile
+ </a>
+ </div>
+ <simple-metric
+ v-for="metric in $options.simpleMetrics"
+ :current-request="currentRequest"
+ :key="metric"
+ :metric="metric"
+ />
+ <div
+ id="peek-view-gc"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ <span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span>ms
+ /
+ <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span>
+ gc
+ </span>
+ </div>
+ <request-selector
+ v-if="currentRequest"
+ :current-request="currentRequest"
+ :requests="requests"
+ @change-current-request="changeCurrentRequest"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
new file mode 100644
index 00000000000..3ed07a4a47d
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -0,0 +1,52 @@
+<script>
+export default {
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ requests: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentRequestId: this.currentRequest.id,
+ };
+ },
+ watch: {
+ currentRequestId(newRequestId) {
+ this.$emit('change-current-request', newRequestId);
+ },
+ },
+ methods: {
+ truncatedUrl(requestUrl) {
+ const components = requestUrl.replace(/\/$/, '').split('/');
+ let truncated = components[components.length - 1];
+
+ if (truncated.match(/^\d+$/)) {
+ truncated = `${components[components.length - 2]}/${truncated}`;
+ }
+
+ return truncated;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ id="peek-request-selector"
+ class="pull-right"
+ >
+ <select v-model="currentRequestId">
+ <option
+ v-for="request in requests"
+ :key="request.id"
+ :value="request.id"
+ >
+ {{ truncatedUrl(request.url) }}
+ </option>
+ </select>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue
new file mode 100644
index 00000000000..b654bc66249
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/simple_metric.vue
@@ -0,0 +1,30 @@
+<script>
+export default {
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ metric: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`peek-view-${metric}`"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ {{ currentRequest.details[metric].duration }}
+ /
+ {{ currentRequest.details[metric].calls }}
+ </span>
+ {{ metric }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
new file mode 100644
index 00000000000..2b5915f381f
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
@@ -0,0 +1,20 @@
+<script>
+export default {
+ mounted() {
+ const upstreamPerformanceBar = document
+ .getElementById('peek-view-performance-bar')
+ .cloneNode(true);
+
+ upstreamPerformanceBar.classList.remove('hidden');
+
+ this.$refs.wrapper.appendChild(upstreamPerformanceBar);
+ },
+};
+</script>
+<template>
+ <div
+ id="peek-view-performance-bar-vue"
+ class="view"
+ ref="wrapper"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
new file mode 100644
index 00000000000..a0ddf36a672
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -0,0 +1,37 @@
+import 'vendor/peek.performance_bar';
+
+import Vue from 'vue';
+import performanceBarApp from './components/performance_bar_app.vue';
+import PerformanceBarStore from './stores/performance_bar_store';
+
+export default ({ container }) =>
+ new Vue({
+ el: container,
+ components: {
+ performanceBarApp,
+ },
+ data() {
+ const performanceBarData = document.querySelector(this.$options.el)
+ .dataset;
+ const store = new PerformanceBarStore();
+
+ return {
+ store,
+ env: performanceBarData.env,
+ requestId: performanceBarData.requestId,
+ peekUrl: performanceBarData.peekUrl,
+ profileUrl: performanceBarData.profileUrl,
+ };
+ },
+ render(createElement) {
+ return createElement('performance-bar-app', {
+ props: {
+ store: this.store,
+ env: this.env,
+ requestId: this.requestId,
+ peekUrl: this.peekUrl,
+ profileUrl: this.profileUrl,
+ },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
new file mode 100644
index 00000000000..3ebfaa87a4e
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import _ from 'underscore';
+import axios from '../../lib/utils/axios_utils';
+
+let vueResourceInterceptor;
+
+export default class PerformanceBarService {
+ static fetchRequestDetails(peekUrl, requestId) {
+ return axios.get(peekUrl, { params: { request_id: requestId } });
+ }
+
+ static registerInterceptor(peekUrl, callback) {
+ vueResourceInterceptor = (request, next) => {
+ next(response => {
+ const requestId = response.headers['x-request-id'];
+ const requestUrl = response.url;
+
+ if (requestUrl !== peekUrl && requestId) {
+ callback(requestId, requestUrl);
+ }
+ });
+ };
+
+ Vue.http.interceptors.push(vueResourceInterceptor);
+
+ return axios.interceptors.response.use(response => {
+ const requestId = response.headers['x-request-id'];
+ const requestUrl = response.config.url;
+
+ if (requestUrl !== peekUrl && requestId) {
+ callback(requestId, requestUrl);
+ }
+
+ return response;
+ });
+ }
+
+ static removeInterceptor(interceptor) {
+ axios.interceptors.response.eject(interceptor);
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors,
+ vueResourceInterceptor,
+ );
+ }
+}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
new file mode 100644
index 00000000000..c6b2f55243c
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -0,0 +1,39 @@
+export default class PerformanceBarStore {
+ constructor() {
+ this.requests = [];
+ }
+
+ addRequest(requestId, requestUrl, requestDetails) {
+ if (!this.findRequest(requestId)) {
+ this.requests.push({
+ id: requestId,
+ url: requestUrl,
+ details: requestDetails,
+ });
+ }
+
+ return this.requests;
+ }
+
+ findRequest(requestId) {
+ return this.requests.find(request => request.id === requestId);
+ }
+
+ addRequestDetails(requestId, requestDetails) {
+ const request = this.findRequest(requestId);
+
+ request.details = requestDetails;
+
+ return request;
+ }
+
+ requestsWithDetails() {
+ return this.requests.filter(request => request.details);
+ }
+
+ canTrackRequest(requestUrl) {
+ return (
+ this.requests.filter(request => request.url === requestUrl).length < 2
+ );
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js
deleted file mode 100644
index 901adbe9fce..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js
+++ /dev/null
@@ -1,148 +0,0 @@
-import Vue from 'vue';
-import Translate from '../../vue_shared/translate';
-
-Vue.use(Translate);
-
-const inputNameAttribute = 'schedule[cron]';
-
-export default {
- props: {
- initialCronInterval: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- inputNameAttribute,
- cronInterval: this.initialCronInterval,
- cronIntervalPresets: {
- everyDay: '0 4 * * *',
- everyWeek: '0 4 * * 0',
- everyMonth: '0 4 1 * *',
- },
- cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
- customInputEnabled: false,
- };
- },
- computed: {
- intervalIsPreset() {
- return _.contains(this.cronIntervalPresets, this.cronInterval);
- },
- // The text input is editable when there's a custom interval, or when it's
- // a preset interval and the user clicks the 'custom' radio button
- isEditable() {
- return !!(this.customInputEnabled || !this.intervalIsPreset);
- },
- },
- methods: {
- toggleCustomInput(shouldEnable) {
- this.customInputEnabled = shouldEnable;
-
- if (shouldEnable) {
- // We need to change the value so other radios don't remain selected
- // because the model (cronInterval) hasn't changed. The server trims it.
- this.cronInterval = `${this.cronInterval} `;
- }
- },
- },
- created() {
- if (this.intervalIsPreset) {
- this.enableCustomInput = false;
- }
- },
- watch: {
- cronInterval() {
- // updates field validation state when model changes, as
- // glFieldError only updates on input.
- Vue.nextTick(() => {
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- });
- },
- },
- template: `
- <div class="interval-pattern-form-group">
- <div class="cron-preset-radio-input">
- <input
- id="custom"
- class="label-light"
- type="radio"
- :name="inputNameAttribute"
- :value="cronInterval"
- :checked="isEditable"
- @click="toggleCustomInput(true)"
- />
-
- <label for="custom">
- {{ s__('PipelineSheduleIntervalPattern|Custom') }}
- </label>
-
- <span class="cron-syntax-link-wrap">
- (<a :href="cronSyntaxUrl" target="_blank">{{ __('Cron syntax') }}</a>)
- </span>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-day"
- class="label-light"
- type="radio"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyDay"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-light" for="every-day">
- {{ __('Every day (at 4:00am)') }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-week"
- class="label-light"
- type="radio"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyWeek"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-light" for="every-week">
- {{ __('Every week (Sundays at 4:00am)') }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-month"
- class="label-light"
- type="radio"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyMonth"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-light" for="every-month">
- {{ __('Every month (on the 1st at 4:00am)') }}
- </label>
- </div>
-
- <div class="cron-interval-input-wrapper">
- <input
- id="schedule_cron"
- class="form-control inline cron-interval-input"
- type="text"
- :placeholder="__('Define a custom pattern with cron syntax')"
- required="true"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :disabled="!isEditable"
- />
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js
deleted file mode 100644
index c827b7402dc..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import Vue from 'vue';
-import Cookies from 'js-cookie';
-import Translate from '../../vue_shared/translate';
-import illustrationSvg from '../icons/intro_illustration.svg';
-
-Vue.use(Translate);
-
-const cookieKey = 'pipeline_schedules_callout_dismissed';
-
-export default {
- name: 'PipelineSchedulesCallout',
- data() {
- return {
- docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
- illustrationSvg,
- calloutDismissed: Cookies.get(cookieKey) === 'true',
- };
- },
- methods: {
- dismissCallout() {
- this.calloutDismissed = true;
- Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
- },
- },
- template: `
- <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
- <div class="bordered-box landing content-block">
- <button
- id="dismiss-callout-btn"
- class="btn btn-default close"
- @click="dismissCallout">
- <i class="fa fa-times"></i>
- </button>
- <div class="svg-container" v-html="illustrationSvg"></div>
- <div class="user-callout-copy">
- <h4>{{ __('Scheduling Pipelines') }}</h4>
- <p>
- {{ __('The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.') }}
- </p>
- <p> {{ __('Learn more in the') }}
- <a
- :href="docsUrl"
- target="_blank"
- rel="nofollow">{{ s__('Learn more in the|pipeline schedules documentation') }}</a>. <!-- oneline to prevent extra space before period -->
- </p>
- </div>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
deleted file mode 100644
index 0c3926d76b5..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
+++ /dev/null
@@ -1,52 +0,0 @@
-export default class TargetBranchDropdown {
- constructor() {
- this.$dropdown = $('.js-target-branch-dropdown');
- this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
- this.$input = $('#schedule_ref');
- this.initDefaultBranch();
- this.initDropdown();
- }
-
- initDropdown() {
- this.$dropdown.glDropdown({
- data: this.formatBranchesList(),
- filterable: true,
- selectable: true,
- toggleLabel: item => item.name,
- search: {
- fields: ['name'],
- },
- clicked: cfg => this.updateInputValue(cfg),
- text: item => item.name,
- });
-
- this.setDropdownToggle();
- }
-
- formatBranchesList() {
- return this.$dropdown.data('data')
- .map(val => ({ name: val }));
- }
-
- setDropdownToggle() {
- const initialValue = this.$input.val();
-
- this.$dropdownToggle.text(initialValue);
- }
-
- initDefaultBranch() {
- const initialValue = this.$input.val();
- const defaultBranch = this.$dropdown.data('defaultBranch');
-
- if (!initialValue) {
- this.$input.val(defaultBranch);
- }
- }
-
- updateInputValue({ selectedObj, e }) {
- e.preventDefault();
-
- this.$input.val(selectedObj.name);
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- }
-}
diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
deleted file mode 100644
index 95ed9c7dc21..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/* eslint-disable class-methods-use-this */
-
-const defaultTimezone = 'UTC';
-
-export default class TimezoneDropdown {
- constructor() {
- this.$dropdown = $('.js-timezone-dropdown');
- this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
- this.$input = $('#schedule_cron_timezone');
- this.timezoneData = this.$dropdown.data('data');
- this.initDefaultTimezone();
- this.initDropdown();
- }
-
- initDropdown() {
- this.$dropdown.glDropdown({
- data: this.timezoneData,
- filterable: true,
- selectable: true,
- toggleLabel: item => item.name,
- search: {
- fields: ['name'],
- },
- clicked: cfg => this.updateInputValue(cfg),
- text: item => this.formatTimezone(item),
- });
-
- this.setDropdownToggle();
- }
-
- formatUtcOffset(offset) {
- let prefix = '';
-
- if (offset > 0) {
- prefix = '+';
- } else if (offset < 0) {
- prefix = '-';
- }
-
- return `${prefix} ${Math.abs(offset / 3600)}`;
- }
-
- formatTimezone(item) {
- return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
- }
-
- initDefaultTimezone() {
- const initialValue = this.$input.val();
-
- if (!initialValue) {
- this.$input.val(defaultTimezone);
- }
- }
-
- setDropdownToggle() {
- const initialValue = this.$input.val();
-
- this.$dropdownToggle.text(initialValue);
- }
-
- updateInputValue({ selectedObj, e }) {
- e.preventDefault();
- this.$input.val(selectedObj.identifier);
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- }
-}
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
deleted file mode 100644
index c60e77decce..00000000000
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import Vue from 'vue';
-import IntervalPatternInput from './components/interval_pattern_input';
-import TimezoneDropdown from './components/timezone_dropdown';
-import TargetBranchDropdown from './components/target_branch_dropdown';
-
-document.addEventListener('DOMContentLoaded', () => {
- const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput);
- const intervalPatternMount = document.getElementById('interval-pattern-input');
- const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : '';
-
- new IntervalPatternInputComponent({
- propsData: {
- initialCronInterval,
- },
- }).$mount(intervalPatternMount);
-
- const formElement = document.getElementById('new-pipeline-schedule-form');
- gl.timezoneDropdown = new TimezoneDropdown();
- gl.targetBranchDropdown = new TargetBranchDropdown();
- gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
-});
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
deleted file mode 100644
index 6584549ad06..00000000000
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-import PipelineSchedulesCallout from './components/pipeline_schedules_callout';
-
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#pipeline-schedules-callout',
- components: {
- 'pipeline-schedules-callout': PipelineSchedulesCallout,
- },
- render(createElement) {
- return createElement('pipeline-schedules-callout');
- },
-}));
diff --git a/app/assets/javascripts/pipelines.js b/app/assets/javascripts/pipelines.js
index 26a36ad54d1..07abe714367 100644
--- a/app/assets/javascripts/pipelines.js
+++ b/app/assets/javascripts/pipelines.js
@@ -1,4 +1,5 @@
import LinkedTabs from './lib/utils/bootstrap_linked_tabs';
+import { setCiStatusFavicon } from './lib/utils/common_utils';
export default class Pipelines {
constructor(options = {}) {
@@ -8,7 +9,7 @@ export default class Pipelines {
}
if (options.pipelineStatusUrl) {
- gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
+ setCiStatusFavicon(options.pipelineStatusUrl);
}
}
}
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue
index 16cc0761fc1..0cdffbde05b 100644
--- a/app/assets/javascripts/pipelines/components/async_button.vue
+++ b/app/assets/javascripts/pipelines/components/async_button.vue
@@ -1,67 +1,79 @@
<script>
-/* eslint-disable no-new, no-alert */
+ /* eslint-disable no-alert */
-import eventHub from '../event_hub';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- endpoint: {
- type: String,
- required: true,
+ export default {
+ directives: {
+ tooltip,
},
- title: {
- type: String,
- required: true,
+ components: {
+ loadingIcon,
+ icon,
},
- icon: {
- type: String,
- required: true,
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ cssClass: {
+ type: String,
+ required: true,
+ },
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
},
- cssClass: {
- type: String,
- required: true,
+ data() {
+ return {
+ isLoading: false,
+ };
},
- confirmActionMessage: {
- type: String,
- required: false,
+ computed: {
+ buttonClass() {
+ return `btn ${this.cssClass}`;
+ },
},
- },
- directives: {
- tooltip,
- },
- components: {
- loadingIcon,
- },
- data() {
- return {
- isLoading: false,
- };
- },
- computed: {
- iconClass() {
- return `fa fa-${this.icon}`;
+ created() {
+ // We're using eventHub to listen to the modal here instead of
+ // using props because it would would make the parent components
+ // much more complex to keep track of the loading state of each button
+ eventHub.$on('postAction', this.setLoading);
},
- buttonClass() {
- return `btn ${this.cssClass}`;
+ beforeDestroy() {
+ eventHub.$off('postAction', this.setLoading);
},
- },
- methods: {
- onClick() {
- if (this.confirmActionMessage && confirm(this.confirmActionMessage)) {
- this.makeRequest();
- } else if (!this.confirmActionMessage) {
- this.makeRequest();
- }
+ methods: {
+ onClick() {
+ eventHub.$emit('openConfirmationModal', {
+ pipelineId: this.pipelineId,
+ endpoint: this.endpoint,
+ type: this.type,
+ });
+ },
+ setLoading(endpoint) {
+ if (endpoint === this.endpoint) {
+ this.isLoading = true;
+ }
+ },
},
- makeRequest() {
- this.isLoading = true;
-
- eventHub.$emit('postAction', this.endpoint);
- },
- },
-};
+ };
</script>
<template>
@@ -75,10 +87,9 @@ export default {
data-container="body"
data-placement="top"
:disabled="isLoading">
- <i
- :class="iconClass"
- aria-hidden="true">
- </i>
+ <icon
+ :name="icon"
+ />
<loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/blank_state.vue
new file mode 100644
index 00000000000..8d3d6223d7b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/blank_state.vue
@@ -0,0 +1,32 @@
+<script>
+ export default {
+ name: 'PipelinesSvgState',
+ props: {
+ svgPath: {
+ type: String,
+ required: true,
+ },
+
+ message: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="row empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content">
+ <img :src="svgPath" />
+ </div>
+ </div>
+
+ <div class="col-xs-12 text-center">
+ <div class="text-content">
+ <h4>{{ message }}</h4>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue
index 3db64339a62..10ac8c08bed 100644
--- a/app/assets/javascripts/pipelines/components/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/empty_state.vue
@@ -1,33 +1,62 @@
<script>
-import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg';
-
-export default {
- props: {
- helpPagePath: {
- type: String,
- required: true,
+ export default {
+ name: 'PipelinesEmptyState',
+ props: {
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ canSetCi: {
+ type: Boolean,
+ required: true,
+ },
},
- },
- data: () => ({ pipelinesEmptyStateSVG }),
-};
+ };
</script>
-
<template>
<div class="row empty-state js-empty-state">
<div class="col-xs-12">
- <div class="svg-content" v-html="pipelinesEmptyStateSVG" />
+ <div class="svg-content svg-250">
+ <img :src="emptyStateSvgPath" />
+ </div>
</div>
- <div class="col-xs-12 text-center">
+ <div class="col-xs-12">
<div class="text-content">
- <h4>Build with confidence</h4>
- <p>
- Continous Integration can help catch bugs by running your tests automatically,
- while Continuous Deployment can help you deliver code to your product environment.
+
+ <template v-if="canSetCi">
+ <h4 class="text-center">
+ {{ s__('Pipelines|Build with confidence') }}
+ </h4>
+
+ <p>
+ {{ s__(`Pipelines|Continous Integration can help
+ catch bugs by running your tests automatically,
+ while Continuous Deployment can help you deliver
+ code to your product environment.`) }}
+ </p>
+
+ <div class="text-center">
+ <a
+ :href="helpPagePath"
+ class="btn btn-primary js-get-started-pipelines"
+ >
+ {{ s__('Pipelines|Get started with Pipelines') }}
+ </a>
+ </div>
+ </template>
+
+ <p
+ v-else
+ class="text-center"
+ >
+ {{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
</p>
- <a :href="helpPagePath" class="btn btn-info">
- Get started with Pipelines
- </a>
+
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/error_state.vue b/app/assets/javascripts/pipelines/components/error_state.vue
deleted file mode 100644
index 90cee68163e..00000000000
--- a/app/assets/javascripts/pipelines/components/error_state.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-<script>
-import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg';
-
-export default {
- data: () => ({ pipelinesErrorStateSVG }),
-};
-</script>
-
-<template>
- <div class="row empty-state js-pipelines-error-state">
- <div class="col-xs-12">
- <div class="svg-content" v-html="pipelinesErrorStateSVG" />
- </div>
-
- <div class="col-xs-12 text-center">
- <div class="text-content">
- <h4>The API failed to fetch the pipelines.</h4>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 54227425d2a..d7effb27bff 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,12 +1,20 @@
<script>
- import getActionIcon from '../../../vue_shared/ci_action_icons';
import tooltip from '../../../vue_shared/directives/tooltip';
-
+ import icon from '../../../vue_shared/components/icon.vue';
+ import { dasherize } from '../../../lib/utils/text_utility';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
+ components: {
+ icon,
+ },
+
+ directives: {
+ tooltip,
+ },
+
props: {
tooltipText: {
type: String,
@@ -29,17 +37,10 @@
},
},
- directives: {
- tooltip,
- },
-
computed: {
- actionIconSvg() {
- return getActionIcon(this.actionIcon);
- },
-
cssClass() {
- return `js-${gl.text.dasherize(this.actionIcon)}`;
+ const actionIconDash = dasherize(this.actionIcon);
+ return `${actionIconDash} js-icon-${actionIconDash}`;
},
},
};
@@ -50,14 +51,10 @@
:data-method="actionMethod"
:title="tooltipText"
:href="link"
- class="ci-action-icon-container"
- data-container="body">
-
- <i
- class="ci-action-icon-wrapper"
- :class="cssClass"
- v-html="actionIconSvg"
- aria-hidden="true"
- />
+ class="ci-action-icon-container ci-action-icon-wrapper"
+ :class="cssClass"
+ data-container="body"
+ >
+ <icon :name="actionIcon" />
</a>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
index 18fe1847eef..7c4fd65e36f 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
@@ -1,5 +1,5 @@
<script>
- import getActionIcon from '../../../vue_shared/ci_action_icons';
+ import icon from '../../../vue_shared/components/icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
/**
@@ -7,6 +7,13 @@
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
+ components: {
+ icon,
+ },
+
+ directives: {
+ tooltip,
+ },
props: {
tooltipText: {
type: String,
@@ -28,16 +35,6 @@
required: true,
},
},
-
- directives: {
- tooltip,
- },
-
- computed: {
- actionIconSvg() {
- return getActionIcon(this.actionIcon);
- },
- },
};
</script>
<template>
@@ -49,7 +46,8 @@
rel="nofollow"
class="ci-action-icon-wrapper js-ci-status-icon"
data-container="body"
- v-html="actionIconSvg"
- aria-label="Job's action">
+ aria-label="Job's action"
+ >
+ <icon :name="actionIcon" />
</a>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
index 2944689a5a7..be213c2ee78 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -1,4 +1,5 @@
<script>
+ import $ from 'jquery';
import jobNameComponent from './job_name_component.vue';
import jobComponent from './job_component.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
@@ -18,7 +19,7 @@
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
- * "icon": "icon_action_retry",
+ * "icon": "retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
@@ -27,13 +28,6 @@
* }
*/
export default {
- props: {
- job: {
- type: Object,
- required: true,
- },
- },
-
directives: {
tooltip,
},
@@ -43,15 +37,44 @@
jobNameComponent,
},
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+
computed: {
tooltipText() {
return `${this.job.name} - ${this.job.status.label}`;
},
},
+
+ mounted() {
+ this.stopDropdownClickPropagation();
+ },
+
+ methods: {
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(this.$el
+ .querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
+ .on('click', (e) => {
+ e.stopPropagation();
+ });
+ },
+ },
};
</script>
<template>
- <div>
+ <div class="ci-job-dropdown-container">
<button
v-tooltip
type="button"
@@ -62,22 +85,25 @@
<job-name-component
:name="job.name"
- :status="job.status" />
+ :status="job.status"
+ />
<span class="dropdown-counter-badge">
- {{job.size}}
+ {{ job.size }}
</span>
</button>
<ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
<li class="scrollable-menu">
<ul>
- <li v-for="item in job.jobs">
+ <li
+ v-for="(item, i) in job.jobs"
+ :key="i">
<job-component
:job="item"
:is-dropdown="true"
css-class-job-name="mini-pipeline-graph-dropdown-item"
- />
+ />
</li>
</ul>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 77cbaeb43ef..ab84711d4a2 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,9 +1,13 @@
<script>
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import stageColumnComponent from './stage_column_component.vue';
- import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
- import '../../../flash';
export default {
+ components: {
+ stageColumnComponent,
+ loadingIcon,
+ },
+
props: {
isLoading: {
type: Boolean,
@@ -15,11 +19,6 @@
},
},
- components: {
- stageColumnComponent,
- loadingIcon,
- },
-
computed: {
graph() {
return this.pipeline.details && this.pipeline.details.stages;
@@ -53,12 +52,12 @@
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
- <div class="pipeline-visualization pipeline-graph">
+ <div class="pipeline-visualization pipeline-graph pipeline-tab-content">
<div class="text-center">
<loading-icon
v-if="isLoading"
size="3"
- />
+ />
</div>
<ul
@@ -70,7 +69,8 @@
:jobs="stage.groups"
:key="stage.name"
:stage-connector-class="stageConnectorClass(index, stage)"
- :is-first-column="isFirstColumn(index)"/>
+ :is-first-column="isFirstColumn(index)"
+ />
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
index 1f5ed3f1074..9b136573135 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -19,7 +19,7 @@
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
- * "icon": "icon_action_retry",
+ * "icon": "retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
@@ -29,6 +29,15 @@
*/
export default {
+ components: {
+ actionComponent,
+ dropdownActionComponent,
+ jobNameComponent,
+ },
+
+ directives: {
+ tooltip,
+ },
props: {
job: {
type: Object,
@@ -48,19 +57,27 @@
},
},
- components: {
- actionComponent,
- dropdownActionComponent,
- jobNameComponent,
- },
-
- directives: {
- tooltip,
- },
-
computed: {
+ status() {
+ return this.job && this.job.status ? this.job.status : {};
+ },
+
tooltipText() {
- return `${this.job.name} - ${this.job.status.label}`;
+ const textBuilder = [];
+
+ if (this.job.name) {
+ textBuilder.push(this.job.name);
+ }
+
+ if (this.job.name && this.status.label) {
+ textBuilder.push('-');
+ }
+
+ if (this.status.label) {
+ textBuilder.push(`${this.job.status.label}`);
+ }
+
+ return textBuilder.join(' ');
},
/**
@@ -75,48 +92,52 @@
};
</script>
<template>
- <div>
+ <div class="ci-job-component">
<a
v-tooltip
- v-if="job.status.details_path"
- :href="job.status.details_path"
+ v-if="status.has_details"
+ :href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
- data-container="body">
+ data-container="body"
+ class="js-pipeline-graph-job-link"
+ >
<job-name-component
:name="job.name"
:status="job.status"
- />
+ />
</a>
<div
v-else
v-tooltip
+ class="js-job-component-tooltip"
:title="tooltipText"
:class="cssClassJobName"
- data-container="body">
+ data-container="body"
+ >
<job-name-component
:name="job.name"
:status="job.status"
- />
+ />
</div>
<action-component
v-if="hasAction && !isDropdown"
- :tooltip-text="job.status.action.title"
- :link="job.status.action.path"
- :action-icon="job.status.action.icon"
- :action-method="job.status.action.method"
- />
+ :tooltip-text="status.action.title"
+ :link="status.action.path"
+ :action-icon="status.action.icon"
+ :action-method="status.action.method"
+ />
<dropdown-action-component
v-if="hasAction && isDropdown"
- :tooltip-text="job.status.action.title"
- :link="job.status.action.path"
- :action-icon="job.status.action.icon"
- :action-method="job.status.action.method"
- />
+ :tooltip-text="status.action.title"
+ :link="status.action.path"
+ :action-icon="status.action.icon"
+ :action-method="status.action.method"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index d8856e10668..14f4964a406 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -8,6 +8,9 @@
* - Dropdown badge components
*/
export default {
+ components: {
+ ciIcon,
+ },
props: {
name: {
type: String,
@@ -19,19 +22,14 @@
required: true,
},
},
-
- components: {
- ciIcon,
- },
};
</script>
<template>
- <span>
- <ci-icon
- :status="status" />
+ <span class="ci-job-name-component">
+ <ci-icon :status="status" />
<span class="ci-status-text">
- {{name}}
+ {{ name }}
</span>
</span>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 9b1bbb0906f..7adcf4017b8 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,58 +1,57 @@
<script>
-import jobComponent from './job_component.vue';
-import dropdownJobComponent from './dropdown_job_component.vue';
+ import jobComponent from './job_component.vue';
+ import dropdownJobComponent from './dropdown_job_component.vue';
-export default {
- props: {
- title: {
- type: String,
- required: true,
+ export default {
+ components: {
+ jobComponent,
+ dropdownJobComponent,
},
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
- jobs: {
- type: Array,
- required: true,
- },
+ jobs: {
+ type: Array,
+ required: true,
+ },
- isFirstColumn: {
- type: Boolean,
- required: false,
- default: false,
- },
+ isFirstColumn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
- stageConnectorClass: {
- type: String,
- required: false,
- default: '',
+ stageConnectorClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
- },
- components: {
- jobComponent,
- dropdownJobComponent,
- },
+ methods: {
+ firstJob(list) {
+ return list[0];
+ },
- methods: {
- firstJob(list) {
- return list[0];
- },
-
- jobId(job) {
- return `ci-badge-${job.name}`;
- },
+ jobId(job) {
+ return `ci-badge-${job.name}`;
+ },
- buildConnnectorClass(index) {
- return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ buildConnnectorClass(index) {
+ return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ },
},
- },
-};
+ };
</script>
<template>
<li
class="stage-column"
:class="stageConnectorClass">
<div class="stage-name">
- {{title}}
+ {{ title }}
</div>
<div class="builds-container">
<ul>
@@ -61,7 +60,8 @@ export default {
:key="job.id"
class="build"
:class="buildConnnectorClass(index)"
- :id="jobId(job)">
+ :id="jobId(job)"
+ >
<div class="curve"></div>
@@ -69,12 +69,12 @@ export default {
v-if="job.size === 1"
:job="job"
css-class-job-name="build-content"
- />
+ />
<dropdown-job-component
v-if="job.size > 1"
:job="job"
- />
+ />
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 2a1ecac3707..e08c2092680 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,82 +1,81 @@
<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';
+ 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,
+ export default {
+ name: 'PipelineHeaderSection',
+ components: {
+ ciHeader,
+ loadingIcon,
},
- isLoading: {
- type: Boolean,
- required: true,
+ 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;
+ data() {
+ return {
+ actions: this.getActions(),
+ };
},
- shouldRenderContent() {
- return !this.isLoading && Object.keys(this.pipeline).length;
+
+ 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);
+ watch: {
+ pipeline() {
+ this.actions = this.getActions();
+ },
+ },
- this.$set(this.actions[index], 'isLoading', true);
+ methods: {
+ postAction(action) {
+ const index = this.actions.indexOf(action);
- eventHub.$emit('headerPostAction', action);
- },
+ this.$set(this.actions[index], 'isLoading', true);
- getActions() {
- const actions = [];
+ eventHub.$emit('headerPostAction', action);
+ },
- 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,
- });
- }
+ getActions() {
+ const actions = [];
- 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,
- });
- }
+ 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,
+ });
+ }
- return actions;
- },
- },
+ 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,
+ });
+ }
- watch: {
- pipeline() {
- this.actions = this.getActions();
+ return actions;
+ },
},
- },
-};
+ };
</script>
<template>
<div class="pipeline-header-container">
@@ -89,9 +88,11 @@ export default {
:user="pipeline.user"
:actions="actions"
@actionClicked="postAction"
- />
+ />
<loading-icon
v-if="isLoading"
- size="2"/>
+ size="2"
+ class="prepend-top-default append-bottom-default"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue
index 632fc167f2b..eba5678e3e5 100644
--- a/app/assets/javascripts/pipelines/components/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/nav_controls.vue
@@ -1,54 +1,67 @@
<script>
-export default {
- name: 'PipelineNavControls',
- props: {
- newPipelinePath: {
- type: String,
- required: true,
- },
+ import LoadingButton from '../../vue_shared/components/loading_button.vue';
- hasCiEnabled: {
- type: Boolean,
- required: true,
+ export default {
+ name: 'PipelineNavControls',
+ components: {
+ LoadingButton,
},
+ props: {
+ newPipelinePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- helpPagePath: {
- type: String,
- required: true,
- },
+ resetCachePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- ciLintPath: {
- type: String,
- required: true,
- },
+ ciLintPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- canCreatePipeline: {
- type: Boolean,
- required: true,
+ isResetCacheButtonLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- },
-};
+ methods: {
+ onClickResetCache() {
+ this.$emit('resetRunnersCache', this.resetCachePath);
+ },
+ },
+ };
</script>
<template>
<div class="nav-controls">
<a
- v-if="canCreatePipeline"
+ v-if="newPipelinePath"
:href="newPipelinePath"
- class="btn btn-create">
- Run Pipeline
+ class="btn btn-create js-run-pipeline"
+ >
+ {{ s__('Pipelines|Run Pipeline') }}
</a>
- <a
- v-if="!hasCiEnabled"
- :href="helpPagePath"
- class="btn btn-info">
- Get started with Pipelines
- </a>
+ <loading-button
+ v-if="resetCachePath"
+ @click="onClickResetCache"
+ :loading="isResetCacheButtonLoading"
+ class="btn btn-default js-clear-cache"
+ :label="s__('Pipelines|Clear Runner Caches')"
+ />
<a
+ v-if="ciLintPath"
:href="ciLintPath"
- class="btn btn-default">
- CI Lint
+ class="btn btn-default js-ci-lint"
+ >
+ {{ s__('Pipelines|CI Lint') }}
</a>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.vue b/app/assets/javascripts/pipelines/components/navigation_tabs.vue
deleted file mode 100644
index d2f6d47f043..00000000000
--- a/app/assets/javascripts/pipelines/components/navigation_tabs.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<script>
-export default {
- name: 'PipelineNavigationTabs',
- props: {
- scope: {
- type: String,
- required: true,
- },
- count: {
- type: Object,
- required: true,
- },
- paths: {
- type: Object,
- required: true,
- },
- },
- mounted() {
- $(document).trigger('init.scrolling-tabs');
- },
-};
-</script>
-<template>
- <ul class="nav-links scrolling-tabs">
- <li
- class="js-pipelines-tab-all"
- :class="{ active: scope === 'all'}">
- <a :href="paths.allPath">
- All
- <span class="badge js-totalbuilds-count">
- {{count.all}}
- </span>
- </a>
- </li>
- <li
- class="js-pipelines-tab-pending"
- :class="{ active: scope === 'pending'}">
- <a :href="paths.pendingPath">
- Pending
- <span class="badge">
- {{count.pending}}
- </span>
- </a>
- </li>
- <li
- class="js-pipelines-tab-running"
- :class="{ active: scope === 'running'}">
- <a :href="paths.runningPath">
- Running
- <span class="badge">
- {{count.running}}
- </span>
- </a>
- </li>
- <li
- class="js-pipelines-tab-finished"
- :class="{ active: scope === 'finished'}">
- <a :href="paths.finishedPath">
- Finished
- <span class="badge">
- {{count.finished}}
- </span>
- </a>
- </li>
- <li
- class="js-pipelines-tab-branches"
- :class="{ active: scope === 'branches'}">
- <a :href="paths.branchesPath">Branches</a>
- </li>
- <li
- class="js-pipelines-tab-tags"
- :class="{ active: scope === 'tags'}">
- <a :href="paths.tagsPath">Tags</a>
- </li>
- </ul>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 2ca5ac2912f..ceb4d9ca604 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -1,33 +1,56 @@
<script>
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import popover from '../../vue_shared/directives/popover';
-export default {
- props: {
- pipeline: {
- type: Object,
- required: true,
+ export default {
+ components: {
+ userAvatarLink,
},
- },
- components: {
- userAvatarLink,
- },
- directives: {
- tooltip,
- },
- computed: {
- user() {
- return this.pipeline.user;
+ directives: {
+ tooltip,
+ popover,
},
- },
-};
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ autoDevopsHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ popoverOptions() {
+ return {
+ html: true,
+ trigger: 'focus',
+ placement: 'top',
+ title: `<div class="autodevops-title">
+ This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>
+ </div>`,
+ content: `<a
+ class="autodevops-link"
+ href="${this.autoDevopsHelpPath}"
+ target="_blank"
+ rel="noopener noreferrer nofollow">
+ Learn more about Auto DevOps
+ </a>`,
+ };
+ },
+ },
+ };
</script>
<template>
- <div class="table-section section-15 hidden-xs hidden-sm">
+ <div class="table-section section-15 hidden-xs hidden-sm pipeline-tags">
<a
:href="pipeline.path"
class="js-pipeline-url-link">
- <span class="pipeline-id">#{{pipeline.id}}</span>
+ <span class="pipeline-id">#{{ pipeline.id }}</span>
</a>
<span>by</span>
<user-avatar-link
@@ -58,6 +81,21 @@ export default {
yaml invalid
</span>
<span
+ v-if="pipeline.flags.failure_reason"
+ v-tooltip
+ class="js-pipeline-url-failure label label-danger"
+ :title="pipeline.failure_reason">
+ error
+ </span>
+ <a
+ v-if="pipeline.flags.auto_devops"
+ tabindex="0"
+ class="js-pipeline-url-autodevops label label-info autodevops-badge"
+ v-popover="popoverOptions"
+ role="button">
+ Auto DevOps
+ </a>
+ <span
v-if="pipeline.flags.stuck"
class="js-pipeline-url-stuck label label-warning">
stuck
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index 01ae07aad65..e0a7284124d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -1,214 +1,377 @@
<script>
+ import _ from 'underscore';
+ import { __, sprintf, s__ } from '../../locale';
+ import createFlash from '../../flash';
import PipelinesService from '../services/pipelines_service';
import pipelinesMixin from '../mixins/pipelines';
- import tablePagination from '../../vue_shared/components/table_pagination.vue';
- import navigationTabs from './navigation_tabs.vue';
- import navigationControls from './nav_controls.vue';
+ import TablePagination from '../../vue_shared/components/table_pagination.vue';
+ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
+ import NavigationControls from './nav_controls.vue';
+ import {
+ getParameterByName,
+ parseQueryStringIntoObject,
+ } from '../../lib/utils/common_utils';
+ import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
export default {
+ components: {
+ TablePagination,
+ NavigationTabs,
+ NavigationControls,
+ },
+ mixins: [
+ pipelinesMixin,
+ CIPaginationMixin,
+ ],
props: {
store: {
type: Object,
required: true,
},
+ // Can be rendered in 3 different places, with some visual differences
+ // Accepts root | child
+ // `root` -> main view
+ // `child` -> rendered inside MR or Commit View
+ viewType: {
+ type: String,
+ required: false,
+ default: 'root',
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ errorStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noPipelinesSvgPath: {
+ type: String,
+ required: true,
+ },
+ autoDevopsPath: {
+ type: String,
+ required: true,
+ },
+ hasGitlabCi: {
+ type: Boolean,
+ required: true,
+ },
+ canCreatePipeline: {
+ type: Boolean,
+ required: true,
+ },
+ ciLintPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ resetCachePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ newPipelinePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
- components: {
- tablePagination,
- navigationTabs,
- navigationControls,
- },
- mixins: [
- pipelinesMixin,
- ],
data() {
- const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
-
return {
- endpoint: pipelinesData.endpoint,
- cssClass: pipelinesData.cssClass,
- helpPagePath: pipelinesData.helpPagePath,
- newPipelinePath: pipelinesData.newPipelinePath,
- canCreatePipeline: pipelinesData.canCreatePipeline,
- allPath: pipelinesData.allPath,
- pendingPath: pipelinesData.pendingPath,
- runningPath: pipelinesData.runningPath,
- finishedPath: pipelinesData.finishedPath,
- branchesPath: pipelinesData.branchesPath,
- tagsPath: pipelinesData.tagsPath,
- hasCi: pipelinesData.hasCi,
- ciLintPath: pipelinesData.ciLintPath,
+ // Start with loading state to avoid a glitch when the empty state will be rendered
+ isLoading: true,
state: this.store.state,
- apiScope: 'all',
- pagenum: 1,
+ scope: getParameterByName('scope') || 'all',
+ page: getParameterByName('page') || '1',
+ requestData: {},
+ isResetCacheButtonLoading: false,
};
},
- computed: {
- canCreatePipelineParsed() {
- return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
- },
- scope() {
- const scope = gl.utils.getParameterByName('scope');
- return scope === null ? 'all' : scope;
- },
+ stateMap: {
+ // with tabs
+ loading: 'loading',
+ tableList: 'tableList',
+ error: 'error',
+ emptyTab: 'emptyTab',
+ // without tabs
+ emptyState: 'emptyState',
+ },
+ scopes: {
+ all: 'all',
+ pending: 'pending',
+ running: 'running',
+ finished: 'finished',
+ branches: 'branches',
+ tags: 'tags',
+ },
+ computed: {
/**
- * The empty state should only be rendered when the request is made to fetch all pipelines
- * and none is returned.
- *
- * @return {Boolean}
- */
- shouldRenderEmptyState() {
- return !this.isLoading &&
- !this.hasError &&
- this.hasMadeRequest &&
- !this.state.pipelines.length &&
- (this.scope === 'all' || this.scope === null);
+ * `hasGitlabCi` handles both internal and external CI.
+ * The order on which the checks are made in this method is
+ * important to guarantee we handle all the corner cases.
+ */
+ stateToRender() {
+ const { stateMap } = this.$options;
+
+ if (this.isLoading) {
+ return stateMap.loading;
+ }
+
+ if (this.hasError) {
+ return stateMap.error;
+ }
+
+ if (this.state.pipelines.length) {
+ return stateMap.tableList;
+ }
+
+ if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) {
+ return stateMap.emptyTab;
+ }
+
+ return stateMap.emptyState;
},
/**
- * When a specific scope does not have pipelines we render a message.
- *
- * @return {Boolean}
+ * Tabs are rendered in all states except empty state.
+ * They are not rendered before the first request to avoid a flicker on first load.
*/
- shouldRenderNoPipelinesMessage() {
- return !this.isLoading &&
- !this.hasError &&
- !this.state.pipelines.length &&
- this.scope !== 'all' &&
- this.scope !== null;
+ shouldRenderTabs() {
+ const { stateMap } = this.$options;
+ return this.hasMadeRequest &&
+ [
+ stateMap.loading,
+ stateMap.tableList,
+ stateMap.error,
+ stateMap.emptyTab,
+ ].includes(this.stateToRender);
},
- shouldRenderTable() {
- return !this.hasError &&
- !this.isLoading && this.state.pipelines.length;
+ shouldRenderButtons() {
+ return (this.newPipelinePath ||
+ this.resetCachePath ||
+ this.ciLintPath) && this.shouldRenderTabs;
},
- /**
- * Pagination should only be rendered when there is more than one page.
- *
- * @return {Boolean}
- */
+
shouldRenderPagination() {
return !this.isLoading &&
this.state.pipelines.length &&
this.state.pageInfo.total > this.state.pageInfo.perPage;
},
- hasCiEnabled() {
- return this.hasCi !== undefined;
- },
- paths() {
- return {
- allPath: this.allPath,
- pendingPath: this.pendingPath,
- finishedPath: this.finishedPath,
- runningPath: this.runningPath,
- branchesPath: this.branchesPath,
- tagsPath: this.tagsPath,
- };
- },
- pageParameter() {
- return gl.utils.getParameterByName('page') || this.pagenum;
+
+ emptyTabMessage() {
+ const { scopes } = this.$options;
+ const possibleScopes = [scopes.pending, scopes.running, scopes.finished];
+
+ if (possibleScopes.includes(this.scope)) {
+ return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), {
+ scope: this.scope,
+ });
+ }
+
+ return s__('Pipelines|There are currently no pipelines.');
},
- scopeParameter() {
- return gl.utils.getParameterByName('scope') || this.apiScope;
+
+ tabs() {
+ const { count } = this.state;
+ const { scopes } = this.$options;
+
+ return [
+ {
+ name: __('All'),
+ scope: scopes.all,
+ count: count.all,
+ isActive: this.scope === 'all',
+ },
+ {
+ name: __('Pending'),
+ scope: scopes.pending,
+ count: count.pending,
+ isActive: this.scope === 'pending',
+ },
+ {
+ name: __('Running'),
+ scope: scopes.running,
+ count: count.running,
+ isActive: this.scope === 'running',
+ },
+ {
+ name: __('Finished'),
+ scope: scopes.finished,
+ count: count.finished,
+ isActive: this.scope === 'finished',
+ },
+ {
+ name: __('Branches'),
+ scope: scopes.branches,
+ isActive: this.scope === 'branches',
+ },
+ {
+ name: __('Tags'),
+ scope: scopes.tags,
+ isActive: this.scope === 'tags',
+ },
+ ];
},
},
created() {
this.service = new PipelinesService(this.endpoint);
- this.requestData = { page: this.pageParameter, scope: this.scopeParameter };
+ this.requestData = { page: this.page, scope: this.scope };
},
methods: {
+ successCallback(resp) {
+ return resp.json().then((response) => {
+ // Because we are polling & the user is interacting verify if the response received
+ // matches the last request made
+ if (_.isEqual(parseQueryStringIntoObject(resp.url.split('?')[1]), this.requestData)) {
+ this.store.storeCount(response.count);
+ this.store.storePagination(resp.headers);
+ this.setCommonData(response.pipelines);
+ }
+ });
+ },
/**
- * Will change the page number and update the URL.
- *
- * @param {Number} pageNumber desired page to go to.
+ * Handles URL and query parameter changes.
+ * When the user uses the pagination or the tabs,
+ * - update URL
+ * - Make API request to the server with new parameters
+ * - Update the polling function
+ * - Update the internal state
*/
- change(pageNumber) {
- const param = gl.utils.setParamInURL('page', pageNumber);
+ updateContent(parameters) {
+ this.updateInternalState(parameters);
+
+ // fetch new data
+ return this.service.getPipelines(this.requestData)
+ .then((response) => {
+ this.isLoading = false;
+ this.successCallback(response);
- gl.utils.visitUrl(param);
- return param;
+ // restart polling
+ this.poll.restart({ data: this.requestData });
+ })
+ .catch(() => {
+ this.isLoading = false;
+ this.errorCallback();
+
+ // restart polling
+ this.poll.restart({ data: this.requestData });
+ });
},
- successCallback(resp) {
- const response = {
- headers: resp.headers,
- body: resp.json(),
- };
+ handleResetRunnersCache(endpoint) {
+ this.isResetCacheButtonLoading = true;
- this.store.storeCount(response.body.count);
- this.store.storePagination(response.headers);
- this.setCommonData(response.body.pipelines);
+ this.service.postAction(endpoint)
+ .then(() => {
+ this.isResetCacheButtonLoading = false;
+ createFlash(
+ s__('Pipelines|Project cache successfully reset.'),
+ 'notice',
+ );
+ })
+ .catch(() => {
+ this.isResetCacheButtonLoading = false;
+ createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.'));
+ });
},
},
};
</script>
<template>
- <div :class="cssClass">
+ <div class="pipelines-container">
<div
class="top-area scrolling-tabs-container inner-page-scroll-tabs"
- v-if="!isLoading && !shouldRenderEmptyState">
+ v-if="shouldRenderTabs || shouldRenderButtons"
+ >
<div class="fade-left">
<i
class="fa fa-angle-left"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</div>
<div class="fade-right">
<i
class="fa fa-angle-right"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</div>
+
<navigation-tabs
- :scope="scope"
- :count="state.count"
- :paths="paths"
- />
+ v-if="shouldRenderTabs"
+ :tabs="tabs"
+ @onChangeTab="onChangeTab"
+ scope="pipelines"
+ />
<navigation-controls
+ v-if="shouldRenderButtons"
:new-pipeline-path="newPipelinePath"
- :has-ci-enabled="hasCiEnabled"
- :help-page-path="helpPagePath"
- :ciLintPath="ciLintPath"
- :can-create-pipeline="canCreatePipelineParsed "
- />
+ :reset-cache-path="resetCachePath"
+ :ci-lint-path="ciLintPath"
+ @resetRunnersCache="handleResetRunnersCache"
+ :is-reset-cache-button-loading="isResetCacheButtonLoading"
+ />
</div>
<div class="content-list pipelines">
<loading-icon
- label="Loading Pipelines"
+ v-if="stateToRender === $options.stateMap.loading"
+ :label="s__('Pipelines|Loading Pipelines')"
size="3"
- v-if="isLoading"
- />
+ class="prepend-top-20"
+ />
<empty-state
- v-if="shouldRenderEmptyState"
+ v-else-if="stateToRender === $options.stateMap.emptyState"
:help-page-path="helpPagePath"
- />
+ :empty-state-svg-path="emptyStateSvgPath"
+ :can-set-ci="canCreatePipeline"
+ />
- <error-state v-if="shouldRenderErrorState" />
+ <svg-blank-state
+ v-else-if="stateToRender === $options.stateMap.error"
+ :svg-path="errorStateSvgPath"
+ :message="s__(`Pipelines|There was an error fetching the pipelines.
+ Try again in a few moments or contact your support team.`)"
+ />
- <div
- class="blank-state blank-state-no-icon"
- v-if="shouldRenderNoPipelinesMessage">
- <h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2>
- </div>
+ <svg-blank-state
+ v-else-if="stateToRender === $options.stateMap.emptyTab"
+ :svg-path="noPipelinesSvgPath"
+ :message="emptyTabMessage"
+ />
<div
class="table-holder"
- v-if="shouldRenderTable">
+ v-else-if="stateToRender === $options.stateMap.tableList"
+ >
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
- />
+ :auto-devops-help-path="autoDevopsPath"
+ :view-type="viewType"
+ />
</div>
<table-pagination
v-if="shouldRenderPagination"
- :change="change"
- :pageInfo="state.pageInfo"
- />
+ :change="onChangePage"
+ :page-info="state.pageInfo"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
index 01dfe51cc17..3297af7bde4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
@@ -1,27 +1,25 @@
<script>
- /* global Flash */
- import '~/flash';
- import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
- props: {
- actions: {
- type: Array,
- required: true,
- },
- },
directives: {
tooltip,
},
components: {
loadingIcon,
+ icon,
+ },
+ props: {
+ actions: {
+ type: Array,
+ required: true,
+ },
},
data() {
return {
- playIconSvg,
isLoading: false,
};
},
@@ -52,8 +50,12 @@
data-toggle="dropdown"
data-placement="top"
aria-label="Manual job"
- :disabled="isLoading">
- <span v-html="playIconSvg"></span>
+ :disabled="isLoading"
+ >
+ <icon
+ name="play"
+ class="icon-play"
+ />
<i
class="fa fa-caret-down"
aria-hidden="true">
@@ -62,15 +64,18 @@
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="action in actions">
+ <li
+ v-for="(action, i) in actions"
+ :key="i"
+ >
<button
type="button"
class="js-pipeline-action-link no-btn btn"
@click="onClickAction(action.path)"
:class="{ disabled: isActionDisabled(action) }"
- :disabled="isActionDisabled(action)">
- <span v-html="playIconSvg"></span>
- <span>{{action.name}}</span>
+ :disabled="isActionDisabled(action)"
+ >
+ {{ action.name }}
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
index b19bd509a00..1b9e0f917a4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -1,49 +1,52 @@
<script>
import tooltip from '../../vue_shared/directives/tooltip';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ icon,
+ },
props: {
artifacts: {
type: Array,
required: true,
},
},
- directives: {
- tooltip,
- },
};
</script>
<template>
<div
class="btn-group"
- role="group">
+ role="group"
+ >
<button
v-tooltip
class="dropdown-toggle btn btn-default build-artifacts js-pipeline-dropdown-download"
title="Artifacts"
data-placement="top"
data-toggle="dropdown"
- aria-label="Artifacts">
- <i
- class="fa fa-download"
- aria-hidden="true">
- </i>
+ aria-label="Artifacts"
+ >
+ <icon name="download" />
<i
class="fa fa-caret-down"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="artifact in artifacts">
+ <li
+ v-for="(artifact, i) in artifacts"
+ :key="i">
<a
rel="nofollow"
download
- :href="artifact.path">
- <i
- class="fa fa-download"
- aria-hidden="true">
- </i>
- <span>Download {{artifact.name}} artifacts</span>
+ :href="artifact.path"
+ >
+ Download {{ artifact.name }} artifacts
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
index 5088d92209f..714aed1333e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -1,5 +1,8 @@
<script>
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { s__, sprintf } from '~/locale';
import pipelinesTableRowComponent from './pipelines_table_row.vue';
+ import eventHub from '../event_hub';
/**
* Pipelines Table Component.
@@ -7,6 +10,10 @@
* Given an array of objects, renders a table.
*/
export default {
+ components: {
+ pipelinesTableRowComponent,
+ DeprecatedModal,
+ },
props: {
pipelines: {
type: Array,
@@ -17,9 +24,60 @@
required: false,
default: false,
},
+ autoDevopsHelpPath: {
+ type: String,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: true,
+ },
},
- components: {
- pipelinesTableRowComponent,
+ data() {
+ return {
+ pipelineId: '',
+ endpoint: '',
+ type: '',
+ };
+ },
+ computed: {
+ modalTitle() {
+ return this.type === 'stop' ?
+ sprintf(s__('Pipeline|Stop pipeline #%{pipelineId}?'), {
+ pipelineId: `'${this.pipelineId}'`,
+ }, false) :
+ sprintf(s__('Pipeline|Retry pipeline #%{pipelineId}?'), {
+ pipelineId: `'${this.pipelineId}'`,
+ }, false);
+ },
+ modalText() {
+ return this.type === 'stop' ?
+ sprintf(s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), {
+ pipelineId: `<strong>#${this.pipelineId}</strong>`,
+ }, false) :
+ sprintf(s__('Pipeline|You’re about to retry pipeline %{pipelineId}.'), {
+ pipelineId: `<strong>#${this.pipelineId}</strong>`,
+ }, false);
+ },
+ primaryButtonLabel() {
+ return this.type === 'stop' ? s__('Pipeline|Stop pipeline') : s__('Pipeline|Retry pipeline');
+ },
+ },
+ created() {
+ eventHub.$on('openConfirmationModal', this.setModalData);
+ },
+ beforeDestroy() {
+ eventHub.$off('openConfirmationModal', this.setModalData);
+ },
+ methods: {
+ setModalData(data) {
+ this.pipelineId = data.pipelineId;
+ this.endpoint = data.endpoint;
+ this.type = data.type;
+ },
+ onSubmit() {
+ eventHub.$emit('postAction', this.endpoint);
+ },
},
};
</script>
@@ -27,25 +85,30 @@
<div class="ci-table">
<div
class="gl-responsive-table-row table-row-header"
- role="row">
+ role="row"
+ >
<div
class="table-section section-10 js-pipeline-status pipeline-status"
- role="rowheader">
+ role="rowheader"
+ >
Status
</div>
<div
class="table-section section-15 js-pipeline-info pipeline-info"
- role="rowheader">
+ role="rowheader"
+ >
Pipeline
</div>
<div
- class="table-section section-25 js-pipeline-commit pipeline-commit"
- role="rowheader">
+ class="table-section section-20 js-pipeline-commit pipeline-commit"
+ role="rowheader"
+ >
Commit
</div>
<div
- class="table-section section-15 js-pipeline-stages pipeline-stages"
- role="rowheader">
+ class="table-section section-20 js-pipeline-stages pipeline-stages"
+ role="rowheader"
+ >
Stages
</div>
</div>
@@ -54,6 +117,23 @@
:key="model.id"
:pipeline="model"
:update-graph-dropdown="updateGraphDropdown"
+ :auto-devops-help-path="autoDevopsHelpPath"
+ :view-type="viewType"
/>
+ <deprecated-modal
+ id="confirmation-modal"
+ :title="modalTitle"
+ :text="modalText"
+ kind="danger"
+ :primary-button-label="primaryButtonLabel"
+ @submit="onSubmit"
+ >
+ <template
+ slot="body"
+ slot-scope="props"
+ >
+ <p v-html="props.text"></p>
+ </template>
+ </deprecated-modal>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index c3f1c426d8a..4cbd67e0372 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -1,226 +1,246 @@
<script>
-/* eslint-disable no-param-reassign */
-import asyncButtonComponent from './async_button.vue';
-import pipelinesActionsComponent from './pipelines_actions.vue';
-import pipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import ciBadge from '../../vue_shared/components/ci_badge_link.vue';
-import pipelineStage from './stage.vue';
-import pipelineUrl from './pipeline_url.vue';
-import pipelinesTimeago from './time_ago.vue';
-import commitComponent from '../../vue_shared/components/commit.vue';
+ /* eslint-disable no-param-reassign */
+ import asyncButtonComponent from './async_button.vue';
+ import pipelinesActionsComponent from './pipelines_actions.vue';
+ import pipelinesArtifactsComponent from './pipelines_artifacts.vue';
+ import ciBadge from '../../vue_shared/components/ci_badge_link.vue';
+ import pipelineStage from './stage.vue';
+ import pipelineUrl from './pipeline_url.vue';
+ import pipelinesTimeago from './time_ago.vue';
+ import commitComponent from '../../vue_shared/components/commit.vue';
-/**
- * Pipeline table row.
- *
- * Given the received object renders a table row in the pipelines' table.
- */
-export default {
- props: {
- pipeline: {
- type: Object,
- required: true,
+ /**
+ * Pipeline table row.
+ *
+ * Given the received object renders a table row in the pipelines' table.
+ */
+ export default {
+ components: {
+ asyncButtonComponent,
+ pipelinesActionsComponent,
+ pipelinesArtifactsComponent,
+ commitComponent,
+ pipelineStage,
+ pipelineUrl,
+ ciBadge,
+ pipelinesTimeago,
},
- updateGraphDropdown: {
- type: Boolean,
- required: false,
- default: false,
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ updateGraphDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ autoDevopsHelpPath: {
+ type: String,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: true,
+ },
},
- },
- components: {
- asyncButtonComponent,
- pipelinesActionsComponent,
- pipelinesArtifactsComponent,
- commitComponent,
- pipelineStage,
- pipelineUrl,
- ciBadge,
- pipelinesTimeago,
- },
- computed: {
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * This field needs a lot of verification, because of different possible cases:
- *
- * 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
- * 3. If GitLab user does not have avatar he/she might have a Gravatar
- * 4. If committer is not a GitLab User he/she can have a Gravatar
- * 5. We do not have consistent API object in this case
- * 6. We should improve API and the code
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- let commitAuthorInformation;
+ computed: {
+ /**
+ * If provided, returns the commit tag.
+ * Needed to render the commit component column.
+ *
+ * This field needs a lot of verification, because of different possible cases:
+ *
+ * 1. person who is an author of a commit might be a GitLab user
+ * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
+ * 3. If GitLab user does not have avatar he/she might have a Gravatar
+ * 4. If committer is not a GitLab User he/she can have a Gravatar
+ * 5. We do not have consistent API object in this case
+ * 6. We should improve API and the code
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ let commitAuthorInformation;
- if (!this.pipeline || !this.pipeline.commit) {
- return null;
- }
+ 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.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) {
- commitAuthorInformation = this.pipeline.commit.author;
+ // 1. person who is an author of a commit might be a GitLab user
+ 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) {
+ commitAuthorInformation = this.pipeline.commit.author;
- // 3. If GitLab user does not have avatar he/she might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+ // 3. If GitLab user does not have avatar he/she might have a Gravatar
+ } else if (this.pipeline.commit.author_gravatar_url) {
+ commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ });
+ }
+ // 4. If committer is not a GitLab User he/she can have a Gravatar
+ } else {
+ commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
- });
+ path: `mailto:${this.pipeline.commit.author_email}`,
+ username: this.pipeline.commit.author_name,
+ };
}
- // 4. If committer is not a GitLab User he/she can have a Gravatar
- } else {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- path: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
- return commitAuthorInformation;
- },
+ return commitAuthorInformation;
+ },
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitTag() {
- if (this.pipeline.ref &&
- this.pipeline.ref.tag) {
- return this.pipeline.ref.tag;
- }
- return undefined;
- },
+ /**
+ * If provided, returns the commit tag.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.pipeline.ref &&
+ this.pipeline.ref.tag) {
+ return this.pipeline.ref.tag;
+ }
+ return undefined;
+ },
- /**
- * If provided, returns the commit ref.
- * Needed to render the commit component column.
- *
- * Matches `path` prop sent in the API to `ref_url` prop needed
- * in the commit component.
- *
- * @returns {Object|Undefined}
- */
- commitRef() {
- if (this.pipeline.ref) {
- return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
- if (prop === 'path') {
- accumulator.ref_url = this.pipeline.ref[prop];
- } else {
- accumulator[prop] = this.pipeline.ref[prop];
- }
- return accumulator;
- }, {});
- }
+ /**
+ * If provided, returns the commit ref.
+ * Needed to render the commit component column.
+ *
+ * Matches `path` prop sent in the API to `ref_url` prop needed
+ * in the commit component.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.pipeline.ref) {
+ return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
+ if (prop === 'path') {
+ accumulator.ref_url = this.pipeline.ref[prop];
+ } else {
+ accumulator[prop] = this.pipeline.ref[prop];
+ }
+ return accumulator;
+ }, {});
+ }
- return undefined;
- },
+ return undefined;
+ },
- /**
- * If provided, returns the commit url.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitUrl() {
- if (this.pipeline.commit &&
- this.pipeline.commit.commit_path) {
- return this.pipeline.commit.commit_path;
- }
- return undefined;
- },
+ /**
+ * If provided, returns the commit url.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.commit_path) {
+ return this.pipeline.commit.commit_path;
+ }
+ return undefined;
+ },
- /**
- * If provided, returns the commit short sha.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitShortSha() {
- if (this.pipeline.commit &&
- this.pipeline.commit.short_id) {
- return this.pipeline.commit.short_id;
- }
- return undefined;
- },
+ /**
+ * If provided, returns the commit short sha.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.short_id) {
+ return this.pipeline.commit.short_id;
+ }
+ return undefined;
+ },
- /**
- * If provided, returns the commit title.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitTitle() {
- if (this.pipeline.commit &&
- this.pipeline.commit.title) {
- return this.pipeline.commit.title;
- }
- return undefined;
- },
+ /**
+ * If provided, returns the commit title.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.title) {
+ return this.pipeline.commit.title;
+ }
+ return undefined;
+ },
- /**
- * Timeago components expects a number
- *
- * @return {type} description
- */
- pipelineDuration() {
- if (this.pipeline.details && this.pipeline.details.duration) {
- return this.pipeline.details.duration;
- }
+ /**
+ * Timeago components expects a number
+ *
+ * @return {type} description
+ */
+ pipelineDuration() {
+ if (this.pipeline.details && this.pipeline.details.duration) {
+ return this.pipeline.details.duration;
+ }
- return 0;
- },
+ return 0;
+ },
- /**
- * Timeago component expects a String.
- *
- * @return {String}
- */
- pipelineFinishedAt() {
- if (this.pipeline.details && this.pipeline.details.finished_at) {
- return this.pipeline.details.finished_at;
- }
+ /**
+ * Timeago component expects a String.
+ *
+ * @return {String}
+ */
+ pipelineFinishedAt() {
+ if (this.pipeline.details && this.pipeline.details.finished_at) {
+ return this.pipeline.details.finished_at;
+ }
- return '';
- },
+ return '';
+ },
- pipelineStatus() {
- if (this.pipeline.details && this.pipeline.details.status) {
- return this.pipeline.details.status;
- }
- return {};
- },
+ pipelineStatus() {
+ if (this.pipeline.details && this.pipeline.details.status) {
+ return this.pipeline.details.status;
+ }
+ return {};
+ },
+
+ displayPipelineActions() {
+ return this.pipeline.flags.retryable ||
+ this.pipeline.flags.cancelable ||
+ this.pipeline.details.manual_actions.length ||
+ this.pipeline.details.artifacts.length;
+ },
- displayPipelineActions() {
- return this.pipeline.flags.retryable ||
- this.pipeline.flags.cancelable ||
- this.pipeline.details.manual_actions.length ||
- this.pipeline.details.artifacts.length;
+ isChildView() {
+ return this.viewType === 'child';
+ },
},
- },
-};
+ };
</script>
<template>
<div class="commit gl-responsive-table-row">
<div class="table-section section-10 commit-link">
- <div class="table-mobile-header"
- role="rowheader">
+ <div
+ class="table-mobile-header"
+ role="rowheader"
+ >
Status
</div>
<div class="table-mobile-content">
- <ci-badge :status="pipelineStatus"/>
+ <ci-badge
+ :status="pipelineStatus"
+ :show-text="!isChildView"
+ />
</div>
</div>
- <pipeline-url :pipeline="pipeline" />
+ <pipeline-url
+ :pipeline="pipeline"
+ :auto-devops-help-path="autoDevopsHelpPath"
+ />
- <div class="table-section section-25">
+ <div class="table-section section-20">
<div
class="table-mobile-header"
role="rowheader">
@@ -233,32 +253,37 @@ export default {
:commit-url="commitUrl"
:short-sha="commitShortSha"
:title="commitTitle"
- :author="commitAuthor"/>
+ :author="commitAuthor"
+ :show-branch="!isChildView"
+ />
</div>
</div>
- <div class="table-section section-wrap section-15 stage-cell">
+ <div class="table-section section-wrap section-20 stage-cell">
<div
class="table-mobile-header"
role="rowheader">
Stages
</div>
<div class="table-mobile-content">
- <div class="stage-container dropdown js-mini-pipeline-graph"
- v-if="pipeline.details.stages.length > 0"
- v-for="stage in pipeline.details.stages">
- <pipeline-stage
- :stage="stage"
- :update-dropdown="updateGraphDropdown"
+ <template v-if="pipeline.details.stages.length > 0">
+ <div
+ class="stage-container dropdown js-mini-pipeline-graph"
+ v-for="(stage, index) in pipeline.details.stages"
+ :key="index">
+ <pipeline-stage
+ :stage="stage"
+ :update-dropdown="updateGraphDropdown"
/>
- </div>
+ </div>
+ </template>
</div>
</div>
<pipelines-timeago
:duration="pipelineDuration"
:finished-time="pipelineFinishedAt"
- />
+ />
<div
v-if="displayPipelineActions"
@@ -267,13 +292,13 @@ export default {
<pipelines-actions-component
v-if="pipeline.details.manual_actions.length"
:actions="pipeline.details.manual_actions"
- />
+ />
<pipelines-artifacts-component
v-if="pipeline.details.artifacts.length"
class="hidden-xs hidden-sm"
:artifacts="pipeline.details.artifacts"
- />
+ />
<async-button-component
v-if="pipeline.flags.retryable"
@@ -281,16 +306,23 @@ export default {
css-class="js-pipelines-retry-button btn-default btn-retry"
title="Retry"
icon="repeat"
- />
+ :pipeline-id="pipeline.id"
+ data-toggle="modal"
+ data-target="#confirmation-modal"
+ type="retry"
+ />
<async-button-component
v-if="pipeline.flags.cancelable"
:endpoint="pipeline.cancel_path"
css-class="js-pipelines-cancel-button btn-remove"
- title="Cancel"
- icon="remove"
- confirm-action-message="Are you sure you want to cancel this pipeline?"
- />
+ title="Stop"
+ icon="close"
+ :pipeline-id="pipeline.id"
+ data-toggle="modal"
+ data-target="#confirmation-modal"
+ type="stop"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 87b2725a045..8bc7a1f20b2 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -1,131 +1,134 @@
<script>
-
-/**
- * Renders each stage of the pipeline mini graph.
- *
- * Given the provided endpoint will make a request to
- * fetch the dropdown data when the stage is clicked.
- *
- * Request is made inside this component to make it reusable between:
- * 1. Pipelines main table
- * 2. Pipelines table in commit and Merge request views
- * 3. Merge request widget
- * 4. Commit widget
- */
-
-/* global Flash */
-import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
-
-export default {
- props: {
- stage: {
- type: Object,
- required: true,
+ import $ from 'jquery';
+
+ /**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+ import Flash from '../../flash';
+ import icon from '../../vue_shared/components/icon.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ components: {
+ loadingIcon,
+ icon,
},
- updateDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- directives: {
- tooltip,
- },
-
- data() {
- return {
- isLoading: false,
- dropdownContent: '',
- };
- },
-
- components: {
- loadingIcon,
- },
-
- updated() {
- if (this.dropdownContent.length > 0) {
- this.stopDropdownClickPropagation();
- }
- },
-
- watch: {
- updateDropdown() {
- if (this.updateDropdown &&
- this.isDropdownOpen() &&
- !this.isLoading) {
- this.fetchJobs();
- }
+ directives: {
+ tooltip,
},
- },
- methods: {
- onClickStage() {
- if (!this.isDropdownOpen()) {
- this.isLoading = true;
- this.fetchJobs();
- }
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- fetchJobs() {
- this.$http.get(this.stage.dropdown_path)
- .then((response) => {
- this.dropdownContent = response.json().html;
- this.isLoading = false;
- })
- .catch(() => {
- this.closeDropdown();
- this.isLoading = false;
-
- const flash = new Flash('Something went wrong on our end.');
- return flash;
- });
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: '',
+ };
},
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
- .on('click', (e) => {
- e.stopPropagation();
- });
- },
+ computed: {
+ dropdownClass() {
+ return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
+ },
- closeDropdown() {
- if (this.isDropdownOpen()) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
+ },
- isDropdownOpen() {
- return this.$el.classList.contains('open');
+ borderlessIcon() {
+ return `${this.stage.status.icon}_borderless`;
+ },
},
- },
- computed: {
- dropdownClass() {
- return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown &&
+ this.isDropdownOpen() &&
+ !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
},
- triggerButtonClass() {
- return `ci-status-icon-${this.stage.status.group}`;
+ updated() {
+ if (this.dropdownContent.length > 0) {
+ this.stopDropdownClickPropagation();
+ }
},
- svgIcon() {
- return borderlessStatusIconEntityMap[this.stage.status.icon];
+ methods: {
+ onClickStage() {
+ if (!this.isDropdownOpen()) {
+ this.isLoading = true;
+ this.fetchJobs();
+ }
+ },
+
+ fetchJobs() {
+ this.$http.get(this.stage.dropdown_path)
+ .then(response => response.json())
+ .then((data) => {
+ this.dropdownContent = data.html;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.closeDropdown();
+ this.isLoading = false;
+
+ const flash = new Flash('Something went wrong on our end.');
+ return flash;
+ });
+ },
+
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
+ .on('click', (e) => {
+ e.stopPropagation();
+ });
+ },
+
+ closeDropdown() {
+ if (this.isDropdownOpen()) {
+ $(this.$refs.dropdown).dropdown('toggle');
+ }
+ },
+
+ isDropdownOpen() {
+ return this.$el.classList.contains('open');
+ },
},
- },
-};
+ };
</script>
<template>
@@ -141,35 +144,41 @@ export default {
type="button"
id="stageDropdown"
aria-haspopup="true"
- aria-expanded="false">
+ aria-expanded="false"
+ >
<span
- v-html="svgIcon"
aria-hidden="true"
- :aria-label="stage.title">
+ :aria-label="stage.title"
+ >
+ <icon :name="borderlessIcon" />
</span>
<i
class="fa fa-caret-down"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
<ul
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
- aria-labelledby="stageDropdown">
+ aria-labelledby="stageDropdown"
+ >
<li
:class="dropdownClass"
- class="js-builds-dropdown-list scrollable-menu">
+ class="js-builds-dropdown-list scrollable-menu"
+ >
<loading-icon v-if="isLoading"/>
<ul
v-else
- v-html="dropdownContent">
+ v-html="dropdownContent"
+ >
</ul>
</li>
</ul>
</div>
-</script>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue
index 037684b4e72..cd54d26c9d3 100644
--- a/app/assets/javascripts/pipelines/components/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/time_ago.vue
@@ -5,6 +5,12 @@
import timeagoMixin from '../../vue_shared/mixins/timeago';
export default {
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
props: {
finishedTime: {
type: String,
@@ -15,12 +21,6 @@
required: true,
},
},
- mixins: [
- timeagoMixin,
- ],
- directives: {
- tooltip,
- },
data() {
return {
iconTimerSvg,
@@ -60,26 +60,29 @@
<div class="table-section section-15 pipelines-time-ago">
<div
class="table-mobile-header"
- role="rowheader">
+ role="rowheader"
+ >
Duration
</div>
<div class="table-mobile-content">
<p
class="duration"
- v-if="hasDuration">
- <span
- v-html="iconTimerSvg">
+ v-if="hasDuration"
+ >
+ <span v-html="iconTimerSvg">
</span>
- {{durationFormated}}
+ {{ durationFormated }}
</p>
<p
class="finished-at hidden-xs hidden-sm"
- v-if="hasFinishedTime">
+ v-if="hasFinishedTime"
+ >
<i
class="fa fa-calendar"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
<time
@@ -87,9 +90,9 @@
data-placement="top"
data-container="body"
:title="tooltipTitle(finishedTime)">
- {{timeFormated(finishedTime)}}
+ {{ timeFormated(finishedTime) }}
</time>
</p>
</div>
</div>
-</script>
+</template>
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 9adc15e6266..522a4277bd7 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -1,24 +1,19 @@
-/* global Flash */
-import '~/flash';
import Visibility from 'visibilityjs';
+import { __ } from '../../locale';
+import Flash from '../../flash';
import Poll from '../../lib/utils/poll';
-import emptyState from '../components/empty_state.vue';
-import errorState from '../components/error_state.vue';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import pipelinesTableComponent from '../components/pipelines_table.vue';
+import EmptyState from '../components/empty_state.vue';
+import SvgBlankState from '../components/blank_state.vue';
+import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
+import PipelinesTableComponent from '../components/pipelines_table.vue';
import eventHub from '../event_hub';
export default {
components: {
- pipelinesTableComponent,
- errorState,
- emptyState,
- loadingIcon,
- },
- computed: {
- shouldRenderErrorState() {
- return this.hasError && !this.isLoading;
- },
+ PipelinesTableComponent,
+ SvgBlankState,
+ EmptyState,
+ LoadingIcon,
},
data() {
return {
@@ -56,12 +51,10 @@ export default {
}
});
- eventHub.$on('refreshPipelines', this.fetchPipelines);
eventHub.$on('postAction', this.postAction);
},
beforeDestroy() {
- eventHub.$off('refreshPipelines');
- eventHub.$on('postAction', this.postAction);
+ eventHub.$off('postAction', this.postAction);
},
destroyed() {
this.poll.stop();
@@ -86,6 +79,7 @@ export default {
this.hasError = true;
this.isLoading = false;
this.updateGraphDropdown = false;
+ this.hasMadeRequest = true;
},
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
@@ -96,8 +90,8 @@ export default {
},
postAction(endpoint) {
this.service.postAction(endpoint)
- .then(() => eventHub.$emit('refreshPipelines'))
- .catch(() => new Flash('An error occured while making the request.'));
+ .then(() => this.fetchPipelines())
+ .catch(() => Flash(__('An error occurred while making the request.')));
},
},
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index bfc416da50b..6b26708148c 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,12 +1,15 @@
-/* global Flash */
-
import Vue from 'vue';
-import PipelinesMediator from './pipeline_details_mediatior';
+import Flash from '~/flash';
+import Translate from '~/vue_shared/translate';
+import { __ } from '~/locale';
+import PipelinesMediator from './pipeline_details_mediator';
import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
-document.addEventListener('DOMContentLoaded', () => {
+Vue.use(Translate);
+
+export default () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
@@ -16,14 +19,14 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line
new Vue({
el: '#js-pipeline-graph-vue',
+ components: {
+ pipelineGraph,
+ },
data() {
return {
mediator,
};
},
- components: {
- pipelineGraph,
- },
render(createElement) {
return createElement('pipeline-graph', {
props: {
@@ -37,14 +40,14 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line
new Vue({
el: '#js-pipeline-header-vue',
+ components: {
+ pipelineHeader,
+ },
data() {
return {
mediator,
};
},
- components: {
- pipelineHeader,
- },
created() {
eventHub.$on('headerPostAction', this.postAction);
},
@@ -55,7 +58,7 @@ document.addEventListener('DOMContentLoaded', () => {
postAction(action) {
this.mediator.service.postAction(action.path)
.then(() => this.mediator.refreshPipeline())
- .catch(() => new Flash('An error occurred while making the request.'));
+ .catch(() => Flash(__('An error occurred while making the request.')));
},
},
render(createElement) {
@@ -67,4 +70,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
deleted file mode 100644
index 82537ea06f5..00000000000
--- a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/* 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/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
new file mode 100644
index 00000000000..10f238fe73b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -0,0 +1,59 @@
+import Visibility from 'visibilityjs';
+import Flash from '../flash';
+import Poll from '../lib/utils/poll';
+import { __ } from '../locale';
+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) {
+ return response.json().then((data) => {
+ this.state.isLoading = false;
+ this.store.storePipeline(data);
+ });
+ }
+
+ errorCallback() {
+ this.state.isLoading = false;
+ 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_bundle.js b/app/assets/javascripts/pipelines/pipelines_bundle.js
deleted file mode 100644
index 923d9bfb248..00000000000
--- a/app/assets/javascripts/pipelines/pipelines_bundle.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import PipelinesStore from './stores/pipelines_store';
-import pipelinesComponent from './components/pipelines.vue';
-
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#pipelines-list-vue',
- data() {
- const store = new PipelinesStore();
-
- return {
- store,
- };
- },
- components: {
- pipelinesComponent,
- },
- render(createElement) {
- return createElement('pipelines-component', {
- props: {
- store: this.store,
- },
- });
- },
-}));
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index e2285494e62..47736fc5f42 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import Vue from 'vue';
import VueResource from 'vue-resource';
+import '../../vue_shared/vue_resource_interceptor';
Vue.use(VueResource);
diff --git a/app/assets/javascripts/pipelines/stores/pipelines_store.js b/app/assets/javascripts/pipelines/stores/pipelines_store.js
index ffefe0192f2..651251d2623 100644
--- a/app/assets/javascripts/pipelines/stores/pipelines_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipelines_store.js
@@ -1,3 +1,5 @@
+import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
+
export default class PipelinesStore {
constructor() {
this.state = {};
@@ -19,8 +21,8 @@ export default class PipelinesStore {
let paginationInfo;
if (Object.keys(pagination).length) {
- const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
- paginationInfo = gl.utils.parseIntPagination(normalizedHeaders);
+ const normalizedHeaders = normalizeHeaders(pagination);
+ paginationInfo = parseIntPagination(normalizedHeaders);
} else {
paginationInfo = pagination;
}
diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js
index 141333b2b4d..246a265ef2b 100644
--- a/app/assets/javascripts/preview_markdown.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -1,200 +1,199 @@
/* eslint-disable func-names, no-var, object-shorthand, comma-dangle, prefer-arrow-callback */
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
// MarkdownPreview
//
// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
// (including the explanation of quick actions), and showing a warning when
// more than `x` users are referenced.
//
-(function () {
- var lastTextareaPreviewed;
- var lastTextareaHeight = null;
- var markdownPreview;
- var previewButtonSelector;
- var writeButtonSelector;
-
- window.MarkdownPreview = (function () {
- function MarkdownPreview() {}
-
- // Minimum number of users referenced before triggering a warning
- MarkdownPreview.prototype.referenceThreshold = 10;
- MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
-
- MarkdownPreview.prototype.ajaxCache = {};
-
- MarkdownPreview.prototype.showPreview = function ($form) {
- var mdText;
- var preview = $form.find('.js-md-preview');
- var url = preview.data('url');
- if (preview.hasClass('md-preview-loading')) {
- return;
- }
- mdText = $form.find('textarea.markdown-area').val();
- if (mdText.trim().length === 0) {
- preview.text(this.emptyMessage);
- this.hideReferencedUsers($form);
+var lastTextareaPreviewed;
+var lastTextareaHeight = null;
+var markdownPreview;
+var previewButtonSelector;
+var writeButtonSelector;
+
+function MarkdownPreview() {}
+
+// Minimum number of users referenced before triggering a warning
+MarkdownPreview.prototype.referenceThreshold = 10;
+MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
+
+MarkdownPreview.prototype.ajaxCache = {};
+
+MarkdownPreview.prototype.showPreview = function ($form) {
+ var mdText;
+ var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
+ if (preview.hasClass('md-preview-loading')) {
+ return;
+ }
+ mdText = $form.find('textarea.markdown-area').val();
+
+ if (mdText.trim().length === 0) {
+ preview.text(this.emptyMessage);
+ this.hideReferencedUsers($form);
+ } else {
+ preview.addClass('md-preview-loading').text('Loading...');
+ this.fetchMarkdownPreview(mdText, url, (function (response) {
+ var body;
+ if (response.body.length > 0) {
+ body = response.body;
} else {
- preview.addClass('md-preview-loading').text('Loading...');
- this.fetchMarkdownPreview(mdText, url, (function (response) {
- var body;
- if (response.body.length > 0) {
- body = response.body;
- } else {
- body = this.emptyMessage;
- }
-
- preview.removeClass('md-preview-loading').html(body);
- preview.renderGFM();
- this.renderReferencedUsers(response.references.users, $form);
-
- if (response.references.commands) {
- this.renderReferencedCommands(response.references.commands, $form);
- }
- }).bind(this));
- }
- };
-
- MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
- if (!url) {
- return;
- }
- if (text === this.ajaxCache.text) {
- success(this.ajaxCache.response);
- return;
- }
- $.ajax({
- type: 'POST',
- url: url,
- data: {
- text: text
- },
- dataType: 'json',
- success: (function (response) {
- this.ajaxCache = {
- text: text,
- response: response
- };
- success(response);
- }).bind(this)
- });
- };
-
- MarkdownPreview.prototype.hideReferencedUsers = function ($form) {
- $form.find('.referenced-users').hide();
- };
-
- MarkdownPreview.prototype.renderReferencedUsers = function (users, $form) {
- var referencedUsers;
- referencedUsers = $form.find('.referenced-users');
- if (referencedUsers.length) {
- if (users.length >= this.referenceThreshold) {
- referencedUsers.show();
- referencedUsers.find('.js-referenced-users-count').text(users.length);
- } else {
- referencedUsers.hide();
- }
+ body = this.emptyMessage;
}
- };
- MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
- $form.find('.referenced-commands').hide();
- };
+ preview.removeClass('md-preview-loading').html(body);
+ preview.renderGFM();
+ this.renderReferencedUsers(response.references.users, $form);
- MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
- var referencedCommands;
- referencedCommands = $form.find('.referenced-commands');
- if (commands.length > 0) {
- referencedCommands.html(commands);
- referencedCommands.show();
- } else {
- referencedCommands.html('');
- referencedCommands.hide();
+ if (response.references.commands) {
+ this.renderReferencedCommands(response.references.commands, $form);
}
+ }).bind(this));
+ }
+};
+
+MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
+ if (!url) {
+ return;
+ }
+ if (text === this.ajaxCache.text) {
+ success(this.ajaxCache.response);
+ return;
+ }
+ axios.post(url, {
+ text,
+ })
+ .then(({ data }) => {
+ this.ajaxCache = {
+ text: text,
+ response: data,
};
-
- return MarkdownPreview;
- }());
-
- markdownPreview = new window.MarkdownPreview();
-
- previewButtonSelector = '.js-md-preview-button';
-
- writeButtonSelector = '.js-md-write-button';
-
- lastTextareaPreviewed = null;
-
- $.fn.setupMarkdownPreview = function () {
- var $form = $(this);
- $form.find('textarea.markdown-area').on('input', function () {
- markdownPreview.hideReferencedUsers($form);
- });
- };
-
- $(document).on('markdown-preview:show', function (e, $form) {
- if (!$form) {
- return;
- }
-
- lastTextareaPreviewed = $form.find('textarea.markdown-area');
- lastTextareaHeight = lastTextareaPreviewed.height();
-
- // toggle tabs
- $form.find(writeButtonSelector).parent().removeClass('active');
- $form.find(previewButtonSelector).parent().addClass('active');
-
- // toggle content
- $form.find('.md-write-holder').hide();
- $form.find('.md-preview-holder').show();
- markdownPreview.showPreview($form);
- });
-
- $(document).on('markdown-preview:hide', function (e, $form) {
- if (!$form) {
- return;
- }
- lastTextareaPreviewed = null;
-
- if (lastTextareaHeight) {
- $form.find('textarea.markdown-area').height(lastTextareaHeight);
+ success(data);
+ })
+ .catch(() => flash(__('An error occurred while fetching markdown preview')));
+};
+
+MarkdownPreview.prototype.hideReferencedUsers = function ($form) {
+ $form.find('.referenced-users').hide();
+};
+
+MarkdownPreview.prototype.renderReferencedUsers = function (users, $form) {
+ var referencedUsers;
+ referencedUsers = $form.find('.referenced-users');
+ if (referencedUsers.length) {
+ if (users.length >= this.referenceThreshold) {
+ referencedUsers.show();
+ referencedUsers.find('.js-referenced-users-count').text(users.length);
+ } else {
+ referencedUsers.hide();
}
-
- // toggle tabs
- $form.find(writeButtonSelector).parent().addClass('active');
- $form.find(previewButtonSelector).parent().removeClass('active');
-
- // toggle content
- $form.find('.md-write-holder').show();
- $form.find('textarea.markdown-area').focus();
- $form.find('.md-preview-holder').hide();
-
- markdownPreview.hideReferencedCommands($form);
- });
-
- $(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
- var $target;
- $target = $(keyboardEvent.target);
- if ($target.is('textarea.markdown-area')) {
- $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]);
- keyboardEvent.preventDefault();
- } else if (lastTextareaPreviewed) {
- $target = lastTextareaPreviewed;
- $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]);
- keyboardEvent.preventDefault();
- }
- });
-
- $(document).on('click', previewButtonSelector, function (e) {
- var $form;
- e.preventDefault();
- $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:show', [$form]);
+ }
+};
+
+MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
+ $form.find('.referenced-commands').hide();
+};
+
+MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
+ var referencedCommands;
+ referencedCommands = $form.find('.referenced-commands');
+ if (commands.length > 0) {
+ referencedCommands.html(commands);
+ referencedCommands.show();
+ } else {
+ referencedCommands.html('');
+ referencedCommands.hide();
+ }
+};
+
+markdownPreview = new MarkdownPreview();
+
+previewButtonSelector = '.js-md-preview-button';
+writeButtonSelector = '.js-md-write-button';
+lastTextareaPreviewed = null;
+const markdownToolbar = $('.md-header-toolbar');
+
+$.fn.setupMarkdownPreview = function () {
+ var $form = $(this);
+ $form.find('textarea.markdown-area').on('input', function () {
+ markdownPreview.hideReferencedUsers($form);
});
+};
+
+$(document).on('markdown-preview:show', function (e, $form) {
+ if (!$form) {
+ return;
+ }
+
+ lastTextareaPreviewed = $form.find('textarea.markdown-area');
+ lastTextareaHeight = lastTextareaPreviewed.height();
+
+ // toggle tabs
+ $form.find(writeButtonSelector).parent().removeClass('active');
+ $form.find(previewButtonSelector).parent().addClass('active');
+
+ // toggle content
+ $form.find('.md-write-holder').hide();
+ $form.find('.md-preview-holder').show();
+ markdownToolbar.removeClass('active');
+ markdownPreview.showPreview($form);
+});
+
+$(document).on('markdown-preview:hide', function (e, $form) {
+ if (!$form) {
+ return;
+ }
+ lastTextareaPreviewed = null;
- $(document).on('click', writeButtonSelector, function (e) {
- var $form;
- e.preventDefault();
- $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:hide', [$form]);
- });
-}());
+ if (lastTextareaHeight) {
+ $form.find('textarea.markdown-area').height(lastTextareaHeight);
+ }
+
+ // toggle tabs
+ $form.find(writeButtonSelector).parent().addClass('active');
+ $form.find(previewButtonSelector).parent().removeClass('active');
+
+ // toggle content
+ $form.find('.md-write-holder').show();
+ $form.find('textarea.markdown-area').focus();
+ $form.find('.md-preview-holder').hide();
+ markdownToolbar.addClass('active');
+
+ markdownPreview.hideReferencedCommands($form);
+});
+
+$(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
+ var $target;
+ $target = $(keyboardEvent.target);
+ if ($target.is('textarea.markdown-area')) {
+ $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]);
+ keyboardEvent.preventDefault();
+ } else if (lastTextareaPreviewed) {
+ $target = lastTextareaPreviewed;
+ $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]);
+ keyboardEvent.preventDefault();
+ }
+});
+
+$(document).on('click', previewButtonSelector, function (e) {
+ var $form;
+ e.preventDefault();
+ $form = $(this).closest('form');
+ $(document).triggerHandler('markdown-preview:show', [$form]);
+});
+
+$(document).on('click', writeButtonSelector, function (e) {
+ var $form;
+ e.preventDefault();
+ $form = $(this).closest('form');
+ $(document).triggerHandler('markdown-preview:hide', [$form]);
+});
+
+export default MarkdownPreview;
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
new file mode 100644
index 00000000000..f50002afbf2
--- /dev/null
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -0,0 +1,135 @@
+<script>
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { __, s__, sprintf } from '~/locale';
+ import csrf from '~/lib/utils/csrf';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ actionUrl: {
+ type: String,
+ required: true,
+ },
+ confirmWithPassword: {
+ type: Boolean,
+ required: true,
+ },
+ username: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ enteredPassword: '',
+ enteredUsername: '',
+ };
+ },
+ computed: {
+ csrfToken() {
+ return csrf.token;
+ },
+ inputLabel() {
+ let confirmationValue;
+ if (this.confirmWithPassword) {
+ confirmationValue = __('password');
+ } else {
+ confirmationValue = __('username');
+ }
+
+ confirmationValue = `<code>${confirmationValue}</code>`;
+
+ return sprintf(
+ s__('Profiles|Type your %{confirmationValue} to confirm:'),
+ { confirmationValue },
+ false,
+ );
+ },
+ text() {
+ return sprintf(
+ s__(`Profiles|
+You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account.
+Once you confirm %{deleteAccount}, it cannot be undone or recovered.`),
+ {
+ yourAccount: `<strong>${s__('Profiles|your account')}</strong>`,
+ deleteAccount: `<strong>${s__('Profiles|Delete Account')}</strong>`,
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ canSubmit() {
+ if (this.confirmWithPassword) {
+ return this.enteredPassword !== '';
+ }
+
+ return this.enteredUsername === this.username;
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-account-modal"
+ :title="s__('Profiles|Delete your account?')"
+ :text="text"
+ kind="danger"
+ :primary-button-label="s__('Profiles|Delete account')"
+ @submit="onSubmit"
+ :submit-disabled="!canSubmit()">
+
+ <template
+ slot="body"
+ slot-scope="props">
+ <p v-html="props.text"></p>
+
+ <form
+ ref="form"
+ :action="actionUrl"
+ method="post">
+
+ <input
+ type="hidden"
+ name="_method"
+ value="delete"
+ />
+ <input
+ type="hidden"
+ name="authenticity_token"
+ :value="csrfToken"
+ />
+
+ <p
+ id="input-label"
+ v-html="inputLabel"
+ >
+ </p>
+
+ <input
+ v-if="confirmWithPassword"
+ name="password"
+ class="form-control"
+ type="password"
+ v-model="enteredPassword"
+ aria-labelledby="input-label"
+ />
+ <input
+ v-else
+ name="username"
+ class="form-control"
+ type="text"
+ v-model="enteredUsername"
+ aria-labelledby="input-label"
+ />
+ </form>
+ </template>
+
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
new file mode 100644
index 00000000000..84049a1f0b7
--- /dev/null
+++ b/app/assets/javascripts/profile/account/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import deleteAccountModal from './components/delete_account_modal.vue';
+
+export default () => {
+ Vue.use(Translate);
+
+ const deleteAccountButton = document.getElementById('delete-account-button');
+ const deleteAccountModalEl = document.getElementById('delete-account-modal');
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: deleteAccountModalEl,
+ components: {
+ deleteAccountModal,
+ },
+ mounted() {
+ deleteAccountButton.classList.remove('disabled');
+ },
+ render(createElement) {
+ return createElement('delete-account-modal', {
+ props: {
+ actionUrl: deleteAccountModalEl.dataset.actionUrl,
+ confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword,
+ username: deleteAccountModalEl.dataset.username,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index cf1566eeb87..8f93156cdd1 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -1,6 +1,8 @@
/* eslint-disable no-useless-escape, max-len, quotes, no-var, no-underscore-dangle, func-names, space-before-function-paren, no-unused-vars, no-return-assign, object-shorthand, one-var, one-var-declaration-per-line, comma-dangle, consistent-return, class-methods-use-this, new-parens */
-import 'vendor/cropper';
+import $ from 'jquery';
+import 'cropper';
+import _ from 'underscore';
((global) => {
// Matches everything but the file name
@@ -72,7 +74,8 @@ import 'vendor/cropper';
aspectRatio: 1,
modal: true,
scalable: false,
- rotatable: false,
+ rotatable: true,
+ checkOrientation: true,
zoomable: true,
dragMode: 'move',
guides: false,
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 4ccea0624ee..0af34657d72 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,100 +1,84 @@
/* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */
-/* global Flash */
-((global) => {
- class Profile {
- constructor({ form } = {}) {
- this.onSubmitForm = this.onSubmitForm.bind(this);
- this.form = form || $('.edit-user');
- this.bindEvents();
- this.initAvatarGlCrop();
- }
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import flash from '../flash';
- initAvatarGlCrop() {
- const cropOpts = {
- filename: '.js-avatar-filename',
- previewImage: '.avatar-image .avatar',
- modalCrop: '.modal-profile-crop',
- pickImageEl: '.js-choose-user-avatar-button',
- uploadImageBtn: '.js-upload-user-avatar',
- modalCropImg: '.modal-profile-crop-image'
- };
- this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
- }
+export default class Profile {
+ constructor({ form } = {}) {
+ this.onSubmitForm = this.onSubmitForm.bind(this);
+ this.form = form || $('.edit-user');
+ this.setRepoRadio();
+ this.bindEvents();
+ this.initAvatarGlCrop();
+ }
- bindEvents() {
- $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
- $('#user_notification_email').on('change', this.submitForm);
- $('#user_notified_of_own_activity').on('change', this.submitForm);
- $('.update-username').on('ajax:before', this.beforeUpdateUsername);
- $('.update-username').on('ajax:complete', this.afterUpdateUsername);
- $('.update-notifications').on('ajax:success', this.onUpdateNotifs);
- this.form.on('submit', this.onSubmitForm);
- }
+ initAvatarGlCrop() {
+ const cropOpts = {
+ filename: '.js-avatar-filename',
+ previewImage: '.avatar-image .avatar',
+ modalCrop: '.modal-profile-crop',
+ pickImageEl: '.js-choose-user-avatar-button',
+ uploadImageBtn: '.js-upload-user-avatar',
+ modalCropImg: '.modal-profile-crop-image',
+ };
+ this.avatarGlCrop = $('.js-user-avatar-input')
+ .glCrop(cropOpts)
+ .data('glcrop');
+ }
- submitForm() {
- return $(this).parents('form').submit();
- }
+ bindEvents() {
+ $('.js-preferences-form').on(
+ 'change.preference',
+ 'input[type=radio]',
+ this.submitForm,
+ );
+ $('#user_notification_email').on('change', this.submitForm);
+ $('#user_notified_of_own_activity').on('change', this.submitForm);
+ this.form.on('submit', this.onSubmitForm);
+ }
- onSubmitForm(e) {
- e.preventDefault();
- return this.saveForm();
- }
+ submitForm() {
+ return $(this)
+ .parents('form')
+ .submit();
+ }
- beforeUpdateUsername() {
- $('.loading-username', this).removeClass('hidden');
- }
+ onSubmitForm(e) {
+ e.preventDefault();
+ return this.saveForm();
+ }
- afterUpdateUsername() {
- $('.loading-username', this).addClass('hidden');
- $('button[type=submit]', this).enable();
- }
+ saveForm() {
+ const self = this;
+ const formData = new FormData(this.form[0]);
+ const avatarBlob = this.avatarGlCrop.getBlob();
- onUpdateNotifs(e, data) {
- return data.saved ?
- new Flash("Notification settings saved", "notice") :
- new Flash("Failed to save new settings", "alert");
+ if (avatarBlob != null) {
+ formData.append('user[avatar]', avatarBlob, 'avatar.png');
}
- saveForm() {
- const self = this;
- const formData = new FormData(this.form[0]);
- const avatarBlob = this.avatarGlCrop.getBlob();
-
- if (avatarBlob != null) {
- formData.append('user[avatar]', avatarBlob, 'avatar.png');
- }
-
- return $.ajax({
- url: this.form.attr('action'),
- type: this.form.attr('method'),
- data: formData,
- dataType: "json",
- processData: false,
- contentType: false,
- success: response => new Flash(response.message, 'notice'),
- error: jqXHR => new Flash(jqXHR.responseJSON.message, 'alert'),
- complete: () => {
- window.scrollTo(0, 0);
- // Enable submit button after requests ends
- return self.form.find(':input[disabled]').enable();
- }
- });
- }
+ axios({
+ method: this.form.attr('method'),
+ url: this.form.attr('action'),
+ data: formData,
+ })
+ .then(({ data }) => flash(data.message, 'notice'))
+ .then(() => {
+ window.scrollTo(0, 0);
+ // Enable submit button after requests ends
+ self.form.find(':input[disabled]').enable();
+ })
+ .catch(error => flash(error.message));
}
- $(function() {
- $(document).on('input.ssh_key', '#key_key', function() {
- const $title = $('#key_title');
- const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
-
- // Extract the SSH Key title from its comment
- if (comment && comment.length > 1) {
- return $title.val(comment[1]).change();
- }
- });
- if (global.utils.getPagePath() === 'profiles') {
- return new Profile();
+ setRepoRadio() {
+ const multiEditRadios = $('input[name="user[multi_file]"]');
+ if (this.newRepoActivated || this.newRepoActivated === 'true') {
+ multiEditRadios.filter('[value=on]').prop('checked', true);
+ } else {
+ multiEditRadios.filter('[value=off]').prop('checked', true);
}
- });
-})(window.gl || (window.gl = {}));
+ }
+}
diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js
deleted file mode 100644
index ff35a9bcb83..00000000000
--- a/app/assets/javascripts/profile/profile_bundle.js
+++ /dev/null
@@ -1,2 +0,0 @@
-import './gl_crop';
-import './profile';
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
deleted file mode 100644
index 738e710deb9..00000000000
--- a/app/assets/javascripts/project.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, comma-dangle, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */
-/* global ProjectSelect */
-
-import Cookies from 'js-cookie';
-
-(function() {
- this.Project = (function() {
- function Project() {
- $('ul.clone-options-dropdown a').click(function() {
- var url;
- if ($(this).hasClass('active')) {
- return;
- }
- $('.active').not($(this)).removeClass('active');
- $(this).toggleClass('active');
- url = $("#project_clone").val();
- $('#project_clone').val(url);
- return $('.clone').text(url);
- // Git protocol switcher
- // Remove the active class for all buttons (ssh, http, kerberos if shown)
- // Add the active class for the clicked button
- // Update the input field
- // Update the command line instructions
- });
- // Ref switcher
- this.initRefSwitcher();
- $('.project-refs-select').on('change', function() {
- return $(this).parents('form').submit();
- });
- $('.hide-no-ssh-message').on('click', function(e) {
- Cookies.set('hide_no_ssh_message', 'false');
- $(this).parents('.no-ssh-key-message').remove();
- return e.preventDefault();
- });
- $('.hide-no-password-message').on('click', function(e) {
- Cookies.set('hide_no_password_message', 'false');
- $(this).parents('.no-password-message').remove();
- return e.preventDefault();
- });
- this.projectSelectDropdown();
- }
-
- Project.prototype.projectSelectDropdown = function() {
- new ProjectSelect();
- $('.project-item-select').on('click', (function(_this) {
- return function(e) {
- return _this.changeProject($(e.currentTarget).val());
- };
- })(this));
- return $('.js-projects-dropdown-toggle').on('click', function(e) {
- e.preventDefault();
- return $('.js-projects-dropdown').select2('open');
- });
- };
-
- Project.prototype.changeProject = function(url) {
- return window.location = url;
- };
-
- Project.prototype.initRefSwitcher = function() {
- var refListItem = document.createElement('li');
- var refLink = document.createElement('a');
-
- refLink.href = '#';
-
- return $('.js-project-refs-dropdown').each(function() {
- var $dropdown, selected;
- $dropdown = $(this);
- selected = $dropdown.data('selected');
- return $dropdown.glDropdown({
- data: function(term, callback) {
- return $.ajax({
- url: $dropdown.data('refs-url'),
- data: {
- ref: $dropdown.data('ref'),
- search: term
- },
- dataType: "json"
- }).done(function(refs) {
- return callback(refs);
- });
- },
- selectable: true,
- filterable: true,
- filterRemote: true,
- filterByText: true,
- fieldName: $dropdown.data('field-name'),
- renderRow: function(ref) {
- var li = refListItem.cloneNode(false);
-
- if (ref.header != null) {
- li.className = 'dropdown-header';
- li.textContent = ref.header;
- } else {
- var link = refLink.cloneNode(false);
-
- if (ref === selected) {
- link.className = 'is-active';
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
-
- li.appendChild(link);
- }
-
- return li;
- },
- id: function(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel: function(obj, $el) {
- return $el.text().trim();
- },
- clicked: function(options) {
- const { e } = options;
- e.preventDefault();
- if ($('input[name="ref"]').length) {
- var $form = $dropdown.closest('form');
- var action = $form.attr('action');
- var divider = action.indexOf('?') === -1 ? '?' : '&';
- gl.utils.visitUrl(action + '' + divider + '' + $form.serialize());
- }
- }
- });
- });
- };
-
- return Project;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/project_avatar.js b/app/assets/javascripts/project_avatar.js
deleted file mode 100644
index aabdfbf65e2..00000000000
--- a/app/assets/javascripts/project_avatar.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, no-useless-escape, max-len */
-(function() {
- this.ProjectAvatar = (function() {
- function ProjectAvatar() {
- $('.js-choose-project-avatar-button').bind('click', function() {
- var form;
- form = $(this).closest('form');
- return form.find('.js-project-avatar-input').click();
- });
- $('.js-project-avatar-input').bind('change', function() {
- var filename, form;
- form = $(this).closest('form');
- filename = $(this).val().replace(/^.*[\\\/]/, '');
- return form.find('.js-avatar-filename').text(filename);
- });
- }
-
- return ProjectAvatar;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/project_edit.js b/app/assets/javascripts/project_edit.js
index d7d284b6c86..47bf2226781 100644
--- a/app/assets/javascripts/project_edit.js
+++ b/app/assets/javascripts/project_edit.js
@@ -1,6 +1,8 @@
+import $ from 'jquery';
+
export default function setupProjectEdit() {
const $transferForm = $('.js-project-transfer-form');
- const $selectNamespace = $transferForm.find('.select2');
+ const $selectNamespace = $transferForm.find('select.select2');
$selectNamespace.on('change', () => {
$transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 11f9754780d..4c4acd487f8 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -1,169 +1,163 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, consistent-return, one-var, one-var-declaration-per-line, no-cond-assign, max-len, object-shorthand, no-param-reassign, comma-dangle, prefer-template, no-unused-vars, no-return-assign */
-/* global fuzzaldrinPlus */
-(function() {
- this.ProjectFindFile = (function() {
- var highlighter;
+import $ from 'jquery';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
- function ProjectFindFile(element1, options) {
- this.element = element1;
- this.options = options;
- 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
- this.initEvent();
- // focus text input box
- this.inputElement.focus();
- // load file list
- this.load(this.options.url);
+// highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> )
+const highlighter = function(element, text, matches) {
+ var highlightText, j, lastIndex, len, matchIndex, matchedChars, unmatched;
+ lastIndex = 0;
+ highlightText = "";
+ matchedChars = [];
+ for (j = 0, len = matches.length; j < len; j += 1) {
+ matchIndex = matches[j];
+ unmatched = text.substring(lastIndex, matchIndex);
+ if (unmatched) {
+ if (matchedChars.length) {
+ element.append(matchedChars.join("").bold());
+ }
+ matchedChars = [];
+ element.append(document.createTextNode(unmatched));
}
+ matchedChars.push(text[matchIndex]);
+ lastIndex = matchIndex + 1;
+ }
+ if (matchedChars.length) {
+ element.append(matchedChars.join("").bold());
+ }
+ return element.append(document.createTextNode(text.substring(lastIndex)));
+};
- ProjectFindFile.prototype.initEvent = function() {
- this.inputElement.off("keyup");
- this.inputElement.on("keyup", (function(_this) {
- return function(event) {
- var oldValue, ref, target, value;
- target = $(event.target);
- value = target.val();
- oldValue = (ref = target.data("oldValue")) != null ? ref : "";
- if (value !== oldValue) {
- target.data("oldValue", value);
- _this.findFile();
- return _this.element.find("tr.tree-item").eq(0).addClass("selected").focus();
- }
- };
- })(this));
- };
+export default class ProjectFindFile {
+ constructor(element1, options) {
+ this.element = element1;
+ this.options = options;
+ 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
+ this.initEvent();
+ // focus text input box
+ this.inputElement.focus();
+ // load file list
+ this.load(this.options.url);
+ }
- ProjectFindFile.prototype.findFile = function() {
- var result, searchText;
- searchText = this.inputElement.val();
- result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths;
- return this.renderList(result, searchText);
- // find file
- };
+ initEvent() {
+ this.inputElement.off("keyup");
+ this.inputElement.on("keyup", (function(_this) {
+ return function(event) {
+ var oldValue, ref, target, value;
+ target = $(event.target);
+ value = target.val();
+ oldValue = (ref = target.data("oldValue")) != null ? ref : "";
+ if (value !== oldValue) {
+ target.data("oldValue", value);
+ _this.findFile();
+ return _this.element.find("tr.tree-item").eq(0).addClass("selected").focus();
+ }
+ };
+ })(this));
+ }
- // files pathes load
- ProjectFindFile.prototype.load = function(url) {
- return $.ajax({
- url: url,
- method: "get",
- dataType: "json",
- success: (function(_this) {
- return function(data) {
- _this.element.find(".loading").hide();
- _this.filePaths = data;
- _this.findFile();
- return _this.element.find(".files-slider tr.tree-item").eq(0).addClass("selected").focus();
- };
- })(this)
- });
- };
+ findFile() {
+ var result, searchText;
+ searchText = this.inputElement.val();
+ result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths;
+ return this.renderList(result, searchText);
+ // find file
+ }
- // render result
- ProjectFindFile.prototype.renderList = function(filePaths, searchText) {
- var blobItemUrl, filePath, html, i, j, len, matches, results;
- this.element.find(".tree-table > tbody").empty();
- results = [];
- for (i = j = 0, len = filePaths.length; j < len; i = (j += 1)) {
- filePath = filePaths[i];
- if (i === 20) {
- break;
- }
- if (searchText) {
- matches = fuzzaldrinPlus.match(filePath, searchText);
- }
- blobItemUrl = this.options.blobUrlTemplate + "/" + filePath;
- html = this.makeHtml(filePath, matches, blobItemUrl);
- results.push(this.element.find(".tree-table > tbody").append(html));
- }
- return results;
- };
+ // files pathes load
+ load(url) {
+ axios.get(url)
+ .then(({ data }) => {
+ this.element.find('.loading').hide();
+ this.filePaths = data;
+ this.findFile();
+ this.element.find('.files-slider tr.tree-item').eq(0).addClass('selected').focus();
+ })
+ .catch(() => flash(__('An error occurred while loading filenames')));
+ }
- // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> )
- highlighter = function(element, text, matches) {
- var highlightText, j, lastIndex, len, matchIndex, matchedChars, unmatched;
- lastIndex = 0;
- highlightText = "";
- matchedChars = [];
- for (j = 0, len = matches.length; j < len; j += 1) {
- matchIndex = matches[j];
- unmatched = text.substring(lastIndex, matchIndex);
- if (unmatched) {
- if (matchedChars.length) {
- element.append(matchedChars.join("").bold());
- }
- matchedChars = [];
- element.append(document.createTextNode(unmatched));
- }
- matchedChars.push(text[matchIndex]);
- lastIndex = matchIndex + 1;
+ // render result
+ renderList(filePaths, searchText) {
+ var blobItemUrl, filePath, html, i, j, len, matches, results;
+ this.element.find(".tree-table > tbody").empty();
+ results = [];
+ for (i = j = 0, len = filePaths.length; j < len; i = (j += 1)) {
+ filePath = filePaths[i];
+ if (i === 20) {
+ break;
}
- if (matchedChars.length) {
- element.append(matchedChars.join("").bold());
+ if (searchText) {
+ matches = fuzzaldrinPlus.match(filePath, searchText);
}
- return element.append(document.createTextNode(text.substring(lastIndex)));
- };
+ blobItemUrl = this.options.blobUrlTemplate + "/" + filePath;
+ html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl);
+ results.push(this.element.find(".tree-table > tbody").append(html));
+ }
+ return results;
+ }
- // make tbody row html
- ProjectFindFile.prototype.makeHtml = function(filePath, matches, blobItemUrl) {
- var $tr;
- $tr = $("<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>");
- if (matches) {
- $tr.find("a").replaceWith(highlighter($tr.find("a"), filePath, matches).attr("href", blobItemUrl));
- } else {
- $tr.find("a").attr("href", blobItemUrl);
- $tr.find(".str-truncated").text(filePath);
- }
- return $tr;
- };
+ // make tbody row html
+ static makeHtml(filePath, matches, blobItemUrl) {
+ var $tr;
+ $tr = $("<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>");
+ if (matches) {
+ $tr.find("a").replaceWith(highlighter($tr.find("a"), filePath, matches).attr("href", blobItemUrl));
+ } else {
+ $tr.find("a").attr("href", blobItemUrl);
+ $tr.find(".str-truncated").text(filePath);
+ }
+ return $tr;
+ }
- ProjectFindFile.prototype.selectRow = function(type) {
- var next, rows, selectedRow;
- rows = this.element.find(".files-slider tr.tree-item");
- selectedRow = this.element.find(".files-slider tr.tree-item.selected");
- if (rows && rows.length > 0) {
- if (selectedRow && selectedRow.length > 0) {
- if (type === "UP") {
- next = selectedRow.prev();
- } else if (type === "DOWN") {
- next = selectedRow.next();
- }
- if (next.length > 0) {
- selectedRow.removeClass("selected");
- selectedRow = next;
- }
- } else {
- selectedRow = rows.eq(0);
+ selectRow(type) {
+ var next, rows, selectedRow;
+ rows = this.element.find(".files-slider tr.tree-item");
+ selectedRow = this.element.find(".files-slider tr.tree-item.selected");
+ if (rows && rows.length > 0) {
+ if (selectedRow && selectedRow.length > 0) {
+ if (type === "UP") {
+ next = selectedRow.prev();
+ } else if (type === "DOWN") {
+ next = selectedRow.next();
+ }
+ if (next.length > 0) {
+ selectedRow.removeClass("selected");
+ selectedRow = next;
}
- return selectedRow.addClass("selected").focus();
+ } else {
+ selectedRow = rows.eq(0);
}
- };
+ return selectedRow.addClass("selected").focus();
+ }
+ }
- ProjectFindFile.prototype.selectRowUp = function() {
- return this.selectRow("UP");
- };
+ selectRowUp() {
+ return this.selectRow("UP");
+ }
- ProjectFindFile.prototype.selectRowDown = function() {
- return this.selectRow("DOWN");
- };
+ selectRowDown() {
+ return this.selectRow("DOWN");
+ }
- ProjectFindFile.prototype.goToTree = function() {
- return location.href = this.options.treeUrl;
- };
+ goToTree() {
+ return location.href = this.options.treeUrl;
+ }
- ProjectFindFile.prototype.goToBlob = function() {
- var $link = this.element.find(".tree-item.selected .tree-item-file-name a");
+ goToBlob() {
+ var $link = this.element.find(".tree-item.selected .tree-item-file-name a");
- if ($link.length) {
- $link.get(0).click();
- }
- };
-
- return ProjectFindFile;
- })();
-}).call(window);
+ if ($link.length) {
+ $link.get(0).click();
+ }
+ }
+}
diff --git a/app/assets/javascripts/project_fork.js b/app/assets/javascripts/project_fork.js
index 47197db39d3..6fedd94a6a9 100644
--- a/app/assets/javascripts/project_fork.js
+++ b/app/assets/javascripts/project_fork.js
@@ -1,13 +1,9 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, max-len */
-(function() {
- this.ProjectFork = (function() {
- function ProjectFork() {
- $('.fork-thumbnail a').on('click', function() {
- $('.fork-namespaces').hide();
- return $('.save-project-loader').show();
- });
- }
+import $ from 'jquery';
- return ProjectFork;
- })();
-}).call(window);
+export default () => {
+ $('.js-fork-thumbnail').on('click', function forkThumbnailClicked() {
+ if ($(this).hasClass('disabled')) return false;
+
+ return $('.js-fork-content').toggle();
+ });
+};
diff --git a/app/assets/javascripts/project_import.js b/app/assets/javascripts/project_import.js
index 08334bf1ec5..d2d26d6f67e 100644
--- a/app/assets/javascripts/project_import.js
+++ b/app/assets/javascripts/project_import.js
@@ -1,13 +1,8 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, max-len */
+import { visitUrl } from './lib/utils/url_utility';
-(function() {
- this.ProjectImport = (function() {
- function ProjectImport() {
- setTimeout(function() {
- return gl.utils.visitUrl(location.href);
- }, 5000);
- }
+export default function projectImport() {
+ setTimeout(() => {
+ visitUrl(location.href);
+ }, 5000);
+}
- return ProjectImport;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js
index 0a811627600..f31beb4dc78 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -1,55 +1,52 @@
-/* eslint-disable wrap-iife, func-names, space-before-function-paren, object-shorthand, comma-dangle, one-var, one-var-declaration-per-line, no-restricted-syntax, max-len, no-param-reassign */
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
-(function(global) {
- class ProjectLabelSubscription {
- constructor(container) {
- this.$container = $(container);
- this.$buttons = this.$container.find('.js-subscribe-button');
+export default class ProjectLabelSubscription {
+ constructor(container) {
+ this.$container = $(container);
+ this.$buttons = this.$container.find('.js-subscribe-button');
- this.$buttons.on('click', this.toggleSubscription.bind(this));
- }
+ this.$buttons.on('click', this.toggleSubscription.bind(this));
+ }
- toggleSubscription(event) {
- event.preventDefault();
+ toggleSubscription(event) {
+ event.preventDefault();
- const $btn = $(event.currentTarget);
- const $span = $btn.find('span');
- const url = $btn.attr('data-url');
- const oldStatus = $btn.attr('data-status');
+ const $btn = $(event.currentTarget);
+ const $span = $btn.find('span');
+ const url = $btn.attr('data-url');
+ const oldStatus = $btn.attr('data-status');
- $btn.addClass('disabled');
- $span.toggleClass('hidden');
+ $btn.addClass('disabled');
+ $span.toggleClass('hidden');
- $.ajax({
- type: 'POST',
- url: url
- }).done(() => {
- let newStatus, newAction;
+ axios.post(url).then(() => {
+ let newStatus;
+ let newAction;
- if (oldStatus === 'unsubscribed') {
- [newStatus, newAction] = ['subscribed', 'Unsubscribe'];
- } else {
- [newStatus, newAction] = ['unsubscribed', 'Subscribe'];
- }
+ if (oldStatus === 'unsubscribed') {
+ [newStatus, newAction] = ['subscribed', 'Unsubscribe'];
+ } else {
+ [newStatus, newAction] = ['unsubscribed', 'Subscribe'];
+ }
- $span.toggleClass('hidden');
- $btn.removeClass('disabled');
+ $span.toggleClass('hidden');
+ $btn.removeClass('disabled');
- this.$buttons.attr('data-status', newStatus);
- this.$buttons.find('> span').text(newAction);
+ this.$buttons.attr('data-status', newStatus);
+ this.$buttons.find('> span').text(newAction);
- this.$buttons.map((button) => {
- const $button = $(button);
+ this.$buttons.map((button) => {
+ const $button = $(button);
- if ($button.attr('data-original-title')) {
- $button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle');
- }
+ if ($button.attr('data-original-title')) {
+ $button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle');
+ }
- return button;
- });
+ return button;
});
- }
+ }).catch(() => flash(__('There was an error subscribing to this label.')));
}
-
- global.ProjectLabelSubscription = ProjectLabelSubscription;
-})(window.gl || (window.gl = {}));
+}
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
deleted file mode 100644
index c0f757269cb..00000000000
--- a/app/assets/javascripts/project_new.js
+++ /dev/null
@@ -1,157 +0,0 @@
-/* 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 highlightChanges($elm) {
- $elm.addClass('highlight-changes');
- setTimeout(() => $elm.removeClass('highlight-changes'), 10);
-}
-
-(function() {
- this.ProjectNew = (function() {
- function ProjectNew() {
- 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() {
- $('.project-edit-container').hide();
- return $('.save-project-loader').show();
- };
- })(this));
-
- this.initVisibilitySelect();
-
- this.toggleSettings();
- this.toggleSettingsOnclick();
- this.toggleRepoVisibility();
- }
-
- ProjectNew.prototype.initVisibilitySelect = function() {
- const visibilityContainer = document.querySelector('.js-visibility-select');
- 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() {
- var self = this;
-
- this.$selects.each(function () {
- var $select = $(this);
- var className = $select.data('field')
- .replace(/_/g, '-')
- .replace('access-level', 'feature');
- self._showOrHide($select, '.' + className);
- });
- };
-
- ProjectNew.prototype.toggleSettingsOnclick = function() {
- this.$selects.on('change', this.toggleSettings);
- };
-
- ProjectNew.prototype._showOrHide = function(checkElement, container) {
- var $container = $(container);
-
- if ($(checkElement).val() !== '0') {
- return $container.show();
- } else {
- return $container.hide();
- }
- };
-
- 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()
- .hide();
-
- $repoAccessLevel.off('change')
- .on('change', function () {
- var selectedVal = parseInt($repoAccessLevel.val(), 10);
-
- this.$repoSelects.each(function () {
- var $this = $(this);
- var repoSelectVal = parseInt($this.val(), 10);
-
- $this.find('option').enable();
-
- if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
- $this.val(selectedVal).trigger('change');
- highlightChanges($this);
- }
-
- $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));
- };
-
- return ProjectNew;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 9896b88d487..cb2e6855d1d 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,111 +1,76 @@
/* 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 */
+
+import $ from 'jquery';
import Api from './api';
+import ProjectSelectComboButton from './project_select_combo_button';
-(function() {
- this.ProjectSelect = (function() {
- function ProjectSelect() {
- $('.js-projects-dropdown-toggle').each(function(i, dropdown) {
- var $dropdown;
- $dropdown = $(dropdown);
- return $dropdown.glDropdown({
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name_with_namespace']
- },
- data: function(term, callback) {
- var finalCallback, projectsCallback;
- var orderBy = $dropdown.data('order-by');
- finalCallback = function(projects) {
- return callback(projects);
- };
- if (this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
- var data;
- data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (this.groupId) {
- return Api.groupProjects(this.groupId, term, projectsCallback);
- } else {
- return Api.projects(term, { order_by: orderBy }, projectsCallback);
- }
- },
- url: function(project) {
- return project.web_url;
- },
- text: function(project) {
- return project.name_with_namespace;
- }
- });
- });
- $('.ajax-project-select').each(function(i, select) {
- var placeholder;
- 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');
+export default function projectSelect() {
+ $('.ajax-project-select').each(function(i, select) {
+ var placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ this.groupId = $(select).data('groupId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+
+ placeholder = "Search for project";
+ if (this.includeGroups) {
+ placeholder += " or group";
+ }
- placeholder = "Search for project";
- if (this.includeGroups) {
- placeholder += " or group";
- }
- return $(select).select2({
- placeholder: placeholder,
- minimumInputLength: 0,
- query: (function(_this) {
- return function(query) {
- var finalCallback, projectsCallback;
- finalCallback = function(projects) {
+ $(select).select2({
+ placeholder: placeholder,
+ minimumInputLength: 0,
+ query: (function (_this) {
+ return function (query) {
+ var finalCallback, projectsCallback;
+ finalCallback = function (projects) {
+ var data;
+ data = {
+ results: projects
+ };
+ return query.callback(data);
+ };
+ if (_this.includeGroups) {
+ projectsCallback = function (projects) {
+ var groupsCallback;
+ groupsCallback = function (groups) {
var data;
- data = {
- results: projects
- };
- return query.callback(data);
+ data = groups.concat(projects);
+ return finalCallback(data);
};
- if (_this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
- var data;
- data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (_this.groupId) {
- return Api.groupProjects(_this.groupId, query.term, projectsCallback);
- } else {
- return Api.projects(query.term, {
- order_by: _this.orderBy,
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled
- }, projectsCallback);
- }
+ return Api.groups(query.term, {}, groupsCallback);
};
- })(this),
- id: function(project) {
- return project.web_url;
- },
- text: function(project) {
- return project.name_with_namespace || project.name;
- },
- dropdownCssClass: "ajax-project-dropdown"
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (_this.groupId) {
+ return Api.groupProjects(_this.groupId, query.term, projectsCallback);
+ } else {
+ return Api.projects(query.term, {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ membership: !_this.allProjects,
+ }, projectsCallback);
+ }
+ };
+ })(this),
+ id: function(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
});
- });
- }
-
- return ProjectSelect;
- })();
-}).call(window);
+ },
+ text: function (project) {
+ return project.name_with_namespace || project.name;
+ },
+ dropdownCssClass: "ajax-project-dropdown"
+ });
+ if (simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
+}
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
new file mode 100644
index 00000000000..9b404896e86
--- /dev/null
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -0,0 +1,99 @@
+import $ from 'jquery';
+import AccessorUtilities from './lib/utils/accessor';
+
+export default class ProjectSelectComboButton {
+ constructor(select) {
+ this.projectSelectInput = $(select);
+ this.newItemBtn = $('.new-project-item-link');
+ this.resourceType = this.newItemBtn.data('type');
+ this.resourceLabel = this.newItemBtn.data('label');
+ this.formattedText = this.deriveTextVariants();
+ this.groupId = this.projectSelectInput.data('groupId');
+ this.bindEvents();
+ this.initLocalStorage();
+ }
+
+ bindEvents() {
+ this.projectSelectInput.siblings('.new-project-item-select-button')
+ .on('click', e => this.openDropdown(e));
+
+ this.newItemBtn.on('click', (e) => {
+ if (!this.getProjectFromLocalStorage()) {
+ e.preventDefault();
+ this.openDropdown(e);
+ }
+ });
+
+ this.projectSelectInput.on('change', () => this.selectProject());
+ }
+
+ initLocalStorage() {
+ const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (localStorageIsSafe) {
+ this.localStorageKey = ['group', this.groupId, this.formattedText.localStorageItemType, 'recent-project'].join('-');
+ this.setBtnTextFromLocalStorage();
+ }
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ openDropdown(event) {
+ $(event.currentTarget).siblings('.project-item-select').select2('open');
+ }
+
+ selectProject() {
+ const selectedProjectData = JSON.parse(this.projectSelectInput.val());
+ const projectUrl = `${selectedProjectData.url}/${this.projectSelectInput.data('relativePath')}`;
+ const projectName = selectedProjectData.name;
+
+ const projectMeta = {
+ url: projectUrl,
+ name: projectName,
+ };
+
+ this.setNewItemBtnAttributes(projectMeta);
+ this.setProjectInLocalStorage(projectMeta);
+ }
+
+ setBtnTextFromLocalStorage() {
+ const cachedProjectData = this.getProjectFromLocalStorage();
+
+ this.setNewItemBtnAttributes(cachedProjectData);
+ }
+
+ setNewItemBtnAttributes(project) {
+ if (project) {
+ this.newItemBtn.attr('href', project.url);
+ this.newItemBtn.text(`${this.formattedText.defaultTextPrefix} in ${project.name}`);
+ } else {
+ this.newItemBtn.text(`Select project to create ${this.formattedText.presetTextSuffix}`);
+ }
+ }
+
+ getProjectFromLocalStorage() {
+ const projectString = localStorage.getItem(this.localStorageKey);
+
+ return JSON.parse(projectString);
+ }
+
+ setProjectInLocalStorage(projectMeta) {
+ const projectString = JSON.stringify(projectMeta);
+
+ localStorage.setItem(this.localStorageKey, projectString);
+ }
+
+ deriveTextVariants() {
+ const defaultTextPrefix = this.resourceLabel;
+
+ // the trailing slice call depluralizes each of these strings (e.g. new-issues -> new-issue)
+ const localStorageItemType = `new-${this.resourceType.split('_').join('-').slice(0, -1)}`;
+ const presetTextSuffix = this.resourceType.split('_').join(' ').slice(0, -1);
+
+ return {
+ localStorageItemType, // new-issue / new-merge-request
+ defaultTextPrefix, // New issue / New merge request
+ presetTextSuffix, // issue / merge request
+ };
+ }
+}
+
diff --git a/app/assets/javascripts/project_show.js b/app/assets/javascripts/project_show.js
deleted file mode 100644
index 3a51c1f26ac..00000000000
--- a/app/assets/javascripts/project_show.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife */
-
-(function() {
- this.ProjectShow = (function() {
- function ProjectShow() {}
-
- return ProjectShow;
- })();
-}).call(window);
-
-// I kept class for future
diff --git a/app/assets/javascripts/project_variables.js b/app/assets/javascripts/project_variables.js
deleted file mode 100644
index 4ee2e49306d..00000000000
--- a/app/assets/javascripts/project_variables.js
+++ /dev/null
@@ -1,43 +0,0 @@
-(() => {
- const HIDDEN_VALUE_TEXT = '******';
-
- class ProjectVariables {
- constructor() {
- this.$revealBtn = $('.js-btn-toggle-reveal-values');
- this.$revealBtn.on('click', this.toggleRevealState.bind(this));
- }
-
- toggleRevealState(e) {
- e.preventDefault();
-
- const oldStatus = this.$revealBtn.attr('data-status');
- let newStatus = 'hidden';
- let newAction = 'Reveal Values';
-
- if (oldStatus === 'hidden') {
- newStatus = 'revealed';
- newAction = 'Hide Values';
- }
-
- this.$revealBtn.attr('data-status', newStatus);
-
- const $variables = $('.variable-value');
-
- $variables.each((_, variable) => {
- const $variable = $(variable);
- let newText = HIDDEN_VALUE_TEXT;
-
- if (newStatus === 'revealed') {
- newText = $variable.attr('data-value');
- }
-
- $variable.text(newText);
- });
-
- this.$revealBtn.text(newAction);
- }
- }
-
- window.gl = window.gl || {};
- window.gl.ProjectVariables = ProjectVariables;
-})();
diff --git a/app/assets/javascripts/project_visibility.js b/app/assets/javascripts/project_visibility.js
new file mode 100644
index 00000000000..7c95c71e239
--- /dev/null
+++ b/app/assets/javascripts/project_visibility.js
@@ -0,0 +1,43 @@
+import $ from 'jquery';
+
+function setVisibilityOptions(namespaceSelector) {
+ if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) {
+ return;
+ }
+ const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex];
+ const { name, visibility, visibilityLevel, showPath, editPath } = selectedNamespace.dataset;
+
+ document.querySelectorAll('.visibility-level-setting .radio').forEach((option) => {
+ const optionInput = option.querySelector('input[type=radio]');
+ const optionValue = optionInput ? optionInput.value : 0;
+ const optionTitle = option.querySelector('.option-title');
+ const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
+
+ // don't change anything if the option is restricted by admin
+ if (!option.classList.contains('restricted')) {
+ if (visibilityLevel < optionValue) {
+ option.classList.add('disabled');
+ optionInput.disabled = true;
+ const reason = option.querySelector('.option-disabled-reason');
+ if (reason) {
+ reason.innerHTML =
+ `This project cannot be ${optionName} because the visibility of
+ <a href="${showPath}">${name}</a> is ${visibility}. To make this project
+ ${optionName}, you must first <a href="${editPath}">change the visibility</a>
+ of the parent group.`;
+ }
+ } else {
+ option.classList.remove('disabled');
+ optionInput.disabled = false;
+ }
+ }
+ });
+}
+
+export default function initProjectVisibilitySelector() {
+ const namespaceSelector = document.querySelector('select.js-select-namespace');
+ if (namespaceSelector) {
+ $('.select2.js-select-namespace').on('change', () => setVisibilityOptions(namespaceSelector));
+ setVisibilityOptions(namespaceSelector);
+ }
+}
diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js
new file mode 100644
index 00000000000..4e20fce1460
--- /dev/null
+++ b/app/assets/javascripts/projects/project_import_gitlab_project.js
@@ -0,0 +1,9 @@
+import $ from 'jquery';
+import { getParameterValues } from '../lib/utils/url_utility';
+
+export default () => {
+ const path = getParameterValues('path')[0];
+
+ // get the path url and append it in the inputS
+ $('.js-path-name').val(path);
+};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
new file mode 100644
index 00000000000..93603dfc14d
--- /dev/null
+++ b/app/assets/javascripts/projects/project_new.js
@@ -0,0 +1,138 @@
+import $ from 'jquery';
+import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
+
+let hasUserDefinedProjectPath = false;
+
+const deriveProjectPathFromUrl = ($projectImportUrl) => {
+ const $currentProjectPath = $projectImportUrl.parents('.toggle-import-form').find('#project_path');
+ if (hasUserDefinedProjectPath) {
+ return;
+ }
+
+ let importUrl = $projectImportUrl.val().trim();
+ if (importUrl.length === 0) {
+ return;
+ }
+
+ /*
+ \/?: remove trailing slash
+ (\.git\/?)?: remove trailing .git (with optional trailing slash)
+ (\?.*)?: remove query string
+ (#.*)?: remove fragment identifier
+ */
+ importUrl = importUrl.replace(/\/?(\.git\/?)?(\?.*)?(#.*)?$/, '');
+
+ // extract everything after the last slash
+ const pathMatch = /\/([^/]+)$/.exec(importUrl);
+ if (pathMatch) {
+ $currentProjectPath.val(pathMatch[1]);
+ }
+};
+
+const bindEvents = () => {
+ const $newProjectForm = $('#new_project');
+ const $projectImportUrl = $('#project_import_url');
+ const $projectPath = $('#project_path');
+ const $useTemplateBtn = $('.template-button > input');
+ const $projectFieldsForm = $('.project-fields-form');
+ const $selectedTemplateText = $('.selected-template');
+ const $changeTemplateBtn = $('.change-template');
+ const $selectedIcon = $('.selected-icon svg');
+ const $templateProjectNameInput = $('#template-project-name #project_path');
+ const $pushNewProjectTipTrigger = $('.push-new-project-tip');
+
+ if ($newProjectForm.length !== 1) {
+ return;
+ }
+
+ $('.how_to_import_link').on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).next('.modal').show();
+ });
+
+ $('.modal-header .close').on('click', () => {
+ $('.modal').hide();
+ });
+
+ $('.btn_import_gitlab_project').on('click', () => {
+ const importHref = $('a.btn_import_gitlab_project').attr('href');
+ $('.btn_import_gitlab_project').attr('href', `${importHref}?namespace_id=${$('#project_namespace_id').val()}&path=${$projectPath.val()}`);
+ });
+
+ if ($pushNewProjectTipTrigger) {
+ $pushNewProjectTipTrigger
+ .removeAttr('rel')
+ .removeAttr('target')
+ .on('click', (e) => { e.preventDefault(); })
+ .popover({
+ title: $pushNewProjectTipTrigger.data('title'),
+ placement: 'auto bottom',
+ html: 'true',
+ content: $('.push-new-project-tip-template').html(),
+ })
+ .on('shown.bs.popover', () => {
+ $(document).on('click.popover touchstart.popover', (event) => {
+ if ($(event.target).closest('.popover').length === 0) {
+ $pushNewProjectTipTrigger.trigger('click');
+ }
+ });
+
+ const target = $(`#${$pushNewProjectTipTrigger.attr('aria-describedby')}`).find('.js-select-on-focus');
+ addSelectOnFocusBehaviour(target);
+
+ target.focus();
+ })
+ .on('hide.bs.popover', () => {
+ $(document).off('click.popover touchstart.popover');
+ });
+ }
+
+ function chooseTemplate() {
+ $('.template-option').hide();
+ $projectFieldsForm.addClass('selected');
+ $selectedIcon.removeClass('active');
+ const value = $(this).val();
+ const templates = {
+ rails: {
+ text: 'Ruby on Rails',
+ icon: '.selected-icon .icon-rails',
+ },
+ express: {
+ text: 'NodeJS Express',
+ icon: '.selected-icon .icon-node-express',
+ },
+ spring: {
+ text: 'Spring',
+ icon: '.selected-icon .icon-java-spring',
+ },
+ };
+
+ const selectedTemplate = templates[value];
+ $selectedTemplateText.text(selectedTemplate.text);
+ $(selectedTemplate.icon).addClass('active');
+ $templateProjectNameInput.focus();
+ }
+
+ $useTemplateBtn.on('change', chooseTemplate);
+
+ $changeTemplateBtn.on('click', () => {
+ $('.template-option').show();
+ $projectFieldsForm.removeClass('selected');
+ $useTemplateBtn.prop('checked', false);
+ });
+
+ $newProjectForm.on('submit', () => {
+ $projectPath.val($projectPath.val().trim());
+ });
+
+ $projectPath.on('keyup', () => {
+ hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
+ });
+
+ $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
+};
+
+export default {
+ bindEvents,
+ deriveProjectPathFromUrl,
+};
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
new file mode 100644
index 00000000000..63f20a0041d
--- /dev/null
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -0,0 +1,120 @@
+<script>
+ import Visibility from 'visibilityjs';
+ import ciIcon from '~/vue_shared/components/ci_icon.vue';
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import Poll from '~/lib/utils/poll';
+ import Flash from '~/flash';
+ import { s__, sprintf } from '~/locale';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import CommitPipelineService from '../services/commit_pipeline_service';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ ciIcon,
+ loadingIcon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ /* This prop can be used to replace some of the `render_commit_status`
+ used across GitLab, this way we could use this vue component and add a
+ realtime status where it makes sense
+ realtime: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }, */
+ },
+ data() {
+ return {
+ ciStatus: {},
+ isLoading: true,
+ };
+ },
+ computed: {
+ statusTitle() {
+ return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text });
+ },
+ },
+ mounted() {
+ this.service = new CommitPipelineService(this.endpoint);
+ this.initPolling();
+ },
+ methods: {
+ successCallback(res) {
+ const pipelines = res.data.pipelines;
+ if (pipelines.length > 0) {
+ // The pipeline entity always keeps the latest pipeline info on the `details.status`
+ this.ciStatus = pipelines[0].details.status;
+ }
+ this.isLoading = false;
+ },
+ errorCallback() {
+ this.ciStatus = {
+ text: 'not found',
+ icon: 'status_notfound',
+ group: 'notfound',
+ };
+ this.isLoading = false;
+ Flash(s__('Something went wrong on our end'));
+ },
+ initPolling() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'fetchData',
+ successCallback: response => this.successCallback(response),
+ errorCallback: this.errorCallback,
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ this.fetchPipelineCommitData();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ },
+ fetchPipelineCommitData() {
+ this.service.fetchData()
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+ },
+ destroy() {
+ this.poll.stop();
+ },
+ };
+</script>
+<template>
+ <div>
+ <loading-icon
+ label="Loading pipeline status"
+ size="3"
+ v-if="isLoading"
+ />
+ <a
+ v-else
+ :href="ciStatus.details_path"
+ >
+ <ci-icon
+ v-tooltip
+ :title="statusTitle"
+ :aria-label="statusTitle"
+ data-container="body"
+ :status="ciStatus"
+ />
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/tree/services/commit_pipeline_service.js b/app/assets/javascripts/projects/tree/services/commit_pipeline_service.js
new file mode 100644
index 00000000000..4b4189bc2de
--- /dev/null
+++ b/app/assets/javascripts/projects/tree/services/commit_pipeline_service.js
@@ -0,0 +1,11 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default class CommitPipelineService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ fetchData() {
+ return axios.get(this.endpoint);
+ }
+}
diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue
new file mode 100644
index 00000000000..34a60dd574b
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/app.vue
@@ -0,0 +1,157 @@
+<script>
+import bs from '../../breakpoints';
+import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+import projectsListFrequent from './projects_list_frequent.vue';
+import projectsListSearch from './projects_list_search.vue';
+
+import search from './search.vue';
+
+export default {
+ components: {
+ search,
+ loadingIcon,
+ projectsListFrequent,
+ projectsListSearch,
+ },
+ props: {
+ currentProject: {
+ type: Object,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoadingProjects: false,
+ isFrequentsListVisible: false,
+ isSearchListVisible: false,
+ isLocalStorageFailed: false,
+ isSearchFailed: false,
+ searchQuery: '',
+ };
+ },
+ computed: {
+ frequentProjects() {
+ return this.store.getFrequentProjects();
+ },
+ searchProjects() {
+ return this.store.getSearchedProjects();
+ },
+ },
+ created() {
+ if (this.currentProject.id) {
+ this.logCurrentProjectAccess();
+ }
+
+ eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$on('searchProjects', this.fetchSearchedProjects);
+ eventHub.$on('searchCleared', this.handleSearchClear);
+ eventHub.$on('searchFailed', this.handleSearchFailure);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$off('searchProjects', this.fetchSearchedProjects);
+ eventHub.$off('searchCleared', this.handleSearchClear);
+ eventHub.$off('searchFailed', this.handleSearchFailure);
+ },
+ methods: {
+ toggleFrequentProjectsList(state) {
+ this.isLoadingProjects = !state;
+ this.isSearchListVisible = !state;
+ this.isFrequentsListVisible = state;
+ },
+ toggleSearchProjectsList(state) {
+ this.isLoadingProjects = !state;
+ this.isFrequentsListVisible = !state;
+ this.isSearchListVisible = state;
+ },
+ toggleLoader(state) {
+ this.isFrequentsListVisible = !state;
+ this.isSearchListVisible = !state;
+ this.isLoadingProjects = state;
+ },
+ fetchFrequentProjects() {
+ const screenSize = bs.getBreakpointSize();
+ if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
+ this.toggleSearchProjectsList(true);
+ } else {
+ this.toggleLoader(true);
+ this.isLocalStorageFailed = false;
+ const projects = this.service.getFrequentProjects();
+ if (projects) {
+ this.toggleFrequentProjectsList(true);
+ this.store.setFrequentProjects(projects);
+ } else {
+ this.isLocalStorageFailed = true;
+ this.toggleFrequentProjectsList(true);
+ this.store.setFrequentProjects([]);
+ }
+ }
+ },
+ fetchSearchedProjects(searchQuery) {
+ this.searchQuery = searchQuery;
+ this.toggleLoader(true);
+ this.service.getSearchedProjects(this.searchQuery)
+ .then(res => res.json())
+ .then((results) => {
+ this.toggleSearchProjectsList(true);
+ this.store.setSearchedProjects(results);
+ })
+ .catch(() => {
+ this.isSearchFailed = true;
+ this.toggleSearchProjectsList(true);
+ });
+ },
+ logCurrentProjectAccess() {
+ this.service.logProjectAccess(this.currentProject);
+ },
+ handleSearchClear() {
+ this.searchQuery = '';
+ this.toggleFrequentProjectsList(true);
+ this.store.clearSearchedProjects();
+ },
+ handleSearchFailure() {
+ this.isSearchFailed = true;
+ this.toggleSearchProjectsList(true);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <search/>
+ <loading-icon
+ class="loading-animation prepend-top-20"
+ size="2"
+ v-if="isLoadingProjects"
+ :label="s__('ProjectsDropdown|Loading projects')"
+ />
+ <div
+ class="section-header"
+ v-if="isFrequentsListVisible"
+ >
+ {{ s__('ProjectsDropdown|Frequently visited') }}
+ </div>
+ <projects-list-frequent
+ v-if="isFrequentsListVisible"
+ :local-storage-failed="isLocalStorageFailed"
+ :projects="frequentProjects"
+ />
+ <projects-list-search
+ v-if="isSearchListVisible"
+ :search-failed="isSearchFailed"
+ :matcher="searchQuery"
+ :projects="searchProjects"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
new file mode 100644
index 00000000000..246dbeaaded
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
@@ -0,0 +1,57 @@
+<script>
+ import { s__ } from '../../locale';
+ import projectsListItem from './projects_list_item.vue';
+
+ export default {
+ components: {
+ projectsListItem,
+ },
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ localStorageFailed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.localStorageFailed ?
+ s__('ProjectsDropdown|This feature requires browser localStorage support') :
+ s__('ProjectsDropdown|Projects you visit often will appear here');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="projects-list-frequent-container"
+ >
+ <ul
+ class="list-unstyled"
+ >
+ <li
+ class="section-empty"
+ v-if="isListEmpty"
+ >
+ {{ listEmptyMessage }}
+ </li>
+ <projects-list-item
+ v-else
+ v-for="(project, index) in projects"
+ :key="index"
+ :project-id="project.id"
+ :project-name="project.name"
+ :namespace="project.namespace"
+ :web-url="project.webUrl"
+ :avatar-url="project.avatarUrl"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
new file mode 100644
index 00000000000..759cdd1ded9
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
@@ -0,0 +1,116 @@
+<script>
+ /* eslint-disable vue/require-default-prop, vue/require-prop-types */
+ import identicon from '../../vue_shared/components/identicon.vue';
+
+ export default {
+ components: {
+ identicon,
+ },
+ props: {
+ matcher: {
+ type: String,
+ required: false,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ webUrl: {
+ type: String,
+ required: true,
+ },
+ avatarUrl: {
+ required: true,
+ validator(value) {
+ return value === null || typeof value === 'string';
+ },
+ },
+ },
+ computed: {
+ hasAvatar() {
+ return this.avatarUrl !== null;
+ },
+ highlightedProjectName() {
+ if (this.matcher) {
+ const matcherRegEx = new RegExp(this.matcher, 'gi');
+ const matches = this.projectName.match(matcherRegEx);
+
+ if (matches && matches.length > 0) {
+ return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
+ }
+ }
+ return this.projectName;
+ },
+ /**
+ * Smartly truncates project namespace by doing two things;
+ * 1. Only include Group names in path by removing project name
+ * 2. Only include first and last group names in the path
+ * when namespace has more than 2 groups present
+ *
+ * First part (removal of project name from namespace) can be
+ * done from backend but doing so involves migration of
+ * existing project namespaces which is not wise thing to do.
+ */
+ truncatedNamespace() {
+ const namespaceArr = this.namespace.split(' / ');
+ namespaceArr.splice(-1, 1);
+ let namespace = namespaceArr.join(' / ');
+
+ if (namespaceArr.length > 2) {
+ namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
+ }
+
+ return namespace;
+ },
+ },
+ };
+</script>
+
+<template>
+ <li
+ class="projects-list-item-container"
+ >
+ <a
+ class="clearfix"
+ :href="webUrl"
+ >
+ <div
+ class="project-item-avatar-container"
+ >
+ <img
+ v-if="hasAvatar"
+ class="avatar s32"
+ :src="avatarUrl"
+ />
+ <identicon
+ v-else
+ size-class="s32"
+ :entity-id="projectId"
+ :entity-name="projectName"
+ />
+ </div>
+ <div
+ class="project-item-metadata-container"
+ >
+ <div
+ class="project-title"
+ :title="projectName"
+ v-html="highlightedProjectName"
+ >
+ </div>
+ <div
+ class="project-namespace"
+ :title="namespace"
+ >{{ truncatedNamespace }}</div>
+ </div>
+ </a>
+ </li>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue
new file mode 100644
index 00000000000..8d0c29177e6
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue
@@ -0,0 +1,63 @@
+<script>
+import { s__ } from '../../locale';
+import projectsListItem from './projects_list_item.vue';
+
+export default {
+ components: {
+ projectsListItem,
+ },
+ props: {
+ matcher: {
+ type: String,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
+ searchFailed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.searchFailed ?
+ s__('ProjectsDropdown|Something went wrong on our end.') :
+ s__('ProjectsDropdown|Sorry, no projects matched your search');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="projects-list-search-container"
+ >
+ <ul
+ class="list-unstyled"
+ >
+ <li
+ v-if="isListEmpty"
+ :class="{ 'section-failure': searchFailed }"
+ class="section-empty"
+ >
+ {{ listEmptyMessage }}
+ </li>
+ <projects-list-item
+ v-else
+ v-for="(project, index) in projects"
+ :key="index"
+ :project-id="project.id"
+ :project-name="project.name"
+ :namespace="project.namespace"
+ :web-url="project.webUrl"
+ :avatar-url="project.avatarUrl"
+ :matcher="matcher"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue
new file mode 100644
index 00000000000..0c46ed184be
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/search.vue
@@ -0,0 +1,65 @@
+<script>
+ import _ from 'underscore';
+ import eventHub from '../event_hub';
+
+ export default {
+ data() {
+ return {
+ searchQuery: '',
+ };
+ },
+ watch: {
+ searchQuery() {
+ this.handleInput();
+ },
+ },
+ mounted() {
+ eventHub.$on('dropdownOpen', this.setFocus);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.setFocus);
+ },
+ methods: {
+ setFocus() {
+ this.$refs.search.focus();
+ },
+ emitSearchEvents() {
+ if (this.searchQuery) {
+ eventHub.$emit('searchProjects', this.searchQuery);
+ } else {
+ eventHub.$emit('searchCleared');
+ }
+ },
+ /**
+ * Callback function within _.debounce is intentionally
+ * kept as ES5 `function() {}` instead of ES6 `() => {}`
+ * as it otherwise messes up function context
+ * and component reference is no longer accessible via `this`
+ */
+ // eslint-disable-next-line func-names
+ handleInput: _.debounce(function () {
+ this.emitSearchEvents();
+ }, 500),
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="search-input-container hidden-xs"
+ >
+ <input
+ type="search"
+ class="form-control"
+ ref="search"
+ v-model="searchQuery"
+ :placeholder="s__('ProjectsDropdown|Search your projects')"
+ />
+ <i
+ v-if="!searchQuery"
+ class="search-icon fa fa-fw fa-search"
+ aria-hidden="true"
+ >
+ </i>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js
new file mode 100644
index 00000000000..8937097184c
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/constants.js
@@ -0,0 +1,10 @@
+export const FREQUENT_PROJECTS = {
+ MAX_COUNT: 20,
+ LIST_COUNT_DESKTOP: 5,
+ LIST_COUNT_MOBILE: 3,
+ ELIGIBLE_FREQUENCY: 3,
+};
+
+export const HOUR_IN_MS = 3600000;
+
+export const STORAGE_KEY = 'frequent-projects';
diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js
new file mode 100644
index 00000000000..e1ca70c51a6
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/index.js
@@ -0,0 +1,66 @@
+import $ from 'jquery';
+import Vue from 'vue';
+
+import Translate from '../vue_shared/translate';
+import eventHub from './event_hub';
+import ProjectsService from './service/projects_service';
+import ProjectsStore from './store/projects_store';
+
+import projectsDropdownApp from './components/app.vue';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-projects-dropdown');
+ const navEl = document.getElementById('nav-projects-dropdown');
+
+ // Don't do anything if element doesn't exist (No projects dropdown)
+ // This is for when the user accesses GitLab without logging in
+ if (!el || !navEl) {
+ return;
+ }
+
+ $(navEl).on('shown.bs.dropdown', () => {
+ eventHub.$emit('dropdownOpen');
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ projectsDropdownApp,
+ },
+ data() {
+ const dataset = this.$options.el.dataset;
+ const store = new ProjectsStore();
+ const service = new ProjectsService(dataset.userName);
+
+ const project = {
+ id: Number(dataset.projectId),
+ name: dataset.projectName,
+ namespace: dataset.projectNamespace,
+ webUrl: dataset.projectWebUrl,
+ avatarUrl: dataset.projectAvatarUrl || null,
+ lastAccessedOn: Date.now(),
+ };
+
+ return {
+ store,
+ service,
+ state: store.state,
+ currentUserName: dataset.userName,
+ currentProject: project,
+ };
+ },
+ render(createElement) {
+ return createElement('projects-dropdown-app', {
+ props: {
+ currentUserName: this.currentUserName,
+ currentProject: this.currentProject,
+ store: this.store,
+ service: this.service,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js
new file mode 100644
index 00000000000..7231f520933
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js
@@ -0,0 +1,133 @@
+import _ from 'underscore';
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import bp from '../../breakpoints';
+import Api from '../../api';
+import AccessorUtilities from '../../lib/utils/accessor';
+
+import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
+
+Vue.use(VueResource);
+
+export default class ProjectsService {
+ constructor(currentUserName) {
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.currentUserName = currentUserName;
+ this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
+ this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
+ }
+
+ getSearchedProjects(searchQuery) {
+ return this.projectsPath.get({
+ simple: true,
+ per_page: 20,
+ membership: !!gon.current_user_id,
+ order_by: 'last_activity_at',
+ search: searchQuery,
+ });
+ }
+
+ getFrequentProjects() {
+ if (this.isLocalStorageAvailable) {
+ return this.getTopFrequentProjects();
+ }
+ return null;
+ }
+
+ logProjectAccess(project) {
+ let matchFound = false;
+ let storedFrequentProjects;
+
+ if (this.isLocalStorageAvailable) {
+ const storedRawProjects = localStorage.getItem(this.storageKey);
+
+ // Check if there's any frequent projects list set
+ if (!storedRawProjects) {
+ // No frequent projects list set, set one up.
+ storedFrequentProjects = [];
+ storedFrequentProjects.push({ ...project, frequency: 1 });
+ } else {
+ // Check if project is already present in frequents list
+ // When found, update metadata of it.
+ storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => {
+ if (projectItem.id === project.id) {
+ matchFound = true;
+ const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
+ const updatedProject = {
+ ...project,
+ frequency: projectItem.frequency,
+ lastAccessedOn: projectItem.lastAccessedOn,
+ };
+
+ // Check if duration since last access of this project
+ // is over an hour
+ if (diff > 1) {
+ return {
+ ...updatedProject,
+ frequency: updatedProject.frequency + 1,
+ lastAccessedOn: Date.now(),
+ };
+ }
+
+ return {
+ ...updatedProject,
+ };
+ }
+
+ return projectItem;
+ });
+
+ // Check whether currently logged project is present in frequents list
+ if (!matchFound) {
+ // We always keep size of frequents collection to 20 projects
+ // out of which only 5 projects with
+ // highest value of `frequency` and most recent `lastAccessedOn`
+ // are shown in projects dropdown
+ if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
+ storedFrequentProjects.shift(); // Remove an item from head of array
+ }
+
+ storedFrequentProjects.push({ ...project, frequency: 1 });
+ }
+ }
+
+ localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
+ }
+ }
+
+ getTopFrequentProjects() {
+ const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
+ let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
+
+ if (!storedFrequentProjects) {
+ return [];
+ }
+
+ if (bp.getBreakpointSize() === 'sm' ||
+ bp.getBreakpointSize() === 'xs') {
+ frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
+ }
+
+ const frequentProjects = storedFrequentProjects
+ .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY);
+
+ // Sort all frequent projects in decending order of frequency
+ // and then by lastAccessedOn with recent most first
+ frequentProjects.sort((projectA, projectB) => {
+ if (projectA.frequency < projectB.frequency) {
+ return 1;
+ } else if (projectA.frequency > projectB.frequency) {
+ return -1;
+ } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
+ return 1;
+ } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
+ return -1;
+ }
+
+ return 0;
+ });
+
+ return _.first(frequentProjects, frequentProjectsCount);
+ }
+}
diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js
new file mode 100644
index 00000000000..ffefbe693f4
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/store/projects_store.js
@@ -0,0 +1,33 @@
+export default class ProjectsStore {
+ constructor() {
+ this.state = {};
+ this.state.frequentProjects = [];
+ this.state.searchedProjects = [];
+ }
+
+ setFrequentProjects(rawProjects) {
+ this.state.frequentProjects = rawProjects;
+ }
+
+ getFrequentProjects() {
+ return this.state.frequentProjects;
+ }
+
+ setSearchedProjects(rawProjects) {
+ this.state.searchedProjects = rawProjects.map(rawProject => ({
+ id: rawProject.id,
+ name: rawProject.name,
+ namespace: rawProject.name_with_namespace,
+ webUrl: rawProject.web_url,
+ avatarUrl: rawProject.avatar_url,
+ }));
+ }
+
+ getSearchedProjects() {
+ return this.state.searchedProjects;
+ }
+
+ clearSearchedProjects() {
+ this.state.searchedProjects = [];
+ }
+}
diff --git a/app/assets/javascripts/prometheus_metrics/index.js b/app/assets/javascripts/prometheus_metrics/index.js
deleted file mode 100644
index a0c43c5abe1..00000000000
--- a/app/assets/javascripts/prometheus_metrics/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import PrometheusMetrics from './prometheus_metrics';
-
-$(() => {
- const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- prometheusMetrics.loadActiveMetrics();
-});
diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
index ef4d6df5138..0a60f4845b2 100644
--- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -1,4 +1,9 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import { s__, n__, sprintf } from '~/locale';
+import axios from '../lib/utils/axios_utils';
import PANEL_STATE from './constants';
+import { backOff } from '../lib/utils/common_utils';
export default class PrometheusMetrics {
constructor(wrapperSelector) {
@@ -17,7 +22,8 @@ export default class PrometheusMetrics {
this.$missingEnvVarMetricCount = this.$missingEnvVarPanel.find('.js-env-var-count');
this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list');
- this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('active-metrics');
+ this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics');
+ this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path');
this.$panelToggle.on('click', e => this.handlePanelToggle(e));
}
@@ -57,53 +63,69 @@ export default class PrometheusMetrics {
populateActiveMetrics(metrics) {
let totalMonitoredMetrics = 0;
let totalMissingEnvVarMetrics = 0;
+ let totalExporters = 0;
metrics.forEach((metric) => {
- this.$monitoredMetricsList.append(`<li>${metric.group}<span class="badge">${metric.active_metrics}</span></li>`);
- totalMonitoredMetrics += metric.active_metrics;
- if (metric.metrics_missing_requirements > 0) {
- this.$missingEnvVarMetricsList.append(`<li>${metric.group}</li>`);
- totalMissingEnvVarMetrics += 1;
+ if (metric.active_metrics > 0) {
+ totalExporters += 1;
+ this.$monitoredMetricsList.append(`<li>${_.escape(metric.group)}<span class="badge">${_.escape(metric.active_metrics)}</span></li>`);
+ totalMonitoredMetrics += metric.active_metrics;
+ if (metric.metrics_missing_requirements > 0) {
+ this.$missingEnvVarMetricsList.append(`<li>${_.escape(metric.group)}</li>`);
+ totalMissingEnvVarMetrics += 1;
+ }
}
});
- this.$monitoredMetricsCount.text(totalMonitoredMetrics);
- this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+ if (totalMonitoredMetrics === 0) {
+ const emptyCommonMetricsText = sprintf(s__('PrometheusService|<p class="text-tertiary">No <a href="%{docsUrl}">common metrics</a> were found</p>'), {
+ docsUrl: this.helpMetricsPath,
+ }, false);
+ this.$monitoredMetricsEmpty.empty();
+ this.$monitoredMetricsEmpty.append(emptyCommonMetricsText);
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ } else {
+ const metricsCountText = sprintf(s__('PrometheusService|%{exporters} with %{metrics} were found'), {
+ exporters: n__('%d exporter', '%d exporters', totalExporters),
+ metrics: n__('%d metric', '%d metrics', totalMonitoredMetrics),
+ });
+ this.$monitoredMetricsCount.text(metricsCountText);
+ this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
- if (totalMissingEnvVarMetrics > 0) {
- this.$missingEnvVarPanel.removeClass('hidden');
- this.$missingEnvVarPanel.find('.flash-container').off('click');
- this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
+ if (totalMissingEnvVarMetrics > 0) {
+ this.$missingEnvVarPanel.removeClass('hidden');
+ this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
+ }
}
}
loadActiveMetrics() {
this.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
- gl.utils.backOff((next, stop) => {
- $.getJSON(this.activeMetricsEndpoint)
- .done((res) => {
- if (res && res.success) {
- stop(res);
+ backOff((next, stop) => {
+ axios.get(this.activeMetricsEndpoint)
+ .then(({ data }) => {
+ if (data && data.success) {
+ stop(data);
} else {
this.backOffRequestCounter = this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < 3) {
next();
} else {
- stop(res);
+ stop(data);
}
}
})
- .fail(stop);
+ .catch(stop);
})
- .then((res) => {
- if (res && res.data && res.data.length) {
- this.populateActiveMetrics(res.data);
- } else {
+ .then((res) => {
+ if (res && res.data && res.data.length) {
+ this.populateActiveMetrics(res.data);
+ } else {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ }
+ })
+ .catch(() => {
this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
- }
- })
- .catch(() => {
- this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
- });
+ });
}
}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index 42993a252c3..40a873833e1 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -1,31 +1,26 @@
-/* eslint-disable arrow-parens, no-param-reassign, object-shorthand, no-else-return, comma-dangle, max-len */
+export default class ProtectedBranchAccessDropdown {
+ constructor(options) {
+ this.options = options;
+ this.initDropdown();
+ }
-(global => {
- global.gl = global.gl || {};
-
- gl.ProtectedBranchAccessDropdown = class {
- constructor(options) {
- const { $dropdown, data, onSelect } = options;
-
- $dropdown.glDropdown({
- data: data,
- selectable: true,
- inputId: $dropdown.data('input-id'),
- fieldName: $dropdown.data('field-name'),
- toggleLabel(item, el) {
- if (el.is('.is-active')) {
- return item.text;
- } else {
- return 'Select';
- }
- },
- clicked(opts) {
- const { e } = opts;
-
- e.preventDefault();
- onSelect();
+ initDropdown() {
+ const { $dropdown, data, onSelect } = this.options;
+ $dropdown.glDropdown({
+ data,
+ selectable: true,
+ inputId: $dropdown.data('inputId'),
+ fieldName: $dropdown.data('fieldName'),
+ toggleLabel(item, $el) {
+ if ($el.is('.is-active')) {
+ return item.text;
}
- });
- }
- };
-})(window);
+ return 'Select';
+ },
+ clicked(options) {
+ options.e.preventDefault();
+ onSelect();
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 57ea2f52814..7c61c070a35 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,55 +1,97 @@
-/* eslint-disable no-new, arrow-parens, no-param-reassign, comma-dangle, max-len */
-/* global ProtectedBranchDropdown */
+import $ from 'jquery';
+import _ from 'underscore';
+import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
+import CreateItemDropdown from '../create_item_dropdown';
+import AccessorUtilities from '../lib/utils/accessor';
-(global => {
- global.gl = global.gl || {};
+const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults';
- gl.ProtectedBranchCreate = class {
- constructor() {
- this.$wrap = this.$form = $('#new_protected_branch');
- this.buildDropdowns();
- }
+export default class ProtectedBranchCreate {
+ constructor() {
+ this.$form = $('.js-new-protected-branch');
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.currentProjectUserDefaults = {};
+ this.buildDropdowns();
+ }
- buildDropdowns() {
- const $allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
- const $allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
-
- // Cache callback
- this.onSelectCallback = this.onSelect.bind(this);
-
- // Allowed to Merge dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: $allowedToMergeDropdown,
- data: gon.merge_access_levels,
- onSelect: this.onSelectCallback
- });
-
- // Allowed to Push dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: $allowedToPushDropdown,
- data: gon.push_access_levels,
- onSelect: this.onSelectCallback
- });
-
- // Select default
- $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0);
- $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0);
-
- // Protected branch dropdown
- new ProtectedBranchDropdown({
- $dropdown: this.$wrap.find('.js-protected-branch-select'),
- onSelect: this.onSelectCallback
- });
- }
+ buildDropdowns() {
+ const $allowedToMergeDropdown = this.$form.find('.js-allowed-to-merge');
+ const $allowedToPushDropdown = this.$form.find('.js-allowed-to-push');
+ const $protectedBranchDropdown = this.$form.find('.js-protected-branch-select');
+
+ // Cache callback
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ // Allowed to Merge dropdown
+ this.protectedBranchMergeAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: $allowedToMergeDropdown,
+ data: gon.merge_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ // Allowed to Push dropdown
+ this.protectedBranchPushAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: $allowedToPushDropdown,
+ data: gon.push_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ this.createItemDropdown = new CreateItemDropdown({
+ $dropdown: $protectedBranchDropdown,
+ defaultToggleLabel: 'Protected Branch',
+ fieldName: 'protected_branch[name]',
+ onSelect: this.onSelectCallback,
+ getData: ProtectedBranchCreate.getProtectedBranches,
+ });
- // This will run after clicked callback
- onSelect() {
- // Enable submit button
- const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]');
- const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]');
- const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]');
+ this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown'));
+ }
+
+ // This will run after clicked callback
+ onSelect() {
+ // Enable submit button
+ const $branchInput = this.$form.find('input[name="protected_branch[name]"]');
+ const $allowedToMergeInput = this.$form.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]');
+ const $allowedToPushInput = this.$form.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]');
+ const completedForm = !(
+ $branchInput.val() &&
+ $allowedToMergeInput.length &&
+ $allowedToPushInput.length
+ );
+
+ this.savePreviousSelection($allowedToMergeInput.val(), $allowedToPushInput.val());
+ this.$form.find('input[type="submit"]').prop('disabled', completedForm);
+ }
+
+ static getProtectedBranches(term, callback) {
+ callback(gon.open_branches);
+ }
+
+ loadPreviousSelection(mergeDropdown, pushDropdown) {
+ let mergeIndex = 0;
+ let pushIndex = 0;
+ if (this.isLocalStorageAvailable) {
+ const savedDefaults = JSON.parse(window.localStorage.getItem(PB_LOCAL_STORAGE_KEY));
+ if (savedDefaults != null) {
+ mergeIndex = _.findLastIndex(mergeDropdown.fullData.roles, {
+ id: parseInt(savedDefaults.mergeSelection, 0),
+ });
+ pushIndex = _.findLastIndex(pushDropdown.fullData.roles, {
+ id: parseInt(savedDefaults.pushSelection, 0),
+ });
+ }
+ }
+ mergeDropdown.selectRowAtIndex(mergeIndex);
+ pushDropdown.selectRowAtIndex(pushIndex);
+ }
- this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length));
+ savePreviousSelection(mergeSelection, pushSelection) {
+ if (this.isLocalStorageAvailable) {
+ const branchDefaults = {
+ mergeSelection,
+ pushSelection,
+ };
+ window.localStorage.setItem(PB_LOCAL_STORAGE_KEY, JSON.stringify(branchDefaults));
}
- };
-})(window);
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
deleted file mode 100644
index bc6110fcd4e..00000000000
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/* eslint-disable comma-dangle, no-unused-vars */
-
-class ProtectedBranchDropdown {
- constructor(options) {
- this.onSelect = options.onSelect;
- this.$dropdown = options.$dropdown;
- this.$dropdownContainer = this.$dropdown.parent();
- this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedBranch = this.$dropdownContainer.find('.js-create-new-protected-branch');
-
- this.buildDropdown();
- this.bindEvents();
-
- // Hide footer
- this.$dropdownFooter.addClass('hidden');
- }
-
- buildDropdown() {
- this.$dropdown.glDropdown({
- data: this.getProtectedBranches.bind(this),
- filterable: true,
- remote: false,
- search: {
- fields: ['title']
- },
- selectable: true,
- toggleLabel(selected) {
- return (selected && 'id' in selected) ? selected.title : 'Protected Branch';
- },
- fieldName: 'protected_branch[name]',
- text(protectedBranch) {
- return _.escape(protectedBranch.title);
- },
- id(protectedBranch) {
- return _.escape(protectedBranch.id);
- },
- onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (options) => {
- const { $el, e } = options;
- e.preventDefault();
- this.onSelect();
- }
- });
- }
-
- bindEvents() {
- this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this));
- }
-
- onClickCreateWildcard(e) {
- e.preventDefault();
-
- // Refresh the dropdown's data, which ends up calling `getProtectedBranches`
- this.$dropdown.data('glDropdown').remote.execute();
- this.$dropdown.data('glDropdown').selectRowAtIndex();
- }
-
- getProtectedBranches(term, callback) {
- if (this.selectedBranch) {
- callback(gon.open_branches.concat(this.selectedBranch));
- } else {
- callback(gon.open_branches);
- }
- }
-
- toggleCreateNewButton(branchName) {
- this.selectedBranch = {
- title: branchName,
- id: branchName,
- text: branchName
- };
-
- if (branchName) {
- this.$dropdownContainer
- .find('.js-create-new-protected-branch code')
- .text(branchName);
- }
-
- this.$dropdownFooter.toggleClass('hidden', !branchName);
- }
-}
-
-window.ProtectedBranchDropdown = ProtectedBranchDropdown;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 6ef59e94384..54560d08ad7 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,69 +1,62 @@
-/* eslint-disable no-new, arrow-parens, no-param-reassign, comma-dangle, max-len */
-/* global Flash */
-
-(global => {
- global.gl = global.gl || {};
-
- gl.ProtectedBranchEdit = class {
- constructor(options) {
- this.$wrap = options.$wrap;
- this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
- this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
-
- this.buildDropdowns();
- }
-
- buildDropdowns() {
- // Allowed to merge dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: this.$allowedToMergeDropdown,
- data: gon.merge_access_levels,
- onSelect: this.onSelect.bind(this)
- });
-
- // Allowed to push dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: this.$allowedToPushDropdown,
- data: gon.push_access_levels,
- onSelect: this.onSelect.bind(this)
- });
- }
-
- onSelect() {
- const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`);
- const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`);
-
- // Do not update if one dropdown has not selected any option
- if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
-
- this.$allowedToMergeDropdown.disable();
- this.$allowedToPushDropdown.disable();
-
- $.ajax({
- type: 'POST',
- url: this.$wrap.data('url'),
- dataType: 'json',
- data: {
- _method: 'PATCH',
- protected_branch: {
- merge_access_levels_attributes: [{
- id: this.$allowedToMergeDropdown.data('access-level-id'),
- access_level: $allowedToMergeInput.val()
- }],
- push_access_levels_attributes: [{
- id: this.$allowedToPushDropdown.data('access-level-id'),
- access_level: $allowedToPushInput.val()
- }]
- }
- },
- error() {
- $.scrollTo(0);
- new Flash('Failed to update branch!');
- }
- }).always(() => {
- this.$allowedToMergeDropdown.enable();
- this.$allowedToPushDropdown.enable();
- });
- }
- };
-})(window);
+import flash from '../flash';
+import axios from '../lib/utils/axios_utils';
+import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
+
+export default class ProtectedBranchEdit {
+ constructor(options) {
+ this.$wrap = options.$wrap;
+ this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
+ this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ // Allowed to merge dropdown
+ this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: this.$allowedToMergeDropdown,
+ data: gon.merge_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ // Allowed to push dropdown
+ this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: this.$allowedToPushDropdown,
+ data: gon.push_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+ }
+
+ onSelect() {
+ const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`);
+ const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`);
+
+ // Do not update if one dropdown has not selected any option
+ if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
+
+ this.$allowedToMergeDropdown.disable();
+ this.$allowedToPushDropdown.disable();
+
+ axios.patch(this.$wrap.data('url'), {
+ protected_branch: {
+ merge_access_levels_attributes: [{
+ id: this.$allowedToMergeDropdown.data('accessLevelId'),
+ access_level: $allowedToMergeInput.val(),
+ }],
+ push_access_levels_attributes: [{
+ id: this.$allowedToPushDropdown.data('accessLevelId'),
+ access_level: $allowedToPushInput.val(),
+ }],
+ },
+ }).then(() => {
+ this.$allowedToMergeDropdown.enable();
+ this.$allowedToPushDropdown.enable();
+ }).catch(() => {
+ this.$allowedToMergeDropdown.enable();
+ this.$allowedToPushDropdown.enable();
+
+ flash('Failed to update branch!', 'alert', document.querySelector('.js-protected-branches-list'));
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
index 336fa6c57a7..10253c0febc 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
@@ -1,18 +1,19 @@
-/* eslint-disable arrow-parens, no-param-reassign, no-new, comma-dangle */
+/* eslint-disable no-new */
-(global => {
- global.gl = global.gl || {};
+import $ from 'jquery';
+import ProtectedBranchEdit from './protected_branch_edit';
- gl.ProtectedBranchEditList = class {
- constructor() {
- this.$wrap = $('.protected-branches-list');
+export default class ProtectedBranchEditList {
+ constructor() {
+ this.$wrap = $('.protected-branches-list');
+ this.initEditForm();
+ }
- // Build edit forms
- this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => {
- new gl.ProtectedBranchEdit({
- $wrap: $(el)
- });
+ initEditForm() {
+ this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => {
+ new ProtectedBranchEdit({
+ $wrap: $(el),
});
- }
- };
-})(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branches_bundle.js b/app/assets/javascripts/protected_branches/protected_branches_bundle.js
deleted file mode 100644
index 874d70a1431..00000000000
--- a/app/assets/javascripts/protected_branches/protected_branches_bundle.js
+++ /dev/null
@@ -1,5 +0,0 @@
-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/index.js b/app/assets/javascripts/protected_tags/index.js
deleted file mode 100644
index 61e7ba53862..00000000000
--- a/app/assets/javascripts/protected_tags/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as ProtectedTagCreate } from './protected_tag_create';
-export { default as ProtectedTagEditList } from './protected_tag_edit_list';
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
index d4c9a91a74a..b803da798d5 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -9,8 +9,8 @@ export default class ProtectedTagAccessDropdown {
this.options.$dropdown.glDropdown({
data: this.options.data,
selectable: true,
- inputId: this.options.$dropdown.data('input-id'),
- fieldName: this.options.$dropdown.data('field-name'),
+ inputId: this.options.$dropdown.data('inputId'),
+ fieldName: this.options.$dropdown.data('fieldName'),
toggleLabel(item, $el) {
if ($el.is('.is-active')) {
return item.text;
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index 91bd140bd12..2f8116df0d2 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,5 +1,6 @@
+import $ from 'jquery';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
-import ProtectedTagDropdown from './protected_tag_dropdown';
+import CreateItemDropdown from '../create_item_dropdown';
export default class ProtectedTagCreate {
constructor() {
@@ -24,9 +25,12 @@ export default class ProtectedTagCreate {
$allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
// Protected tag dropdown
- this.protectedTagDropdown = new ProtectedTagDropdown({
+ this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
+ defaultToggleLabel: 'Protected Tag',
+ fieldName: 'protected_tag[name]',
onSelect: this.onSelectCallback,
+ getData: ProtectedTagCreate.getProtectedTags,
});
}
@@ -36,6 +40,10 @@ export default class ProtectedTagCreate {
const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
- this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length));
+ this.$form.find('input[type="submit"]').prop('disabled', !($tagInput.val() && $allowedToCreateInput.length));
+ }
+
+ static getProtectedTags(term, callback) {
+ callback(gon.open_tags);
}
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
deleted file mode 100644
index 9d045886262..00000000000
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ /dev/null
@@ -1,86 +0,0 @@
-export default class ProtectedTagDropdown {
- /**
- * @param {Object} options containing
- * `$dropdown` target element
- * `onSelect` event callback
- * $dropdown must be an element created using `dropdown_tag()` rails helper
- */
- constructor(options) {
- this.onSelect = options.onSelect;
- this.$dropdown = options.$dropdown;
- this.$dropdownContainer = this.$dropdown.parent();
- this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
-
- this.buildDropdown();
- this.bindEvents();
-
- // Hide footer
- this.toggleFooter(true);
- }
-
- buildDropdown() {
- this.$dropdown.glDropdown({
- data: this.getProtectedTags.bind(this),
- filterable: true,
- remote: false,
- search: {
- fields: ['title'],
- },
- selectable: true,
- toggleLabel(selected) {
- return (selected && 'id' in selected) ? selected.title : 'Protected Tag';
- },
- fieldName: 'protected_tag[name]',
- text(protectedTag) {
- return _.escape(protectedTag.title);
- },
- id(protectedTag) {
- return _.escape(protectedTag.id);
- },
- onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (options) => {
- options.e.preventDefault();
- this.onSelect();
- },
- });
- }
-
- bindEvents() {
- this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this));
- }
-
- onClickCreateWildcard(e) {
- this.$dropdown.data('glDropdown').remote.execute();
- this.$dropdown.data('glDropdown').selectRowAtIndex();
- e.preventDefault();
- }
-
- getProtectedTags(term, callback) {
- if (this.selectedTag) {
- callback(gon.open_tags.concat(this.selectedTag));
- } else {
- callback(gon.open_tags);
- }
- }
-
- toggleCreateNewButton(tagName) {
- if (tagName) {
- this.selectedTag = {
- title: tagName,
- id: tagName,
- text: tagName,
- };
-
- this.$dropdownContainer
- .find('.js-create-new-protected-tag code')
- .text(tagName);
- }
-
- this.toggleFooter(!tagName);
- }
-
- toggleFooter(toggleState) {
- this.$dropdownFooter.toggleClass('hidden', toggleState);
- }
-}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index 09a387c0f9e..8687b2a4044 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,6 +1,5 @@
-/* eslint-disable no-new */
-/* global Flash */
-
+import flash from '../flash';
+import axios from '../lib/utils/axios_utils';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
export default class ProtectedTagEdit {
@@ -29,24 +28,19 @@ export default class ProtectedTagEdit {
this.$allowedToCreateDropdownButton.disable();
- $.ajax({
- type: 'POST',
- url: this.$wrap.data('url'),
- dataType: 'json',
- data: {
- _method: 'PATCH',
- protected_tag: {
- create_access_levels_attributes: [{
- id: this.$allowedToCreateDropdownButton.data('access-level-id'),
- access_level: $allowedToCreateInput.val(),
- }],
- },
- },
- error() {
- new Flash('Failed to update tag!', null, $('.js-protected-tags-list'));
+ axios.patch(this.$wrap.data('url'), {
+ protected_tag: {
+ create_access_levels_attributes: [{
+ id: this.$allowedToCreateDropdownButton.data('accessLevelId'),
+ access_level: $allowedToCreateInput.val(),
+ }],
},
- }).always(() => {
+ }).then(() => {
this.$allowedToCreateDropdownButton.enable();
+ }).catch(() => {
+ this.$allowedToCreateDropdownButton.enable();
+
+ flash('Failed to update tag!', 'alert', document.querySelector('.js-protected-tags-list'));
});
}
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
index bd9fc872266..b35bf4d4606 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
@@ -1,5 +1,6 @@
/* eslint-disable no-new */
+import $ from 'jquery';
import ProtectedTagEdit from './protected_tag_edit';
export default class ProtectedTagEditList {
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
index 215cd6fbdfd..95c5cf7b345 100644
--- a/app/assets/javascripts/ref_select_dropdown.js
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -1,11 +1,14 @@
+import $ from 'jquery';
+
class RefSelectDropdown {
constructor($dropdownButton, availableRefs) {
+ const availableRefsValue = availableRefs || JSON.parse(document.getElementById('availableRefs').innerHTML);
$dropdownButton.glDropdown({
- data: availableRefs,
+ data: availableRefsValue,
filterable: true,
filterByText: true,
remote: false,
- fieldName: $dropdownButton.data('field-name'),
+ fieldName: $dropdownButton.data('fieldName'),
filterInput: 'input[type="search"]',
selectable: true,
isSelectable(branch, $el) {
@@ -23,7 +26,7 @@ class RefSelectDropdown {
});
const $dropdownContainer = $dropdownButton.closest('.dropdown');
- const $fieldInput = $(`input[name="${$dropdownButton.data('field-name')}"]`, $dropdownContainer);
+ const $fieldInput = $(`input[name="${$dropdownButton.data('fieldName')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
$filterInput.on('keyup', (e) => {
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
new file mode 100644
index 00000000000..ea0f7199a70
--- /dev/null
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -0,0 +1,62 @@
+<script>
+ import { mapGetters, mapActions } from 'vuex';
+ import Flash from '../../flash';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import store from '../stores';
+ import collapsibleContainer from './collapsible_container.vue';
+ import { errorMessages, errorMessagesTypes } from '../constants';
+
+ export default {
+ name: 'RegistryListApp',
+ components: {
+ collapsibleContainer,
+ loadingIcon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ store,
+ computed: {
+ ...mapGetters([
+ 'isLoading',
+ 'repos',
+ ]),
+ },
+ created() {
+ this.setMainEndpoint(this.endpoint);
+ },
+ mounted() {
+ this.fetchRepos()
+ .catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS]));
+ },
+ methods: {
+ ...mapActions([
+ 'setMainEndpoint',
+ 'fetchRepos',
+ ]),
+ },
+ };
+</script>
+<template>
+ <div>
+ <loading-icon
+ v-if="isLoading"
+ size="3"
+ />
+
+ <collapsible-container
+ v-else-if="!isLoading && repos.length"
+ v-for="(item, index) in repos"
+ :key="index"
+ :repo="item"
+ />
+
+ <p v-else-if="!isLoading && !repos.length">
+ {{ __(`No container images stored for this project.
+Add one by following the instructions above.`) }}
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
new file mode 100644
index 00000000000..a03180e80e6
--- /dev/null
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -0,0 +1,135 @@
+<script>
+ import { mapActions } from 'vuex';
+ import Flash from '../../flash';
+ import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import tableRegistry from './table_registry.vue';
+ import { errorMessages, errorMessagesTypes } from '../constants';
+
+ export default {
+ name: 'CollapsibeContainerRegisty',
+ components: {
+ clipboardButton,
+ loadingIcon,
+ tableRegistry,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ repo: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isOpen: false,
+ };
+ },
+ computed: {
+ clipboardText() {
+ return `docker pull ${this.repo.location}`;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'fetchRepos',
+ 'fetchList',
+ 'deleteRepo',
+ ]),
+
+ toggleRepo() {
+ this.isOpen = !this.isOpen;
+
+ if (this.isOpen) {
+ this.fetchList({ repo: this.repo })
+ .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY));
+ }
+ },
+
+ handleDeleteRepository() {
+ this.deleteRepo(this.repo)
+ .then(() => this.fetchRepos())
+ .catch(() => this.showError(errorMessagesTypes.DELETE_REPO));
+ },
+
+ showError(message) {
+ Flash(errorMessages[message]);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="container-image">
+ <div class="container-image-head">
+ <button
+ type="button"
+ @click="toggleRepo"
+ class="js-toggle-repo btn-link"
+ >
+ <i
+ class="fa"
+ :class="{
+ 'fa-chevron-right': !isOpen,
+ 'fa-chevron-up': isOpen,
+ }"
+ aria-hidden="true"
+ >
+ </i>
+ {{ repo.name }}
+ </button>
+
+ <clipboard-button
+ v-if="repo.location"
+ :text="clipboardText"
+ :title="repo.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+
+ <div class="controls hidden-xs pull-right">
+ <button
+ v-if="repo.canDelete"
+ type="button"
+ class="js-remove-repo btn btn-danger"
+ :title="s__('ContainerRegistry|Remove repository')"
+ :aria-label="s__('ContainerRegistry|Remove repository')"
+ v-tooltip
+ @click="handleDeleteRepository"
+ >
+ <i
+ class="fa fa-trash"
+ aria-hidden="true"
+ >
+ </i>
+ </button>
+ </div>
+ </div>
+
+ <loading-icon
+ v-if="repo.isLoading"
+ class="append-bottom-20"
+ size="2"
+ />
+
+ <div
+ v-else-if="!repo.isLoading && isOpen"
+ class="container-image-tags"
+ >
+
+ <table-registry
+ v-if="repo.list.length"
+ :repo="repo"
+ />
+
+ <div
+ v-else
+ class="nothing-here-block"
+ >
+ {{ s__("ContainerRegistry|No tags in Container Registry for this container image.") }}
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
new file mode 100644
index 00000000000..ee4eb3581f3
--- /dev/null
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -0,0 +1,145 @@
+<script>
+ import { mapActions } from 'vuex';
+ import { n__ } from '../../locale';
+ import Flash from '../../flash';
+ import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
+ import tablePagination from '../../vue_shared/components/table_pagination.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import timeagoMixin from '../../vue_shared/mixins/timeago';
+ import { errorMessages, errorMessagesTypes } from '../constants';
+ import { numberToHumanSize } from '../../lib/utils/number_utils';
+
+ export default {
+ components: {
+ clipboardButton,
+ tablePagination,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
+ props: {
+ repo: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ shouldRenderPagination() {
+ return this.repo.pagination.total > this.repo.pagination.perPage;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'fetchList',
+ 'deleteRegistry',
+ ]),
+
+ layers(item) {
+ return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
+ },
+
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+
+ handleDeleteRegistry(registry) {
+ this.deleteRegistry(registry)
+ .then(() => this.fetchList({ repo: this.repo }))
+ .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
+ },
+
+ onPageChange(pageNumber) {
+ this.fetchList({ repo: this.repo, page: pageNumber })
+ .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY));
+ },
+
+ clipboardText(text) {
+ return `docker pull ${text}`;
+ },
+
+ showError(message) {
+ Flash(errorMessages[message]);
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <table class="table tags">
+ <thead>
+ <tr>
+ <th>{{ s__('ContainerRegistry|Tag') }}</th>
+ <th>{{ s__('ContainerRegistry|Tag ID') }}</th>
+ <th>{{ s__("ContainerRegistry|Size") }}</th>
+ <th>{{ s__("ContainerRegistry|Created") }}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="(item, i) in repo.list"
+ :key="i">
+ <td>
+
+ {{ item.tag }}
+
+ <clipboard-button
+ v-if="item.location"
+ :title="item.location"
+ :text="clipboardText(item.location)"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </td>
+ <td>
+ <span
+ v-tooltip
+ :title="item.revision"
+ data-placement="bottom"
+ >
+ {{ item.shortRevision }}
+ </span>
+ </td>
+ <td>
+ {{ formatSize(item.size) }}
+ <template v-if="item.size && item.layers">
+ &middot;
+ </template>
+ {{ layers(item) }}
+ </td>
+
+ <td>
+ {{ timeFormated(item.createdAt) }}
+ </td>
+
+ <td class="content">
+ <button
+ v-if="item.canDelete"
+ type="button"
+ class="js-delete-registry btn btn-danger hidden-xs pull-right"
+ :title="s__('ContainerRegistry|Remove tag')"
+ :aria-label="s__('ContainerRegistry|Remove tag')"
+ data-container="body"
+ v-tooltip
+ @click="handleDeleteRegistry(item)"
+ >
+ <i
+ class="fa fa-trash"
+ aria-hidden="true"
+ >
+ </i>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <table-pagination
+ v-if="shouldRenderPagination"
+ :change="onPageChange"
+ :page-info="repo.pagination"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js
new file mode 100644
index 00000000000..712b0fade3d
--- /dev/null
+++ b/app/assets/javascripts/registry/constants.js
@@ -0,0 +1,15 @@
+import { __ } from '../locale';
+
+export const errorMessagesTypes = {
+ FETCH_REGISTRY: 'FETCH_REGISTRY',
+ FETCH_REPOS: 'FETCH_REPOS',
+ DELETE_REPO: 'DELETE_REPO',
+ DELETE_REGISTRY: 'DELETE_REGISTRY',
+};
+
+export const errorMessages = {
+ [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'),
+ [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'),
+ [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'),
+ [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'),
+};
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
new file mode 100644
index 00000000000..6fb125192b2
--- /dev/null
+++ b/app/assets/javascripts/registry/index.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import registryApp from './components/app.vue';
+import Translate from '../vue_shared/translate';
+
+Vue.use(Translate);
+
+export default () => new Vue({
+ el: '#js-vue-registry-images',
+ components: {
+ registryApp,
+ },
+ data() {
+ const dataset = document.querySelector(this.$options.el).dataset;
+ return {
+ endpoint: dataset.endpoint,
+ };
+ },
+ render(createElement) {
+ return createElement('registry-app', {
+ props: {
+ endpoint: this.endpoint,
+ },
+ });
+ },
+});
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
new file mode 100644
index 00000000000..795b39bb3dc
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import * as types from './mutation_types';
+
+Vue.use(VueResource);
+
+export const fetchRepos = ({ commit, state }) => {
+ commit(types.TOGGLE_MAIN_LOADING);
+
+ return Vue.http.get(state.endpoint)
+ .then(res => res.json())
+ .then((response) => {
+ commit(types.TOGGLE_MAIN_LOADING);
+ commit(types.SET_REPOS_LIST, response);
+ });
+};
+
+export const fetchList = ({ commit }, { repo, page }) => {
+ commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
+
+ return Vue.http.get(repo.tagsPath, { params: { page } })
+ .then((response) => {
+ const headers = response.headers;
+
+ return response.json().then((resp) => {
+ commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
+ commit(types.SET_REGISTRY_LIST, { repo, resp, headers });
+ });
+ });
+};
+
+export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath);
+
+export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath);
+
+export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
+export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js
new file mode 100644
index 00000000000..588f479c492
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/getters.js
@@ -0,0 +1,2 @@
+export const isLoading = state => state.isLoading;
+export const repos = state => state.repos;
diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js
new file mode 100644
index 00000000000..78b67881210
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: {
+ isLoading: false,
+ endpoint: '', // initial endpoint to fetch the repos list
+ /**
+ * Each object in `repos` has the following strucure:
+ * {
+ * name: String,
+ * isLoading: Boolean,
+ * tagsPath: String // endpoint to request the list
+ * destroyPath: String // endpoit to delete the repo
+ * list: Array // List of the registry images
+ * }
+ *
+ * Each registry image inside `list` has the following structure:
+ * {
+ * tag: String,
+ * revision: String
+ * shortRevision: String
+ * size: Number
+ * layers: Number
+ * createdAt: String
+ * destroyPath: String // endpoit to delete each image
+ * }
+ */
+ repos: [],
+ },
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/stores/mutation_types.js
new file mode 100644
index 00000000000..2c69bf11807
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/mutation_types.js
@@ -0,0 +1,7 @@
+export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
+
+export const SET_REPOS_LIST = 'SET_REPOS_LIST';
+export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
+
+export const SET_REGISTRY_LIST = 'SET_REGISTRY_LIST';
+export const TOGGLE_REGISTRY_LIST_LOADING = 'TOGGLE_REGISTRY_LIST_LOADING';
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
new file mode 100644
index 00000000000..208c3c39866
--- /dev/null
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -0,0 +1,54 @@
+import * as types from './mutation_types';
+import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
+
+export default {
+
+ [types.SET_MAIN_ENDPOINT](state, endpoint) {
+ Object.assign(state, { endpoint });
+ },
+
+ [types.SET_REPOS_LIST](state, list) {
+ Object.assign(state, {
+ repos: list.map(el => ({
+ canDelete: !!el.destroy_path,
+ destroyPath: el.destroy_path,
+ id: el.id,
+ isLoading: false,
+ list: [],
+ location: el.location,
+ name: el.path,
+ tagsPath: el.tags_path,
+ })),
+ });
+ },
+
+ [types.TOGGLE_MAIN_LOADING](state) {
+ Object.assign(state, { isLoading: !state.isLoading });
+ },
+
+ [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) {
+ const listToUpdate = state.repos.find(el => el.id === repo.id);
+
+ const normalizedHeaders = normalizeHeaders(headers);
+ const pagination = parseIntPagination(normalizedHeaders);
+
+ listToUpdate.pagination = pagination;
+
+ listToUpdate.list = resp.map(element => ({
+ tag: element.name,
+ revision: element.revision,
+ shortRevision: element.short_revision,
+ size: element.total_size,
+ layers: element.layers,
+ location: element.location,
+ createdAt: element.created_at,
+ destroyPath: element.destroy_path,
+ canDelete: !!element.destroy_path,
+ }));
+ },
+
+ [types.TOGGLE_REGISTRY_LIST_LOADING](state, list) {
+ const listToUpdate = state.repos.find(el => el.id === list.id);
+ listToUpdate.isLoading = !listToUpdate.isLoading;
+ },
+};
diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js
deleted file mode 100644
index 2c3a9cacd38..00000000000
--- a/app/assets/javascripts/render_gfm.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len */
-
-// Render Gitlab flavoured Markdown
-//
-// Delegates to syntax highlight and render math
-//
-(function() {
- $.fn.renderGFM = function() {
- this.find('.js-syntax-highlight').syntaxHighlight();
- this.find('.js-render-math').renderMath();
- return this;
- };
-
- $(document).on('ready load', function() {
- return $('body').renderGFM();
- });
-}).call(window);
diff --git a/app/assets/javascripts/render_math.js b/app/assets/javascripts/render_math.js
deleted file mode 100644
index 8b3fee49cb9..00000000000
--- a/app/assets/javascripts/render_math.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len, no-console */
-/* global katex */
-
-// Renders math using KaTeX in any element with the
-// `js-render-math` class
-//
-// ### Example Markup
-//
-// <code class="js-render-math"></div>
-//
-(function() {
- // Only load once
- var katexLoaded = false;
-
- // Loop over all math elements and render math
- var renderWithKaTeX = function (elements) {
- elements.each(function () {
- var mathNode = $('<span></span>');
- var $this = $(this);
-
- var display = $this.attr('data-math-style') === 'display';
- try {
- katex.render($this.text(), mathNode.get(0), { displayMode: display });
- mathNode.insertAfter($this);
- $this.remove();
- } catch (err) {
- // What can we do??
- console.log(err.message);
- }
- });
- };
-
- $.fn.renderMath = function() {
- var $this = this;
- if ($this.length === 0) return;
-
- if (katexLoaded) renderWithKaTeX($this);
- else {
- // Request CSS file so it is in the cache
- $.get(gon.katex_css_url, function() {
- var css = $('<link>',
- { rel: 'stylesheet',
- type: 'text/css',
- href: gon.katex_css_url,
- });
- css.appendTo('head');
-
- // Load KaTeX js
- $.getScript(gon.katex_js_url, function() {
- katexLoaded = true;
- renderWithKaTeX($this); // Run KaTeX
- });
- });
- }
- };
-}).call(window);
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 322162afdb8..2088a49590a 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,237 +1,222 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, object-shorthand, comma-dangle, no-else-return, no-param-reassign, max-len */
+import $ from 'jquery';
+import _ from 'underscore';
import Cookies from 'js-cookie';
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
+
+function Sidebar(currentUser) {
+ this.toggleTodo = this.toggleTodo.bind(this);
+ this.sidebar = $('aside');
+
+ this.removeListeners();
+ this.addEventListeners();
+}
+
+Sidebar.initialize = function(currentUser) {
+ if (!this.instance) {
+ this.instance = new Sidebar(currentUser);
+ }
+};
+
+Sidebar.prototype.removeListeners = function () {
+ this.sidebar.off('click', '.sidebar-collapsed-icon');
+ this.sidebar.off('hidden.gl.dropdown');
+ $('.dropdown').off('loading.gl.dropdown');
+ $('.dropdown').off('loaded.gl.dropdown');
+ $(document).off('click', '.js-sidebar-toggle');
+};
+
+Sidebar.prototype.addEventListeners = function() {
+ const $document = $(document);
+
+ this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
+ this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
+ $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
+ $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
+
+ $document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked);
+ return $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo);
+};
+
+Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
+ var $allGutterToggleIcons, $this, $thisIcon;
+ e.preventDefault();
+ $this = $(this);
+ $thisIcon = $this.find('i');
+ $allGutterToggleIcons = $('.js-sidebar-toggle i');
+ if ($thisIcon.hasClass('fa-angle-double-right')) {
+ $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left');
+ $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
+ $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
+ } else {
+ $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right');
+ $('aside.right-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded');
+ $('.layout-page').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded');
+
+ if (gl.lazyLoader) gl.lazyLoader.loadCheck();
+ }
+ if (!triggered) {
+ Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed'));
+ }
+};
+
+Sidebar.prototype.toggleTodo = function(e) {
+ var $btnText, $this, $todoLoading, ajaxType, url;
+ $this = $(e.currentTarget);
+ ajaxType = $this.attr('data-delete-path') ? 'delete' : 'post';
+ if ($this.attr('data-delete-path')) {
+ url = "" + ($this.attr('data-delete-path'));
+ } else {
+ url = "" + ($this.data('url'));
+ }
+
+ $this.tooltip('hide');
+
+ $('.js-issuable-todo').disable().addClass('is-loading');
+
+ axios[ajaxType](url, {
+ issuable_id: $this.data('issuableId'),
+ issuable_type: $this.data('issuableType'),
+ }).then(({ data }) => {
+ this.todoUpdateDone(data);
+ }).catch(() => flash(`There was an error ${ajaxType === 'post' ? 'adding a' : 'deleting the'} todo.`));
+};
+
+Sidebar.prototype.todoUpdateDone = function(data) {
+ const deletePath = data.delete_path ? data.delete_path : null;
+ const attrPrefix = deletePath ? 'mark' : 'todo';
+ const $todoBtns = $('.js-issuable-todo');
+
+ $(document).trigger('todo:toggle', data.count);
+
+ $todoBtns.each((i, el) => {
+ const $el = $(el);
+ const $elText = $el.find('.js-issuable-todo-inner');
+
+ $el.removeClass('is-loading')
+ .enable()
+ .attr('aria-label', $el.data(`${attrPrefix}Text`))
+ .attr('data-delete-path', deletePath)
+ .attr('title', $el.data(`${attrPrefix}Text`));
+
+ if ($el.hasClass('has-tooltip')) {
+ $el.tooltip('fixTitle');
+ }
-(function() {
- this.Sidebar = (function() {
- function Sidebar(currentUser) {
- this.toggleTodo = this.toggleTodo.bind(this);
- this.sidebar = $('aside');
+ if ($el.data(`${attrPrefix}Icon`)) {
+ $elText.html($el.data(`${attrPrefix}Icon`));
+ } else {
+ $elText.text($el.data(`${attrPrefix}Text`));
+ }
+ });
+};
+
+Sidebar.prototype.sidebarDropdownLoading = function(e) {
+ var $loading, $sidebarCollapsedIcon, i, img;
+ $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon');
+ img = $sidebarCollapsedIcon.find('img');
+ i = $sidebarCollapsedIcon.find('i');
+ $loading = $('<i class="fa fa-spinner fa-spin"></i>');
+ if (img.length) {
+ img.before($loading);
+ return img.hide();
+ } else if (i.length) {
+ i.before($loading);
+ return i.hide();
+ }
+};
+
+Sidebar.prototype.sidebarDropdownLoaded = function(e) {
+ var $sidebarCollapsedIcon, i, img;
+ $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon');
+ img = $sidebarCollapsedIcon.find('img');
+ $sidebarCollapsedIcon.find('i.fa-spin').remove();
+ i = $sidebarCollapsedIcon.find('i');
+ if (img.length) {
+ return img.show();
+ } else {
+ return i.show();
+ }
+};
+
+Sidebar.prototype.sidebarCollapseClicked = function(e) {
+ var $block, sidebar;
+ if ($(e.currentTarget).hasClass('dont-change-state')) {
+ return;
+ }
+ sidebar = e.data;
+ e.preventDefault();
+ $block = $(this).closest('.block');
+ return sidebar.openDropdown($block);
+};
+
+Sidebar.prototype.openDropdown = function(blockOrName) {
+ var $block;
+ $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
+ if (!this.isOpen()) {
+ this.setCollapseAfterUpdate($block);
+ this.toggleSidebar('open');
+ }
+
+ // Wait for the sidebar to trigger('click') open
+ // so it doesn't cause our dropdown to close preemptively
+ setTimeout(() => {
+ $block.find('.js-sidebar-dropdown-toggle').trigger('click');
+ });
+};
+
+Sidebar.prototype.setCollapseAfterUpdate = function($block) {
+ $block.addClass('collapse-after-update');
+ return $('.layout-page').addClass('with-overlay');
+};
+
+Sidebar.prototype.onSidebarDropdownHidden = function(e) {
+ var $block, sidebar;
+ sidebar = e.data;
+ e.preventDefault();
+ $block = $(e.target).closest('.block');
+ return sidebar.sidebarDropdownHidden($block);
+};
+
+Sidebar.prototype.sidebarDropdownHidden = function($block) {
+ if ($block.hasClass('collapse-after-update')) {
+ $block.removeClass('collapse-after-update');
+ $('.layout-page').removeClass('with-overlay');
+ return this.toggleSidebar('hide');
+ }
+};
+
+Sidebar.prototype.triggerOpenSidebar = function() {
+ return this.sidebar.find('.js-sidebar-toggle').trigger('click');
+};
+
+Sidebar.prototype.toggleSidebar = function(action) {
+ if (action == null) {
+ action = 'toggle';
+ }
+ if (action === 'toggle') {
+ this.triggerOpenSidebar();
+ }
+ if (action === 'open') {
+ if (!this.isOpen()) {
+ this.triggerOpenSidebar();
+ }
+ }
+ if (action === 'hide') {
+ if (this.isOpen()) {
+ return this.triggerOpenSidebar();
+ }
+ }
+};
- this.$sidebarInner = this.sidebar.find('.issuable-sidebar');
- this.$navGitlab = $('.navbar-gitlab');
- this.$rightSidebar = $('.js-right-sidebar');
+Sidebar.prototype.isOpen = function() {
+ return this.sidebar.is('.right-sidebar-expanded');
+};
- this.removeListeners();
- this.addEventListeners();
- }
+Sidebar.prototype.getBlock = function(name) {
+ return this.sidebar.find(".block." + name);
+};
- Sidebar.prototype.removeListeners = function () {
- this.sidebar.off('click', '.sidebar-collapsed-icon');
- $('.dropdown').off('hidden.gl.dropdown');
- $('.dropdown').off('loading.gl.dropdown');
- $('.dropdown').off('loaded.gl.dropdown');
- $(document).off('click', '.js-sidebar-toggle');
- };
-
- Sidebar.prototype.addEventListeners = function() {
- const $document = $(document);
- const throttledSetSidebarHeight = _.throttle(this.setSidebarHeight.bind(this), 20);
- const debouncedSetSidebarHeight = _.debounce(this.setSidebarHeight.bind(this), 200);
-
- this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
- $('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
- $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
- $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
- $(window).on('resize', () => throttledSetSidebarHeight());
- $document.on('scroll', () => debouncedSetSidebarHeight());
- $document.on('click', '.js-sidebar-toggle', function(e, triggered) {
- var $allGutterToggleIcons, $this, $thisIcon;
- e.preventDefault();
- $this = $(this);
- $thisIcon = $this.find('i');
- $allGutterToggleIcons = $('.js-sidebar-toggle i');
- if ($thisIcon.hasClass('fa-angle-double-right')) {
- $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left');
- $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
- $('.page-with-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
- } else {
- $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right');
- $('aside.right-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded');
- $('.page-with-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded');
- }
- if (!triggered) {
- return Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed'));
- }
- });
- return $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo);
- };
-
- Sidebar.prototype.toggleTodo = function(e) {
- var $btnText, $this, $todoLoading, ajaxType, url;
- $this = $(e.currentTarget);
- ajaxType = $this.attr('data-delete-path') ? 'DELETE' : 'POST';
- if ($this.attr('data-delete-path')) {
- url = "" + ($this.attr('data-delete-path'));
- } else {
- url = "" + ($this.data('url'));
- }
-
- $this.tooltip('hide');
-
- return $.ajax({
- url: url,
- type: ajaxType,
- dataType: 'json',
- data: {
- issuable_id: $this.data('issuable-id'),
- issuable_type: $this.data('issuable-type')
- },
- beforeSend: (function(_this) {
- return function() {
- $('.js-issuable-todo').disable()
- .addClass('is-loading');
- };
- })(this)
- }).done((function(_this) {
- return function(data) {
- return _this.todoUpdateDone(data);
- };
- })(this));
- };
-
- Sidebar.prototype.todoUpdateDone = function(data) {
- const deletePath = data.delete_path ? data.delete_path : null;
- const attrPrefix = deletePath ? 'mark' : 'todo';
- const $todoBtns = $('.js-issuable-todo');
-
- $(document).trigger('todo:toggle', data.count);
-
- $todoBtns.each((i, el) => {
- const $el = $(el);
- const $elText = $el.find('.js-issuable-todo-inner');
-
- $el.removeClass('is-loading')
- .enable()
- .attr('aria-label', $el.data(`${attrPrefix}-text`))
- .attr('data-delete-path', deletePath)
- .attr('title', $el.data(`${attrPrefix}-text`));
-
- if ($el.hasClass('has-tooltip')) {
- $el.tooltip('fixTitle');
- }
-
- if ($el.data(`${attrPrefix}-icon`)) {
- $elText.html($el.data(`${attrPrefix}-icon`));
- } else {
- $elText.text($el.data(`${attrPrefix}-text`));
- }
- });
- };
-
- Sidebar.prototype.sidebarDropdownLoading = function(e) {
- var $loading, $sidebarCollapsedIcon, i, img;
- $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon');
- img = $sidebarCollapsedIcon.find('img');
- i = $sidebarCollapsedIcon.find('i');
- $loading = $('<i class="fa fa-spinner fa-spin"></i>');
- if (img.length) {
- img.before($loading);
- return img.hide();
- } else if (i.length) {
- i.before($loading);
- return i.hide();
- }
- };
-
- Sidebar.prototype.sidebarDropdownLoaded = function(e) {
- var $sidebarCollapsedIcon, i, img;
- $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon');
- img = $sidebarCollapsedIcon.find('img');
- $sidebarCollapsedIcon.find('i.fa-spin').remove();
- i = $sidebarCollapsedIcon.find('i');
- if (img.length) {
- return img.show();
- } else {
- return i.show();
- }
- };
-
- Sidebar.prototype.sidebarCollapseClicked = function(e) {
- var $block, sidebar;
- if ($(e.currentTarget).hasClass('dont-change-state')) {
- return;
- }
- sidebar = e.data;
- e.preventDefault();
- $block = $(this).closest('.block');
- return sidebar.openDropdown($block);
- };
-
- Sidebar.prototype.openDropdown = function(blockOrName) {
- var $block;
- $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
- $block.find('.edit-link').trigger('click');
- if (!this.isOpen()) {
- this.setCollapseAfterUpdate($block);
- return this.toggleSidebar('open');
- }
- };
-
- Sidebar.prototype.setCollapseAfterUpdate = function($block) {
- $block.addClass('collapse-after-update');
- return $('.page-with-sidebar').addClass('with-overlay');
- };
-
- Sidebar.prototype.onSidebarDropdownHidden = function(e) {
- var $block, sidebar;
- sidebar = e.data;
- e.preventDefault();
- $block = $(this).closest('.block');
- return sidebar.sidebarDropdownHidden($block);
- };
-
- Sidebar.prototype.sidebarDropdownHidden = function($block) {
- if ($block.hasClass('collapse-after-update')) {
- $block.removeClass('collapse-after-update');
- $('.page-with-sidebar').removeClass('with-overlay');
- return this.toggleSidebar('hide');
- }
- };
-
- Sidebar.prototype.triggerOpenSidebar = function() {
- return this.sidebar.find('.js-sidebar-toggle').trigger('click');
- };
-
- Sidebar.prototype.toggleSidebar = function(action) {
- if (action == null) {
- action = 'toggle';
- }
- if (action === 'toggle') {
- this.triggerOpenSidebar();
- }
- if (action === 'open') {
- if (!this.isOpen()) {
- this.triggerOpenSidebar();
- }
- }
- if (action === 'hide') {
- if (this.isOpen()) {
- return this.triggerOpenSidebar();
- }
- }
- };
-
- Sidebar.prototype.setSidebarHeight = function() {
- const $navHeight = this.$navGitlab.outerHeight();
- const diff = $navHeight - $(window).scrollTop();
- if (diff > 0) {
- this.$rightSidebar.outerHeight($(window).height() - diff);
- this.$sidebarInner.height('100%');
- } else {
- this.$rightSidebar.outerHeight('100%');
- this.$sidebarInner.height('');
- }
- };
-
- Sidebar.prototype.isOpen = function() {
- return this.sidebar.is('.right-sidebar-expanded');
- };
-
- Sidebar.prototype.getBlock = function(name) {
- return this.sidebar.find(".block." + name);
- };
-
- return Sidebar;
- })();
-}).call(window);
+export default Sidebar;
diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js
deleted file mode 100644
index 05caf177aec..00000000000
--- a/app/assets/javascripts/search.js
+++ /dev/null
@@ -1,118 +0,0 @@
-/* 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 Flash */
-import Api from './api';
-
-(function() {
- this.Search = (function() {
- function Search() {
- 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,
- filterable: true,
- fieldName: 'group_id',
- search: {
- fields: ['full_name']
- },
- data: function(term, callback) {
- return Api.groups(term, {}, function(data) {
- data.unshift({
- full_name: 'Any'
- });
- data.splice(1, 0, 'divider');
- return callback(data);
- });
- },
- id: function(obj) {
- return obj.id;
- },
- text: function(obj) {
- return obj.full_name;
- },
- toggleLabel: function(obj) {
- return ($groupDropdown.data('default-label')) + " " + obj.full_name;
- },
- clicked: (function(_this) {
- return function() {
- return _this.submitSearch();
- };
- })(this)
- });
- $projectDropdown.glDropdown({
- selectable: true,
- filterable: true,
- fieldName: 'project_id',
- search: {
- fields: ['name']
- },
- 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;
- },
- text: function(obj) {
- return obj.name_with_namespace;
- },
- toggleLabel: function(obj) {
- return ($projectDropdown.data('default-label')) + " " + obj.name_with_namespace;
- },
- clicked: (function(_this) {
- return function() {
- return _this.submitSearch();
- };
- })(this)
- });
- }
-
- Search.prototype.eventListeners = function() {
- $(document).off('keyup', '.js-search-input').on('keyup', '.js-search-input', this.searchKeyUp);
- return $(document).off('click', '.js-search-clear').on('click', '.js-search-clear', this.clearSearchField);
- };
-
- Search.prototype.submitSearch = function() {
- return $('.js-search-form').submit();
- };
-
- Search.prototype.searchKeyUp = function() {
- var $input;
- $input = $(this);
- if ($input.val() === '') {
- return $('.js-search-clear').addClass('hidden');
- } else {
- return $('.js-search-clear').removeClass('hidden');
- }
- };
-
- Search.prototype.clearSearchField = function() {
- 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/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 6fd5345a0a6..7dd3e9858c6 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,432 +1,462 @@
-/* eslint-disable comma-dangle, no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, no-cond-assign, consistent-return, object-shorthand, prefer-arrow-callback, func-names, space-before-function-paren, prefer-template, quotes, class-methods-use-this, no-unused-expressions, no-sequences, wrap-iife, no-lonely-if, no-else-return, no-param-reassign, vars-on-top, max-len */
-
-((global) => {
- const KEYCODE = {
- ESCAPE: 27,
- BACKSPACE: 8,
- ENTER: 13,
- UP: 38,
- DOWN: 40
- };
-
- class SearchAutocomplete {
- constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
- this.bindEventContext();
- this.wrap = wrap || $('.search');
- this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
- this.autocompletePath = autocompletePath || this.optsEl.data('autocomplete-path');
- this.projectId = projectId || (this.optsEl.data('autocomplete-project-id') || '');
- this.projectRef = projectRef || (this.optsEl.data('autocomplete-project-ref') || '');
- this.dropdown = this.wrap.find('.dropdown');
- this.dropdownContent = this.dropdown.find('.dropdown-content');
- this.locationBadgeEl = this.getElement('.location-badge');
- this.scopeInputEl = this.getElement('#scope');
- this.searchInput = this.getElement('.search-input');
- this.projectInputEl = this.getElement('#search_project_id');
- this.groupInputEl = this.getElement('#group_id');
- this.searchCodeInputEl = this.getElement('#search_code');
- this.repositoryInputEl = this.getElement('#repository_ref');
- this.clearInput = this.getElement('.js-clear-input');
- this.saveOriginalState();
- // Only when user is logged in
- if (gon.current_user_id) {
- this.createAutocomplete();
- }
- this.searchInput.addClass('disabled');
- this.saveTextLength();
- this.bindEvents();
- }
+/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, no-cond-assign, consistent-return, object-shorthand, prefer-arrow-callback, func-names, space-before-function-paren, prefer-template, quotes, class-methods-use-this, no-sequences, wrap-iife, no-lonely-if, no-else-return, no-param-reassign, vars-on-top, max-len */
+
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import DropdownUtils from './filtered_search/dropdown_utils';
+import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils';
+
+/**
+ * Search input in top navigation bar.
+ * On click, opens a dropdown
+ * As the user types it filters the results
+ * When the user clicks `x` button it cleans the input and closes the dropdown.
+ */
+
+const KEYCODE = {
+ ESCAPE: 27,
+ BACKSPACE: 8,
+ ENTER: 13,
+ UP: 38,
+ DOWN: 40,
+};
+
+function setSearchOptions() {
+ var $projectOptionsDataEl = $('.js-search-project-options');
+ var $groupOptionsDataEl = $('.js-search-group-options');
+ var $dashboardOptionsDataEl = $('.js-search-dashboard-options');
+
+ if ($projectOptionsDataEl.length) {
+ gl.projectOptions = gl.projectOptions || {};
+
+ var projectPath = $projectOptionsDataEl.data('projectPath');
+
+ gl.projectOptions[projectPath] = {
+ name: $projectOptionsDataEl.data('name'),
+ issuesPath: $projectOptionsDataEl.data('issuesPath'),
+ issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'),
+ mrPath: $projectOptionsDataEl.data('mrPath'),
+ };
+ }
- // Finds an element inside wrapper element
- bindEventContext() {
- this.onSearchInputBlur = this.onSearchInputBlur.bind(this);
- this.onClearInputClick = this.onClearInputClick.bind(this);
- this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
- this.onSearchInputClick = this.onSearchInputClick.bind(this);
- this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
- this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
- }
- getElement(selector) {
- return this.wrap.find(selector);
- }
+ if ($groupOptionsDataEl.length) {
+ gl.groupOptions = gl.groupOptions || {};
- saveOriginalState() {
- return this.originalState = this.serializeState();
- }
+ var groupPath = $groupOptionsDataEl.data('groupPath');
- saveTextLength() {
- return this.lastTextLength = this.searchInput.val().length;
- }
+ gl.groupOptions[groupPath] = {
+ name: $groupOptionsDataEl.data('name'),
+ issuesPath: $groupOptionsDataEl.data('issuesPath'),
+ mrPath: $groupOptionsDataEl.data('mrPath'),
+ };
+ }
- createAutocomplete() {
- return this.searchInput.glDropdown({
- filterInputBlur: false,
- filterable: true,
- filterRemote: true,
- highlight: true,
- enterCallback: false,
- filterInput: 'input#search',
- search: {
- fields: ['text']
- },
- id: this.getSearchText,
- data: this.getData.bind(this),
- selectable: true,
- clicked: this.onClick.bind(this)
- });
+ if ($dashboardOptionsDataEl.length) {
+ gl.dashboardOptions = {
+ issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
+ mrPath: $dashboardOptionsDataEl.data('mrPath'),
+ };
+ }
+}
+
+export default class SearchAutocomplete {
+ constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
+ setSearchOptions();
+ this.bindEventContext();
+ this.wrap = wrap || $('.search');
+ this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
+ this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
+ this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || '');
+ this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
+ this.dropdown = this.wrap.find('.dropdown');
+ this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
+ this.dropdownContent = this.dropdown.find('.dropdown-content');
+ this.locationBadgeEl = this.getElement('.location-badge');
+ this.scopeInputEl = this.getElement('#scope');
+ this.searchInput = this.getElement('.search-input');
+ this.projectInputEl = this.getElement('#search_project_id');
+ this.groupInputEl = this.getElement('#group_id');
+ this.searchCodeInputEl = this.getElement('#search_code');
+ this.repositoryInputEl = this.getElement('#repository_ref');
+ this.clearInput = this.getElement('.js-clear-input');
+ this.saveOriginalState();
+
+ // Only when user is logged in
+ if (gon.current_user_id) {
+ this.createAutocomplete();
}
- getSearchText(selectedObject, el) {
- return selectedObject.id ? selectedObject.text : '';
- }
+ this.searchInput.addClass('disabled');
+ this.saveTextLength();
+ this.bindEvents();
+ this.dropdownToggle.dropdown();
+ }
- getData(term, callback) {
- var _this, contents, jqXHR;
- _this = this;
- if (!term) {
- if (contents = this.getCategoryContents()) {
- this.searchInput.data('glDropdown').filter.options.callback(contents);
- this.enableAutocomplete();
- }
- return;
- }
- // Prevent multiple ajax calls
- if (this.loadingSuggestions) {
- return;
- }
- this.loadingSuggestions = true;
- return jqXHR = $.get(this.autocompletePath, {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term: term
- }, function(response) {
- var data, firstCategory, i, lastCategory, len, suggestion;
- // Hide dropdown menu if no suggestions returns
- if (!response.length) {
- _this.disableAutocomplete();
- return;
- }
- data = [];
- // List results
- firstCategory = true;
- for (i = 0, len = response.length; i < len; i += 1) {
- suggestion = response[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- if (!firstCategory) {
- data.push('separator');
- }
- if (firstCategory) {
- firstCategory = false;
- }
- data.push({
- header: suggestion.category
- });
- lastCategory = suggestion.category;
- }
- data.push({
- id: (suggestion.category.toLowerCase()) + "-" + suggestion.id,
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url
- });
- }
- // Add option to proceed with the search
- if (data.length) {
- data.push('separator');
- data.push({
- text: "Result name contains \"" + term + "\"",
- url: "/search?search=" + term + "&project_id=" + (_this.projectInputEl.val()) + "&group_id=" + (_this.groupInputEl.val())
- });
- }
- return callback(data);
- }).always(function() {
- return _this.loadingSuggestions = false;
- });
- }
+ // Finds an element inside wrapper element
+ bindEventContext() {
+ this.onSearchInputBlur = this.onSearchInputBlur.bind(this);
+ this.onClearInputClick = this.onClearInputClick.bind(this);
+ this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
+ this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
+ this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
+ }
+ getElement(selector) {
+ return this.wrap.find(selector);
+ }
- getCategoryContents() {
- var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, userName, utils;
- userId = gon.current_user_id;
- userName = gon.current_username;
- utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions;
- if (utils.isInGroupsPage() && groupOptions) {
- options = groupOptions[utils.getGroupSlug()];
- } else if (utils.isInProjectPage() && projectOptions) {
- options = projectOptions[utils.getProjectSlug()];
- } else if (dashboardOptions) {
- options = dashboardOptions;
- }
- issuesPath = options.issuesPath, mrPath = options.mrPath, name = options.name;
- items = [
- {
- header: "" + name
- }, {
- text: 'Issues assigned to me',
- url: issuesPath + "/?assignee_username=" + userName
- }, {
- text: "Issues I've created",
- url: issuesPath + "/?author_username=" + userName
- }, 'separator', {
- text: 'Merge requests assigned to me',
- url: mrPath + "/?assignee_username=" + userName
- }, {
- text: "Merge requests I've created",
- url: mrPath + "/?author_username=" + userName
- }
- ];
- if (!name) {
- items.splice(0, 1);
+ saveOriginalState() {
+ return this.originalState = this.serializeState();
+ }
+
+ saveTextLength() {
+ return this.lastTextLength = this.searchInput.val().length;
+ }
+
+ createAutocomplete() {
+ return this.searchInput.glDropdown({
+ filterInputBlur: false,
+ filterable: true,
+ filterRemote: true,
+ highlight: true,
+ enterCallback: false,
+ filterInput: 'input#search',
+ search: {
+ fields: ['text'],
+ },
+ id: this.getSearchText,
+ data: this.getData.bind(this),
+ selectable: true,
+ clicked: this.onClick.bind(this),
+ });
+ }
+
+ getSearchText(selectedObject, el) {
+ return selectedObject.id ? selectedObject.text : '';
+ }
+
+ getData(term, callback) {
+ if (!term) {
+ const contents = this.getCategoryContents();
+ if (contents) {
+ this.searchInput.data('glDropdown').filter.options.callback(contents);
+ this.enableAutocomplete();
}
- return items;
+ return;
}
- serializeState() {
- return {
- // Search Criteria
- search_project_id: this.projectInputEl.val(),
- group_id: this.groupInputEl.val(),
- search_code: this.searchCodeInputEl.val(),
- repository_ref: this.repositoryInputEl.val(),
- scope: this.scopeInputEl.val(),
- // Location badge
- _location: this.locationBadgeEl.text()
- };
+ // Prevent multiple ajax calls
+ if (this.loadingSuggestions) {
+ return;
}
- bindEvents() {
- this.searchInput.on('keydown', this.onSearchInputKeyDown);
- this.searchInput.on('keyup', this.onSearchInputKeyUp);
- this.searchInput.on('click', this.onSearchInputClick);
- this.searchInput.on('focus', this.onSearchInputFocus);
- this.searchInput.on('blur', this.onSearchInputBlur);
- this.clearInput.on('click', this.onClearInputClick);
- return this.locationBadgeEl.on('click', (function(_this) {
- return function() {
- return _this.searchInput.focus();
- };
- })(this));
- }
+ this.loadingSuggestions = true;
- enableAutocomplete() {
- var _this;
- // No need to enable anything if user is not logged in
- if (!gon.current_user_id) {
+ return axios.get(this.autocompletePath, {
+ params: {
+ project_id: this.projectId,
+ project_ref: this.projectRef,
+ term: term,
+ },
+ }).then((response) => {
+ // Hide dropdown menu if no suggestions returns
+ if (!response.data.length) {
+ this.disableAutocomplete();
return;
}
- if (!this.dropdown.hasClass('open')) {
- _this = this;
- this.loadingSuggestions = false;
- this.dropdown.addClass('open').trigger('shown.bs.dropdown');
- return this.searchInput.removeClass('disabled');
- }
- }
-
- // Saves last length of the entered text
- onSearchInputKeyDown() {
- return this.saveTextLength();
- }
- onSearchInputKeyUp(e) {
- switch (e.keyCode) {
- case KEYCODE.BACKSPACE:
- // when trying to remove the location badge
- if (this.lastTextLength === 0 && this.badgePresent()) {
- this.removeLocationBadge();
- }
- // When removing the last character and no badge is present
- if (this.lastTextLength === 1) {
- this.disableAutocomplete();
+ const data = [];
+ // List results
+ let firstCategory = true;
+ let lastCategory;
+ for (let i = 0, len = response.data.length; i < len; i += 1) {
+ const suggestion = response.data[i];
+ // Add group header before list each group
+ if (lastCategory !== suggestion.category) {
+ if (!firstCategory) {
+ data.push('separator');
}
- // When removing any character from existin value
- if (this.lastTextLength > 1) {
- this.enableAutocomplete();
- }
- break;
- case KEYCODE.ESCAPE:
- this.restoreOriginalState();
- break;
- case KEYCODE.ENTER:
- this.disableAutocomplete();
- break;
- case KEYCODE.UP:
- case KEYCODE.DOWN:
- return;
- default:
- // Handle the case when deleting the input value other than backspace
- // e.g. Pressing ctrl + backspace or ctrl + x
- if (this.searchInput.val() === '') {
- this.disableAutocomplete();
- } else {
- // We should display the menu only when input is not empty
- if (e.keyCode !== KEYCODE.ENTER) {
- this.enableAutocomplete();
- }
+ if (firstCategory) {
+ firstCategory = false;
}
+ data.push({
+ header: suggestion.category,
+ });
+ lastCategory = suggestion.category;
+ }
+ data.push({
+ id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
+ category: suggestion.category,
+ text: suggestion.label,
+ url: suggestion.url,
+ });
+ }
+ // Add option to proceed with the search
+ if (data.length) {
+ data.push('separator');
+ data.push({
+ text: `Result name contains "${term}"`,
+ url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
+ });
}
- this.wrap.toggleClass('has-value', !!e.target.value);
- }
- // Avoid falsy value to be returned
- onSearchInputClick(e) {
- return e.stopImmediatePropagation();
- }
+ callback(data);
- onSearchInputFocus() {
- this.isFocused = true;
- this.wrap.addClass('search-active');
- if (this.getValue() === '') {
- return this.getData();
- }
- }
+ this.loadingSuggestions = false;
+ }).catch(() => {
+ this.loadingSuggestions = false;
+ });
+ }
- getValue() {
- return this.searchInput.val();
+ getCategoryContents() {
+ const userId = gon.current_user_id;
+ const userName = gon.current_username;
+ const { projectOptions, groupOptions, dashboardOptions } = gl;
+
+ // Get options
+ let options;
+ if (isInGroupsPage() && groupOptions) {
+ options = groupOptions[getGroupSlug()];
+ } else if (isInProjectPage() && projectOptions) {
+ options = projectOptions[getProjectSlug()];
+ } else if (dashboardOptions) {
+ options = dashboardOptions;
}
- onClearInputClick(e) {
- e.preventDefault();
- return this.searchInput.val('').focus();
- }
+ const { issuesPath, mrPath, name, issuesDisabled } = options;
+ const baseItems = [];
- onSearchInputBlur(e) {
- this.isFocused = false;
- this.wrap.removeClass('search-active');
- // If input is blank then restore state
- if (this.searchInput.val() === '') {
- return this.restoreOriginalState();
- }
+ if (name) {
+ baseItems.push({
+ header: `${name}`,
+ });
}
- addLocationBadge(item) {
- var badgeText, category, value;
- category = item.category != null ? item.category + ": " : '';
- value = item.value != null ? item.value : '';
- badgeText = "" + category + value;
- this.locationBadgeEl.text(badgeText).show();
- return this.wrap.addClass('has-location-badge');
+ const issueItems = [
+ {
+ text: 'Issues assigned to me',
+ url: `${issuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: "Issues I've created",
+ url: `${issuesPath}/?author_username=${userName}`,
+ },
+ ];
+ const mergeRequestItems = [
+ {
+ text: 'Merge requests assigned to me',
+ url: `${mrPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: "Merge requests I've created",
+ url: `${mrPath}/?author_username=${userName}`,
+ },
+ ];
+
+ let items;
+ if (issuesDisabled) {
+ items = baseItems.concat(mergeRequestItems);
+ } else {
+ items = baseItems.concat(...issueItems, 'separator', ...mergeRequestItems);
}
+ return items;
+ }
- hasLocationBadge() {
- return this.wrap.is('.has-location-badge');
- }
+ serializeState() {
+ return {
+ // Search Criteria
+ search_project_id: this.projectInputEl.val(),
+ group_id: this.groupInputEl.val(),
+ search_code: this.searchCodeInputEl.val(),
+ repository_ref: this.repositoryInputEl.val(),
+ scope: this.scopeInputEl.val(),
+ // Location badge
+ _location: this.locationBadgeEl.text(),
+ };
+ }
- restoreOriginalState() {
- var i, input, inputs, len;
- inputs = Object.keys(this.originalState);
- for (i = 0, len = inputs.length; i < len; i += 1) {
- input = inputs[i];
- this.getElement("#" + input).val(this.originalState[input]);
- }
- if (this.originalState._location === '') {
- return this.locationBadgeEl.hide();
- } else {
- return this.addLocationBadge({
- value: this.originalState._location
- });
- }
+ bindEvents() {
+ this.searchInput.on('keydown', this.onSearchInputKeyDown);
+ this.searchInput.on('keyup', this.onSearchInputKeyUp);
+ this.searchInput.on('focus', this.onSearchInputFocus);
+ this.searchInput.on('blur', this.onSearchInputBlur);
+ this.clearInput.on('click', this.onClearInputClick);
+ this.locationBadgeEl.on('click', () => this.searchInput.focus());
+ }
+
+ enableAutocomplete() {
+ // No need to enable anything if user is not logged in
+ if (!gon.current_user_id) {
+ return;
}
- badgePresent() {
- return this.locationBadgeEl.length;
+ // If the dropdown is closed, we'll open it
+ if (!this.dropdown.hasClass('open')) {
+ this.loadingSuggestions = false;
+ this.dropdownToggle.dropdown('toggle');
+ return this.searchInput.removeClass('disabled');
}
+ }
- resetSearchState() {
- var i, input, inputs, len, results;
- inputs = Object.keys(this.originalState);
- results = [];
- for (i = 0, len = inputs.length; i < len; i += 1) {
- input = inputs[i];
- // _location isnt a input
- if (input === '_location') {
- break;
+ // Saves last length of the entered text
+ onSearchInputKeyDown() {
+ return this.saveTextLength();
+ }
+
+ onSearchInputKeyUp(e) {
+ switch (e.keyCode) {
+ case KEYCODE.BACKSPACE:
+ // when trying to remove the location badge
+ if (this.lastTextLength === 0 && this.badgePresent()) {
+ this.removeLocationBadge();
+ }
+ // When removing the last character and no badge is present
+ if (this.lastTextLength === 1) {
+ this.disableAutocomplete();
+ }
+ // When removing any character from existin value
+ if (this.lastTextLength > 1) {
+ this.enableAutocomplete();
+ }
+ break;
+ case KEYCODE.ESCAPE:
+ this.restoreOriginalState();
+ break;
+ case KEYCODE.ENTER:
+ this.disableAutocomplete();
+ break;
+ case KEYCODE.UP:
+ case KEYCODE.DOWN:
+ return;
+ default:
+ // Handle the case when deleting the input value other than backspace
+ // e.g. Pressing ctrl + backspace or ctrl + x
+ if (this.searchInput.val() === '') {
+ this.disableAutocomplete();
+ } else {
+ // We should display the menu only when input is not empty
+ if (e.keyCode !== KEYCODE.ENTER) {
+ this.enableAutocomplete();
+ }
}
- results.push(this.getElement("#" + input).val(''));
- }
- return results;
}
+ this.wrap.toggleClass('has-value', !!e.target.value);
+ }
- removeLocationBadge() {
- this.locationBadgeEl.hide();
- this.resetSearchState();
- this.wrap.removeClass('has-location-badge');
- return this.disableAutocomplete();
+ onSearchInputFocus() {
+ this.isFocused = true;
+ this.wrap.addClass('search-active');
+ if (this.getValue() === '') {
+ return this.getData();
}
+ }
- disableAutocomplete() {
- if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) {
- this.searchInput.addClass('disabled');
- this.dropdown.removeClass('open').trigger('hidden.bs.dropdown');
- this.restoreMenu();
- }
- }
+ getValue() {
+ return this.searchInput.val();
+ }
- restoreMenu() {
- var html;
- html = "<ul> <li><a class='dropdown-menu-empty-link is-focused'>Loading...</a></li> </ul>";
- return this.dropdownContent.html(html);
- }
+ onClearInputClick(e) {
+ e.preventDefault();
+ this.wrap.toggleClass('has-value', !!e.target.value);
+ return this.searchInput.val('').focus();
+ }
- onClick(item, $el, e) {
- if (location.pathname.indexOf(item.url) !== -1) {
- if (!e.metaKey) e.preventDefault();
- if (!this.badgePresent) {
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- this.addLocationBadge({
- value: 'This project'
- });
- }
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- this.addLocationBadge({
- value: 'This group'
- });
- }
- }
- $el.removeClass('is-active');
- this.disableAutocomplete();
- return this.searchInput.val('').focus();
- }
+ onSearchInputBlur(e) {
+ this.isFocused = false;
+ this.wrap.removeClass('search-active');
+ // If input is blank then restore state
+ if (this.searchInput.val() === '') {
+ return this.restoreOriginalState();
}
}
- global.SearchAutocomplete = SearchAutocomplete;
+ addLocationBadge(item) {
+ var badgeText, category, value;
+ category = item.category != null ? item.category + ": " : '';
+ value = item.value != null ? item.value : '';
+ badgeText = "" + category + value;
+ this.locationBadgeEl.text(badgeText).show();
+ return this.wrap.addClass('has-location-badge');
+ }
- $(function() {
- var $projectOptionsDataEl = $('.js-search-project-options');
- var $groupOptionsDataEl = $('.js-search-group-options');
- var $dashboardOptionsDataEl = $('.js-search-dashboard-options');
+ hasLocationBadge() {
+ return this.wrap.is('.has-location-badge');
+ }
- if ($projectOptionsDataEl.length) {
- gl.projectOptions = gl.projectOptions || {};
+ restoreOriginalState() {
+ var i, input, inputs, len;
+ inputs = Object.keys(this.originalState);
+ for (i = 0, len = inputs.length; i < len; i += 1) {
+ input = inputs[i];
+ this.getElement("#" + input).val(this.originalState[input]);
+ }
+ if (this.originalState._location === '') {
+ return this.locationBadgeEl.hide();
+ } else {
+ return this.addLocationBadge({
+ value: this.originalState._location,
+ });
+ }
+ }
- var projectPath = $projectOptionsDataEl.data('project-path');
+ badgePresent() {
+ return this.locationBadgeEl.length;
+ }
- gl.projectOptions[projectPath] = {
- name: $projectOptionsDataEl.data('name'),
- issuesPath: $projectOptionsDataEl.data('issues-path'),
- mrPath: $projectOptionsDataEl.data('mr-path')
- };
+ resetSearchState() {
+ var i, input, inputs, len, results;
+ inputs = Object.keys(this.originalState);
+ results = [];
+ for (i = 0, len = inputs.length; i < len; i += 1) {
+ input = inputs[i];
+ // _location isnt a input
+ if (input === '_location') {
+ break;
+ }
+ results.push(this.getElement("#" + input).val(''));
}
+ return results;
+ }
- if ($groupOptionsDataEl.length) {
- gl.groupOptions = gl.groupOptions || {};
-
- var groupPath = $groupOptionsDataEl.data('group-path');
+ removeLocationBadge() {
+ this.locationBadgeEl.hide();
+ this.resetSearchState();
+ this.wrap.removeClass('has-location-badge');
+ return this.disableAutocomplete();
+ }
- gl.groupOptions[groupPath] = {
- name: $groupOptionsDataEl.data('name'),
- issuesPath: $groupOptionsDataEl.data('issues-path'),
- mrPath: $groupOptionsDataEl.data('mr-path')
- };
+ disableAutocomplete() {
+ if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) {
+ this.searchInput.addClass('disabled');
+ this.dropdown.removeClass('open').trigger('hidden.bs.dropdown');
+ this.restoreMenu();
}
+ }
+
+ restoreMenu() {
+ var html;
+ html = '<ul><li class="dropdown-menu-empty-item"><a>Loading...</a></li></ul>';
+ return this.dropdownContent.html(html);
+ }
- if ($dashboardOptionsDataEl.length) {
- gl.dashboardOptions = {
- issuesPath: $dashboardOptionsDataEl.data('issues-path'),
- mrPath: $dashboardOptionsDataEl.data('mr-path')
- };
+ onClick(item, $el, e) {
+ if (location.pathname.indexOf(item.url) !== -1) {
+ if (!e.metaKey) e.preventDefault();
+ if (!this.badgePresent) {
+ if (item.category === 'Projects') {
+ this.projectInputEl.val(item.id);
+ this.addLocationBadge({
+ value: 'This project',
+ });
+ }
+ if (item.category === 'Groups') {
+ this.groupInputEl.val(item.id);
+ this.addLocationBadge({
+ value: 'This group',
+ });
+ }
+ }
+ $el.removeClass('is-active');
+ this.disableAutocomplete();
+ return this.searchInput.val('').focus();
}
- });
-})(window.gl || (window.gl = {}));
+ }
+}
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
index 7fa5996d600..eecde4550f9 100644
--- a/app/assets/javascripts/settings_panels.js
+++ b/app/assets/javascripts/settings_panels.js
@@ -1,34 +1,28 @@
-function expandSectionParent($section, $content) {
- $section.addClass('expanded');
- $content.off('animationend.expandSectionParent');
-}
+import $ from 'jquery';
function expandSection($section) {
$section.find('.js-settings-toggle').text('Collapse');
-
- const $content = $section.find('.settings-content');
- $content.addClass('expanded').off('scroll.expandSection').scrollTop(0);
-
- if ($content.hasClass('no-animate')) {
- expandSectionParent($section, $content);
- } else {
- $content.on('animationend.expandSectionParent', () => expandSectionParent($section, $content));
+ $section.find('.settings-content').off('scroll.expandSection').scrollTop(0);
+ $section.addClass('expanded');
+ if (!$section.hasClass('no-animate')) {
+ $section.addClass('animating')
+ .one('animationend.animateSection', () => $section.removeClass('animating'));
}
}
function closeSection($section) {
$section.find('.js-settings-toggle').text('Expand');
-
- const $content = $section.find('.settings-content');
- $content.removeClass('expanded').on('scroll.expandSection', () => expandSection($section));
-
+ $section.find('.settings-content').on('scroll.expandSection', () => expandSection($section));
$section.removeClass('expanded');
+ if (!$section.hasClass('no-animate')) {
+ $section.addClass('animating')
+ .one('animationend.animateSection', () => $section.removeClass('animating'));
+ }
}
function toggleSection($section) {
- const $content = $section.find('.settings-content');
- $content.removeClass('no-animate');
- if ($content.hasClass('expanded')) {
+ $section.removeClass('no-animate');
+ if ($section.hasClass('expanded')) {
closeSection($section);
} else {
expandSection($section);
@@ -39,6 +33,19 @@ export default function initSettingsPanels() {
$('.settings').each((i, elm) => {
const $section = $(elm);
$section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section));
- $section.find('.settings-content:not(.expanded)').on('scroll.expandSection', () => expandSection($section));
+
+ if (!$section.hasClass('expanded')) {
+ $section.find('.settings-content').on('scroll.expandSection', () => {
+ $section.removeClass('no-animate');
+ expandSection($section);
+ });
+ }
});
+
+ if (location.hash) {
+ const $target = $(location.hash);
+ if ($target.length && $target.hasClass('settings')) {
+ expandSection($target);
+ }
+ }
}
diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js
new file mode 100644
index 00000000000..2f974d6ff9d
--- /dev/null
+++ b/app/assets/javascripts/shared/milestones/form.js
@@ -0,0 +1,10 @@
+import $ from 'jquery';
+import ZenMode from '../../zen_mode';
+import DueDateSelectors from '../../due_date_select';
+import GLForm from '../../gl_form';
+
+export default (initGFM = true) => {
+ new ZenMode(); // eslint-disable-line no-new
+ new DueDateSelectors(); // eslint-disable-line no-new
+ new GLForm($('.milestone-form'), initGFM); // eslint-disable-line no-new
+};
diff --git a/app/assets/javascripts/shared/sessions/u2f.js b/app/assets/javascripts/shared/sessions/u2f.js
new file mode 100644
index 00000000000..6ae9faf1dde
--- /dev/null
+++ b/app/assets/javascripts/shared/sessions/u2f.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import U2FAuthenticate from '../../u2f/authenticate';
+
+export default () => {
+ if (!gon.u2f) return;
+
+ const u2fAuthenticate = new U2FAuthenticate(
+ $('#js-authenticate-u2f'),
+ '#js-login-u2f-form',
+ gon.u2f,
+ document.querySelector('#js-login-2fa-device'),
+ document.querySelector('.js-2fa-form'),
+ );
+ u2fAuthenticate.start();
+ // needed in rspec
+ gl.u2fAuthenticate = u2fAuthenticate;
+};
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index a4a7f3fa944..e31e067033f 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -1,142 +1,123 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, prefer-arrow-callback, consistent-return, object-shorthand, no-unused-vars, one-var, one-var-declaration-per-line, no-else-return, comma-dangle, max-len */
-/* global Mousetrap */
-/* global findFileURL */
+import $ from 'jquery';
import Cookies from 'js-cookie';
-
+import Mousetrap from 'mousetrap';
+import axios from './lib/utils/axios_utils';
+import { refreshCurrentPage, visitUrl } from './lib/utils/url_utility';
import findAndFollowLink from './shortcuts_dashboard_navigation';
-(function() {
- this.Shortcuts = (function() {
- function Shortcuts(skipResetBindings) {
- this.onToggleHelp = this.onToggleHelp.bind(this);
- this.enabledHelp = [];
- if (!skipResetBindings) {
- Mousetrap.reset();
- }
- Mousetrap.bind('?', this.onToggleHelp);
- Mousetrap.bind('s', Shortcuts.focusSearch);
- Mousetrap.bind('f', (e => this.focusFilter(e)));
- Mousetrap.bind('p b', this.onTogglePerfBar);
+const defaultStopCallback = Mousetrap.stopCallback;
+Mousetrap.stopCallback = (e, element, combo) => {
+ if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) {
+ return false;
+ }
+
+ return defaultStopCallback(e, element, combo);
+};
+
+export default class Shortcuts {
+ constructor() {
+ this.onToggleHelp = this.onToggleHelp.bind(this);
+ this.enabledHelp = [];
+
+ Mousetrap.bind('?', this.onToggleHelp);
+ Mousetrap.bind('s', Shortcuts.focusSearch);
+ Mousetrap.bind('f', this.focusFilter.bind(this));
+ Mousetrap.bind('p b', Shortcuts.onTogglePerfBar);
+
+ const findFileURL = document.body.dataset.findFile;
+
+ Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
+ Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
+ Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
+ Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
+ Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
+ Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
+ Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
+
+ Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], Shortcuts.toggleMarkdownPreview);
+
+ if (typeof findFileURL !== 'undefined' && findFileURL !== null) {
+ Mousetrap.bind('t', () => {
+ visitUrl(findFileURL);
+ });
+ }
- const $globalDropdownMenu = $('.global-dropdown-menu');
- const $globalDropdownToggle = $('.global-dropdown-toggle');
+ $(document).on('click.more_help', '.js-more-help-button', function clickMoreHelp(e) {
+ $(this).remove();
+ $('.hidden-shortcut').show();
+ e.preventDefault();
+ });
+ }
- $('.global-dropdown').on('hide.bs.dropdown', () => {
- $globalDropdownMenu.removeClass('shortcuts');
- });
+ onToggleHelp(e) {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
- Mousetrap.bind('n', () => {
- $globalDropdownMenu.toggleClass('shortcuts');
- $globalDropdownToggle.trigger('click');
+ Shortcuts.toggleHelp(this.enabledHelp);
+ }
- if (!$globalDropdownMenu.is(':visible')) {
- $globalDropdownToggle.blur();
- }
- });
+ static onTogglePerfBar(e) {
+ e.preventDefault();
+ const performanceBarCookieName = 'perf_bar_enabled';
+ if (Cookies.get(performanceBarCookieName) === 'true') {
+ Cookies.set(performanceBarCookieName, 'false', { path: '/' });
+ } else {
+ Cookies.set(performanceBarCookieName, 'true', { path: '/' });
+ }
+ refreshCurrentPage();
+ }
- Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
- Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
- Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
- Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
- Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
- Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
- Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
-
- Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], this.toggleMarkdownPreview);
- if (typeof findFileURL !== "undefined" && findFileURL !== null) {
- Mousetrap.bind('t', function() {
- return gl.utils.visitUrl(findFileURL);
- });
- }
+ static toggleMarkdownPreview(e) {
+ // Check if short-cut was triggered while in Write Mode
+ const $target = $(e.target);
+ const $form = $target.closest('form');
+
+ if ($target.hasClass('js-note-text')) {
+ $('.js-md-preview-button', $form).focus();
}
+ $(document).triggerHandler('markdown-preview:toggle', [e]);
+ }
- Shortcuts.prototype.onToggleHelp = function(e) {
- e.preventDefault();
- return Shortcuts.toggleHelp(this.enabledHelp);
- };
+ static toggleHelp(location) {
+ const $modal = $('#modal-shortcuts');
- Shortcuts.prototype.onTogglePerfBar = function(e) {
- e.preventDefault();
- const performanceBarCookieName = 'perf_bar_enabled';
- if (Cookies.get(performanceBarCookieName) === 'true') {
- Cookies.remove(performanceBarCookieName, { path: '/' });
- } else {
- Cookies.set(performanceBarCookieName, true, { path: '/' });
- }
- gl.utils.refreshCurrentPage();
- };
+ if ($modal.length) {
+ $modal.modal('toggle');
+ }
- Shortcuts.prototype.toggleMarkdownPreview = function(e) {
- // Check if short-cut was triggered while in Write Mode
- const $target = $(e.target);
- const $form = $target.closest('form');
+ return axios.get(gon.shortcuts_path, {
+ responseType: 'text',
+ }).then(({ data }) => {
+ $.globalEval(data);
- if ($target.hasClass('js-note-text')) {
- $('.js-md-preview-button', $form).focus();
- }
- return $(document).triggerHandler('markdown-preview:toggle', [e]);
- };
-
- Shortcuts.toggleHelp = function(location) {
- var $modal;
- $modal = $('#modal-shortcuts');
- if ($modal.length) {
- $modal.modal('toggle');
- return;
- }
- return $.ajax({
- url: gon.shortcuts_path,
- dataType: 'script',
- success: function(e) {
- var i, l, len, results;
- if (location && location.length > 0) {
- results = [];
- for (i = 0, len = location.length; i < len; i += 1) {
- l = location[i];
- results.push($(l).show());
- }
- return results;
- } else {
- $('.hidden-shortcut').show();
- return $('.js-more-help-button').remove();
- }
+ if (location && location.length > 0) {
+ const results = [];
+ for (let i = 0, len = location.length; i < len; i += 1) {
+ results.push($(location[i]).show());
}
- });
- };
-
- Shortcuts.prototype.focusFilter = function(e) {
- if (this.filterInput == null) {
- this.filterInput = $('input[type=search]', '.nav-controls');
- }
- this.filterInput.focus();
- return e.preventDefault();
- };
-
- Shortcuts.focusSearch = function(e) {
- $('#search').focus();
- return e.preventDefault();
- };
-
- return Shortcuts;
- })();
-
- $(document).on('click.more_help', '.js-more-help-button', function(e) {
- $(this).remove();
- $('.hidden-shortcut').show();
- return e.preventDefault();
- });
-
- Mousetrap.stopCallback = (function() {
- var defaultStopCallback;
- defaultStopCallback = Mousetrap.stopCallback;
- return function(e, element, combo) {
- // allowed shortcuts if textarea, input, contenteditable are focused
- if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) {
- return false;
- } else {
- return defaultStopCallback.apply(this, arguments);
+ return results;
}
- };
- })();
-}).call(window);
+
+ $('.hidden-shortcut').show();
+ return $('.js-more-help-button').remove();
+ });
+ }
+
+ focusFilter(e) {
+ if (!this.filterInput) {
+ this.filterInput = $('input[type=search]', '.nav-controls');
+ }
+ this.filterInput.focus();
+ e.preventDefault();
+ }
+
+ static focusSearch(e) {
+ $('#search').focus();
+
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js
index ccbf7c59165..908b9cab93d 100644
--- a/app/assets/javascripts/shortcuts_blob.js
+++ b/app/assets/javascripts/shortcuts_blob.js
@@ -1,7 +1,6 @@
-/* global Mousetrap */
-/* global Shortcuts */
-
-import './shortcuts';
+import Mousetrap from 'mousetrap';
+import { getLocationHash, visitUrl } from './lib/utils/url_utility';
+import Shortcuts from './shortcuts';
const defaults = {
skipResetBindings: false,
@@ -19,9 +18,9 @@ export default class ShortcutsBlob extends Shortcuts {
moveToFilePermalink() {
if (this.options.fileBlobPermalinkUrl) {
- const hash = gl.utils.getLocationHash();
+ const hash = getLocationHash();
const hashUrlString = hash ? `#${hash}` : '';
- gl.utils.visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`);
+ visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`);
}
}
}
diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js
index b18b6139b35..1e246a56b85 100644
--- a/app/assets/javascripts/shortcuts_find_file.js
+++ b/app/assets/javascripts/shortcuts_find_file.js
@@ -1,38 +1,29 @@
-/* eslint-disable func-names, space-before-function-paren, max-len, one-var, no-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife */
-/* global Mousetrap */
-/* global ShortcutsNavigation */
+import Mousetrap from 'mousetrap';
+import ShortcutsNavigation from './shortcuts_navigation';
-import './shortcuts_navigation';
+export default class ShortcutsFindFile extends ShortcutsNavigation {
+ constructor(projectFindFile) {
+ super();
-(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; },
- hasProp = {}.hasOwnProperty;
+ const oldStopCallback = Mousetrap.stopCallback;
+ this.projectFindFile = projectFindFile;
- this.ShortcutsFindFile = (function(superClass) {
- extend(ShortcutsFindFile, superClass);
+ Mousetrap.stopCallback = (e, element, combo) => {
+ if (
+ element === this.projectFindFile.inputElement[0] &&
+ (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')
+ ) {
+ // when press up/down key in textbox, cusor prevent to move to home/end
+ event.preventDefault();
+ return false;
+ }
- function ShortcutsFindFile(projectFindFile) {
- var _oldStopCallback;
- this.projectFindFile = projectFindFile;
- ShortcutsFindFile.__super__.constructor.call(this);
- _oldStopCallback = Mousetrap.stopCallback;
- Mousetrap.stopCallback = (function(_this) {
- // override to fire shortcuts action when focus in textbox
- return function(event, element, combo) {
- if (element === _this.projectFindFile.inputElement[0] && (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')) {
- // when press up/down key in textbox, cusor prevent to move to home/end
- event.preventDefault();
- return false;
- }
- return _oldStopCallback(event, element, combo);
- };
- })(this);
- Mousetrap.bind('up', this.projectFindFile.selectRowUp);
- Mousetrap.bind('down', this.projectFindFile.selectRowDown);
- Mousetrap.bind('esc', this.projectFindFile.goToTree);
- Mousetrap.bind('enter', this.projectFindFile.goToBlob);
- }
+ return oldStopCallback(e, element, combo);
+ };
- return ShortcutsFindFile;
- })(ShortcutsNavigation);
-}).call(window);
+ Mousetrap.bind('up', this.projectFindFile.selectRowUp);
+ Mousetrap.bind('down', this.projectFindFile.selectRowDown);
+ Mousetrap.bind('esc', this.projectFindFile.goToTree);
+ Mousetrap.bind('enter', this.projectFindFile.goToBlob);
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index 51448252c0f..193788f754f 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -1,92 +1,73 @@
-/* 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, one-var-declaration-per-line, quotes, prefer-arrow-callback, consistent-return, prefer-template, no-mixed-operators */
-/* global Mousetrap */
-/* global ShortcutsNavigation */
-/* global sidebar */
-
-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; },
- hasProp = {}.hasOwnProperty;
-
- this.ShortcutsIssuable = (function(superClass) {
- extend(ShortcutsIssuable, superClass);
-
- function ShortcutsIssuable(isMergeRequest) {
- ShortcutsIssuable.__super__.constructor.call(this);
- Mousetrap.bind('a', this.openSidebarDropdown.bind(this, 'assignee'));
- Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
- Mousetrap.bind('r', (function(_this) {
- return function() {
- _this.replyWithSelectedText();
- return false;
- };
- })(this));
- Mousetrap.bind('e', (function(_this) {
- return function() {
- _this.editIssue();
- return false;
- };
- })(this));
- Mousetrap.bind('l', this.openSidebarDropdown.bind(this, 'labels'));
- if (isMergeRequest) {
- this.enabledHelp.push('.hidden-shortcut.merge_requests');
- } else {
- this.enabledHelp.push('.hidden-shortcut.issues');
- }
+import $ from 'jquery';
+import Mousetrap from 'mousetrap';
+import _ from 'underscore';
+import Sidebar from './right_sidebar';
+import Shortcuts from './shortcuts';
+import { CopyAsGFM } from './behaviors/markdown/copy_as_gfm';
+
+export default class ShortcutsIssuable extends Shortcuts {
+ constructor(isMergeRequest) {
+ super();
+
+ this.$replyField = isMergeRequest ? $('.js-main-target-form #note_note') : $('.js-main-target-form .js-vue-comment-form');
+
+ Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee'));
+ Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone'));
+ Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
+ Mousetrap.bind('r', this.replyWithSelectedText.bind(this));
+ Mousetrap.bind('e', ShortcutsIssuable.editIssue);
+
+ if (isMergeRequest) {
+ this.enabledHelp.push('.hidden-shortcut.merge_requests');
+ } else {
+ this.enabledHelp.push('.hidden-shortcut.issues');
}
+ }
+
+ replyWithSelectedText() {
+ const documentFragment = window.gl.utils.getSelectedFragment();
+
+ if (!documentFragment) {
+ this.$replyField.focus();
+ return false;
+ }
+
+ const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
+ const selected = CopyAsGFM.nodeToGFM(el);
- ShortcutsIssuable.prototype.replyWithSelectedText = function() {
- var quote, documentFragment, el, selected, separator;
- var replyField = $('.js-main-target-form #note_note');
-
- documentFragment = window.gl.utils.getSelectedFragment();
- if (!documentFragment) {
- replyField.focus();
- return;
- }
-
- el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
- selected = window.gl.CopyAsGFM.nodeToGFM(el);
-
- if (selected.trim() === "") {
- return;
- }
- quote = _.map(selected.split("\n"), function(val) {
- return ("> " + val).trim() + "\n";
- });
- // If replyField already has some content, add a newline before our quote
- separator = replyField.val().trim() !== "" && "\n\n" || '';
- replyField.val(function(_, current) {
- return current + separator + quote.join('') + "\n";
- });
-
- // Trigger autosave
- replyField.trigger('input');
-
- // Trigger autosize
- var event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- replyField.get(0).dispatchEvent(event);
-
- // Focus the input field
- return replyField.focus();
- };
-
- ShortcutsIssuable.prototype.editIssue = function() {
- var $editBtn;
- $editBtn = $('.issuable-edit');
- // 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) {
- sidebar.openDropdown(name);
+ if (selected.trim() === '') {
return false;
- };
+ }
+
+ const quote = _.map(selected.split('\n'), val => `${(`> ${val}`).trim()}\n`);
+
+ // If replyField already has some content, add a newline before our quote
+ const separator = (this.$replyField.val().trim() !== '' && '\n\n') || '';
+ this.$replyField.val((a, current) => `${current}${separator}${quote.join('')}\n`)
+ .trigger('input')
+ .trigger('change');
+
+ // Trigger autosize
+ const event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ this.$replyField.get(0).dispatchEvent(event);
+
+ // Focus the input field
+ this.$replyField.focus();
+
+ return false;
+ }
+
+ static editIssue() {
+ // Need to click the element as on issues, editing is inline
+ // on merge request, editing is on a different page
+ document.querySelector('.js-issuable-edit').click();
+
+ return false;
+ }
- return ShortcutsIssuable;
- })(ShortcutsNavigation);
-}).call(window);
+ static openSidebarDropdown(name) {
+ Sidebar.instance.openDropdown(name);
+ return false;
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index 55bae0c08a1..a4d10850471 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,36 +1,26 @@
-/* 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 Mousetrap from 'mousetrap';
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; },
- hasProp = {}.hasOwnProperty;
+import Shortcuts from './shortcuts';
- this.ShortcutsNavigation = (function(superClass) {
- extend(ShortcutsNavigation, superClass);
+export default class ShortcutsNavigation extends Shortcuts {
+ constructor() {
+ super();
- function ShortcutsNavigation() {
- ShortcutsNavigation.__super__.constructor.call(this);
- Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
- Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-project-activity'));
- Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
- Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
- Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
- Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
- Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
- Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
- Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
- Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
- Mousetrap.bind('g t', () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
- Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
- Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
- this.enabledHelp.push('.hidden-shortcut.project');
- }
+ Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
+ Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-project-activity'));
+ Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
+ Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
+ Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
+ Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
+ Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
+ Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
+ Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
+ Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
+ Mousetrap.bind('g t', () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
+ Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
+ Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
- return ShortcutsNavigation;
- })(Shortcuts);
-}).call(window);
+ this.enabledHelp.push('.hidden-shortcut.project');
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js
index cc44082efa9..a88c280fa3b 100644
--- a/app/assets/javascripts/shortcuts_network.js
+++ b/app/assets/javascripts/shortcuts_network.js
@@ -1,28 +1,17 @@
-/* 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, max-len */
-/* global Mousetrap */
-/* global ShortcutsNavigation */
+import Mousetrap from 'mousetrap';
+import ShortcutsNavigation from './shortcuts_navigation';
-import './shortcuts_navigation';
+export default class ShortcutsNetwork extends ShortcutsNavigation {
+ constructor(graph) {
+ super();
-(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; },
- hasProp = {}.hasOwnProperty;
+ Mousetrap.bind(['left', 'h'], graph.scrollLeft);
+ Mousetrap.bind(['right', 'l'], graph.scrollRight);
+ Mousetrap.bind(['up', 'k'], graph.scrollUp);
+ Mousetrap.bind(['down', 'j'], graph.scrollDown);
+ Mousetrap.bind(['shift+up', 'shift+k'], graph.scrollTop);
+ Mousetrap.bind(['shift+down', 'shift+j'], graph.scrollBottom);
- this.ShortcutsNetwork = (function(superClass) {
- extend(ShortcutsNetwork, superClass);
-
- function ShortcutsNetwork(graph) {
- this.graph = graph;
- ShortcutsNetwork.__super__.constructor.call(this);
- Mousetrap.bind(['left', 'h'], this.graph.scrollLeft);
- Mousetrap.bind(['right', 'l'], this.graph.scrollRight);
- Mousetrap.bind(['up', 'k'], this.graph.scrollUp);
- Mousetrap.bind(['down', 'j'], this.graph.scrollDown);
- Mousetrap.bind(['shift+up', 'shift+k'], this.graph.scrollTop);
- Mousetrap.bind(['shift+down', 'shift+j'], this.graph.scrollBottom);
- this.enabledHelp.push('.hidden-shortcut.network');
- }
-
- return ShortcutsNetwork;
- })(ShortcutsNavigation);
-}).call(window);
+ this.enabledHelp.push('.hidden-shortcut.network');
+ }
+}
diff --git a/app/assets/javascripts/shortcuts_wiki.js b/app/assets/javascripts/shortcuts_wiki.js
index 8a075062a48..41865dcf4ba 100644
--- a/app/assets/javascripts/shortcuts_wiki.js
+++ b/app/assets/javascripts/shortcuts_wiki.js
@@ -1,16 +1,14 @@
-/* eslint-disable class-methods-use-this */
-/* global Mousetrap */
-/* global ShortcutsNavigation */
-
+import Mousetrap from 'mousetrap';
+import ShortcutsNavigation from './shortcuts_navigation';
import findAndFollowLink from './shortcuts_dashboard_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
- Mousetrap.bind('e', this.editWiki);
+ Mousetrap.bind('e', ShortcutsWiki.editWiki);
}
- editWiki() {
+ static editWiki() {
findAndFollowLink('.js-wiki-edit');
}
}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
deleted file mode 100644
index 5a6e47e566e..00000000000
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
+++ /dev/null
@@ -1,59 +0,0 @@
-export default {
- name: 'AssigneeTitle',
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- numberOfAssignees: {
- type: Number,
- required: true,
- },
- editable: {
- type: Boolean,
- required: true,
- },
- showToggle: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- assigneeTitle() {
- const assignees = this.numberOfAssignees;
- return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
- },
- },
- template: `
- <div class="title hide-collapsed">
- {{assigneeTitle}}
- <i
- v-if="loading"
- aria-hidden="true"
- class="fa fa-spinner fa-spin block-loading"
- />
- <a
- v-if="editable"
- class="edit-link pull-right"
- href="#"
- >
- Edit
- </a>
- <a
- v-if="showToggle"
- aria-label="Toggle sidebar"
- class="gutter-toggle pull-right js-sidebar-toggle"
- href="#"
- role="button"
- >
- <i
- aria-hidden="true"
- data-hidden="true"
- class="fa fa-angle-double-right"
- />
- </a>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
new file mode 100644
index 00000000000..5eeb2a41bae
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -0,0 +1,64 @@
+<script>
+export default {
+ name: 'AssigneeTitle',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfAssignees: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ showToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ assigneeTitle() {
+ const assignees = this.numberOfAssignees;
+ return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ },
+ },
+};
+</script>
+<template>
+ <div class="title hide-collapsed">
+ {{ assigneeTitle }}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ >
+
+ </i>
+ <a
+ v-if="editable"
+ class="js-sidebar-dropdown-toggle edit-link pull-right"
+ href="#"
+ >
+ {{ __('Edit') }}
+ </a>
+ <a
+ v-if="showToggle"
+ aria-label="Toggle sidebar"
+ class="gutter-toggle pull-right js-sidebar-toggle"
+ href="#"
+ role="button"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-angle-double-right"
+ >
+ </i>
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
deleted file mode 100644
index 7e5feac622c..00000000000
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.js
+++ /dev/null
@@ -1,224 +0,0 @@
-export default {
- name: 'Assignees',
- data() {
- return {
- defaultRenderCount: 5,
- defaultMaxCounter: 99,
- showLess: true,
- };
- },
- props: {
- rootPath: {
- type: String,
- required: true,
- },
- users: {
- type: Array,
- required: true,
- },
- editable: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- firstUser() {
- return this.users[0];
- },
- hasMoreThanTwoAssignees() {
- return this.users.length > 2;
- },
- hasMoreThanOneAssignee() {
- return this.users.length > 1;
- },
- hasAssignees() {
- return this.users.length > 0;
- },
- hasNoUsers() {
- return !this.users.length;
- },
- hasOneUser() {
- return this.users.length === 1;
- },
- renderShowMoreSection() {
- return this.users.length > this.defaultRenderCount;
- },
- numberOfHiddenAssignees() {
- return this.users.length - this.defaultRenderCount;
- },
- isHiddenAssignees() {
- return this.numberOfHiddenAssignees > 0;
- },
- hiddenAssigneesLabel() {
- return `+ ${this.numberOfHiddenAssignees} more`;
- },
- collapsedTooltipTitle() {
- const maxRender = Math.min(this.defaultRenderCount, this.users.length);
- const renderUsers = this.users.slice(0, maxRender);
- const names = renderUsers.map(u => u.name);
-
- if (this.users.length > maxRender) {
- names.push(`+ ${this.users.length - maxRender} more`);
- }
-
- return names.join(', ');
- },
- sidebarAvatarCounter() {
- let counter = `+${this.users.length - 1}`;
-
- if (this.users.length > this.defaultMaxCounter) {
- counter = `${this.defaultMaxCounter}+`;
- }
-
- return counter;
- },
- },
- methods: {
- assignSelf() {
- this.$emit('assign-self');
- },
- toggleShowLess() {
- this.showLess = !this.showLess;
- },
- renderAssignee(index) {
- return !this.showLess || (index < this.defaultRenderCount && this.showLess);
- },
- avatarUrl(user) {
- return user.avatar || user.avatar_url;
- },
- assigneeUrl(user) {
- return `${this.rootPath}${user.username}`;
- },
- assigneeAlt(user) {
- return `${user.name}'s avatar`;
- },
- assigneeUsername(user) {
- return `@${user.username}`;
- },
- shouldRenderCollapsedAssignee(index) {
- const firstTwo = this.users.length <= 2 && index <= 2;
-
- return index === 0 || firstTwo;
- },
- },
- template: `
- <div>
- <div
- class="sidebar-collapsed-icon sidebar-collapsed-user"
- :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
- data-container="body"
- data-placement="left"
- :title="collapsedTooltipTitle"
- >
- <i
- v-if="hasNoUsers"
- aria-label="No Assignee"
- class="fa fa-user"
- />
- <button
- type="button"
- class="btn-link"
- v-for="(user, index) in users"
- v-if="shouldRenderCollapsedAssignee(index)"
- >
- <img
- width="24"
- class="avatar avatar-inline s24"
- :alt="assigneeAlt(user)"
- :src="avatarUrl(user)"
- />
- <span class="author">
- {{ user.name }}
- </span>
- </button>
- <button
- v-if="hasMoreThanTwoAssignees"
- class="btn-link"
- type="button"
- >
- <span
- class="avatar-counter sidebar-avatar-counter"
- >
- {{ sidebarAvatarCounter }}
- </span>
- </button>
- </div>
- <div class="value hide-collapsed">
- <template v-if="hasNoUsers">
- <span class="assign-yourself no-value">
- No assignee
- <template v-if="editable">
- -
- <button
- type="button"
- class="btn-link"
- @click="assignSelf"
- >
- assign yourself
- </button>
- </template>
- </span>
- </template>
- <template v-else-if="hasOneUser">
- <a
- class="author_link bold"
- :href="assigneeUrl(firstUser)"
- >
- <img
- width="32"
- class="avatar avatar-inline s32"
- :alt="assigneeAlt(firstUser)"
- :src="avatarUrl(firstUser)"
- />
- <span class="author">
- {{ firstUser.name }}
- </span>
- <span class="username">
- {{ assigneeUsername(firstUser) }}
- </span>
- </a>
- </template>
- <template v-else>
- <div class="user-list">
- <div
- class="user-item"
- v-for="(user, index) in users"
- v-if="renderAssignee(index)"
- >
- <a
- class="user-link has-tooltip"
- data-placement="bottom"
- :href="assigneeUrl(user)"
- :data-title="user.name"
- >
- <img
- width="32"
- class="avatar avatar-inline s32"
- :alt="assigneeAlt(user)"
- :src="avatarUrl(user)"
- />
- </a>
- </div>
- </div>
- <div
- v-if="renderShowMoreSection"
- class="user-list-more"
- >
- <button
- type="button"
- class="btn-link"
- @click="toggleShowLess"
- >
- <template v-if="showLess">
- {{ hiddenAssigneesLabel }}
- </template>
- <template v-else>
- - show less
- </template>
- </button>
- </div>
- </template>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
new file mode 100644
index 00000000000..1e7f46454bf
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -0,0 +1,232 @@
+<script>
+export default {
+ name: 'Assignees',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: true,
+ };
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasAssignees() {
+ return this.users.length > 0;
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ renderShowMoreSection() {
+ return this.users.length > this.defaultRenderCount;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - this.defaultRenderCount;
+ },
+ isHiddenAssignees() {
+ return this.numberOfHiddenAssignees > 0;
+ },
+ hiddenAssigneesLabel() {
+ return `+ ${this.numberOfHiddenAssignees} more`;
+ },
+ collapsedTooltipTitle() {
+ const maxRender = Math.min(this.defaultRenderCount, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (this.users.length > maxRender) {
+ names.push(`+ ${this.users.length - maxRender} more`);
+ }
+
+ return names.join(', ');
+ },
+ sidebarAvatarCounter() {
+ let counter = `+${this.users.length - 1}`;
+
+ if (this.users.length > this.defaultMaxCounter) {
+ counter = `${this.defaultMaxCounter}+`;
+ }
+
+ return counter;
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ renderAssignee(index) {
+ return !this.showLess || (index < this.defaultRenderCount && this.showLess);
+ },
+ avatarUrl(user) {
+ return user.avatar || user.avatar_url || gon.default_avatar_url;
+ },
+ assigneeUrl(user) {
+ return `${this.rootPath}${user.username}`;
+ },
+ assigneeAlt(user) {
+ return `${user.name}'s avatar`;
+ },
+ assigneeUsername(user) {
+ return `@${user.username}`;
+ },
+ shouldRenderCollapsedAssignee(index) {
+ const firstTwo = this.users.length <= 2 && index <= 2;
+
+ return index === 0 || firstTwo;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
+ data-container="body"
+ data-placement="left"
+ :title="collapsedTooltipTitle"
+ >
+ <i
+ v-if="hasNoUsers"
+ aria-label="No Assignee"
+ class="fa fa-user"
+ >
+ </i>
+ <button
+ type="button"
+ class="btn-link"
+ v-for="(user, index) in users"
+ v-if="shouldRenderCollapsedAssignee(index)"
+ :key="user.id"
+ >
+ <img
+ width="24"
+ class="avatar avatar-inline s24"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ <span class="author">
+ {{ user.name }}
+ </span>
+ </button>
+ <button
+ v-if="hasMoreThanTwoAssignees"
+ class="btn-link"
+ type="button"
+ >
+ <span
+ class="avatar-counter sidebar-avatar-counter"
+ >
+ {{ sidebarAvatarCounter }}
+ </span>
+ </button>
+ </div>
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value">
+ No assignee
+ <template v-if="editable">
+ -
+ <button
+ type="button"
+ class="btn-link"
+ @click="assignSelf"
+ >
+ assign yourself
+ </button>
+ </template>
+ </span>
+ </template>
+ <template v-else-if="hasOneUser">
+ <a
+ class="author_link bold"
+ :href="assigneeUrl(firstUser)"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(firstUser)"
+ :src="avatarUrl(firstUser)"
+ />
+ <span class="author">
+ {{ firstUser.name }}
+ </span>
+ <span class="username">
+ {{ assigneeUsername(firstUser) }}
+ </span>
+ </a>
+ </template>
+ <template v-else>
+ <div class="user-list">
+ <div
+ class="user-item"
+ v-for="(user, index) in users"
+ v-if="renderAssignee(index)"
+ :key="user.id"
+ >
+ <a
+ class="user-link has-tooltip"
+ data-container="body"
+ data-placement="bottom"
+ :href="assigneeUrl(user)"
+ :data-title="user.name"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ </a>
+ </div>
+ </div>
+ <div
+ v-if="renderShowMoreSection"
+ class="user-list-more"
+ >
+ <button
+ type="button"
+ class="btn-link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenAssigneesLabel }}
+ </template>
+ <template v-else>
+ - show less
+ </template>
+ </button>
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
+
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
deleted file mode 100644
index f83c3b037ed..00000000000
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
+++ /dev/null
@@ -1,87 +0,0 @@
-/* global Flash */
-
-import AssigneeTitle from './assignee_title';
-import Assignees from './assignees';
-
-import Store from '../../stores/sidebar_store';
-import Mediator from '../../sidebar_mediator';
-
-import eventHub from '../../event_hub';
-
-export default {
- name: 'SidebarAssignees',
- data() {
- return {
- mediator: new Mediator(),
- store: new Store(),
- loading: false,
- field: '',
- };
- },
- components: {
- 'assignee-title': AssigneeTitle,
- assignees: Assignees,
- },
- methods: {
- assignSelf() {
- // Notify gl dropdown that we are now assigning to current user
- this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
-
- this.mediator.assignYourself();
- this.saveAssignees();
- },
- saveAssignees() {
- this.loading = true;
-
- function setLoadingFalse() {
- this.loading = false;
- }
-
- this.mediator.saveAssignees(this.field)
- .then(setLoadingFalse.bind(this))
- .catch(() => {
- setLoadingFalse();
- return new Flash('Error occurred when saving assignees');
- });
- },
- },
- created() {
- this.removeAssignee = this.store.removeAssignee.bind(this.store);
- this.addAssignee = this.store.addAssignee.bind(this.store);
- this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
-
- // Get events from glDropdown
- eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
- eventHub.$on('sidebar.addAssignee', this.addAssignee);
- eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
- eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
- },
- beforeDestroy() {
- eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
- eventHub.$off('sidebar.addAssignee', this.addAssignee);
- eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
- eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
- },
- beforeMount() {
- this.field = this.$el.dataset.field;
- this.signedIn = typeof this.$el.dataset.signedIn !== 'undefined';
- },
- template: `
- <div>
- <assignee-title
- :number-of-assignees="store.assignees.length"
- :loading="loading || store.isFetching.assignees"
- :editable="store.editable"
- :show-toggle="!signedIn"
- />
- <assignees
- v-if="!store.isFetching.assignees"
- class="value"
- :root-path="store.rootPath"
- :users="store.assignees"
- :editable="store.editable"
- @assign-self="assignSelf"
- />
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
new file mode 100644
index 00000000000..3c6b9c27814
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -0,0 +1,95 @@
+<script>
+import Flash from '../../../flash';
+import AssigneeTitle from './assignee_title.vue';
+import Assignees from './assignees.vue';
+import Store from '../../stores/sidebar_store';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ components: {
+ AssigneeTitle,
+ Assignees,
+ },
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ field: {
+ type: String,
+ required: true,
+ },
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ loading: false,
+ };
+ },
+ created() {
+ this.removeAssignee = this.store.removeAssignee.bind(this.store);
+ this.addAssignee = this.store.addAssignee.bind(this.store);
+ this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
+
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
+ },
+ methods: {
+ assignSelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.assignYourself();
+ this.saveAssignees();
+ },
+ saveAssignees() {
+ this.loading = true;
+
+ function setLoadingFalse() {
+ this.loading = false;
+ }
+
+ this.mediator.saveAssignees(this.field)
+ .then(setLoadingFalse.bind(this))
+ .catch(() => {
+ setLoadingFalse();
+ return new Flash('Error occurred when saving assignees');
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <assignee-title
+ :number-of-assignees="store.assignees.length"
+ :loading="loading || store.isFetching.assignees"
+ :editable="store.editable"
+ :show-toggle="!signedIn"
+ />
+ <assignees
+ v-if="!store.isFetching.assignees"
+ class="value"
+ :root-path="store.rootPath"
+ :users="store.assignees"
+ :editable="store.editable"
+ @assign-self="assignSelf"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
new file mode 100644
index 00000000000..8a86c409b62
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -0,0 +1,102 @@
+<script>
+ import Flash from '../../../flash';
+ import editForm from './edit_form.vue';
+ import Icon from '../../../vue_shared/components/icon.vue';
+ import { __ } from '../../../locale';
+
+ export default {
+ components: {
+ editForm,
+ Icon,
+ },
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ isEditable: {
+ required: true,
+ type: Boolean,
+ },
+ service: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return {
+ edit: false,
+ };
+ },
+ computed: {
+ confidentialityIcon() {
+ return this.isConfidential ? 'eye-slash' : 'eye';
+ },
+ },
+ methods: {
+ toggleForm() {
+ this.edit = !this.edit;
+ },
+ updateConfidentialAttribute(confidential) {
+ this.service.update('issue', { confidential })
+ .then(() => location.reload())
+ .catch(() => {
+ Flash(__('Something went wrong trying to change the confidentiality of this issue'));
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="block issuable-sidebar-item confidentiality">
+ <div class="sidebar-collapsed-icon">
+ <icon
+ :name="confidentialityIcon"
+ :size="16"
+ aria-hidden="true"
+ />
+ </div>
+ <div class="title hide-collapsed">
+ {{ __('Confidentiality') }}
+ <a
+ v-if="isEditable"
+ class="pull-right confidential-edit"
+ href="#"
+ @click.prevent="toggleForm"
+ >
+ {{ __('Edit') }}
+ </a>
+ </div>
+ <div class="value sidebar-item-value hide-collapsed">
+ <editForm
+ v-if="edit"
+ :toggle-form="toggleForm"
+ :is-confidential="isConfidential"
+ :update-confidential-attribute="updateConfidentialAttribute"
+ />
+ <div
+ v-if="!isConfidential"
+ class="no-value sidebar-item-value">
+ <icon
+ name="eye"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon inline"
+ />
+ {{ __('Not confidential') }}
+ </div>
+ <div
+ v-else
+ class="value sidebar-item-value hide-collapsed">
+ <icon
+ name="eye-slash"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon inline is-active"
+ />
+ {{ __('This issue is confidential') }}
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
new file mode 100644
index 00000000000..c569843b05f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
@@ -0,0 +1,54 @@
+<script>
+ import editFormButtons from './edit_form_buttons.vue';
+ import { s__ } from '../../../locale';
+
+ export default {
+ components: {
+ editFormButtons,
+ },
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+ updateConfidentialAttribute: {
+ required: true,
+ type: Function,
+ },
+ },
+ computed: {
+ confidentialityOnWarning() {
+ return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.');
+ },
+ confidentialityOffWarning() {
+ return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="dropdown open">
+ <div class="dropdown-menu sidebar-item-warning-message">
+ <div>
+ <p
+ v-if="!isConfidential"
+ v-html="confidentialityOnWarning">
+ </p>
+ <p
+ v-else
+ v-html="confidentialityOffWarning">
+ </p>
+ <edit-form-buttons
+ :is-confidential="isConfidential"
+ :toggle-form="toggleForm"
+ :update-confidential-attribute="updateConfidentialAttribute"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
new file mode 100644
index 00000000000..49d5dfeea1a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -0,0 +1,45 @@
+<script>
+export default {
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+ updateConfidentialAttribute: {
+ required: true,
+ type: Function,
+ },
+ },
+ computed: {
+ toggleButtonText() {
+ return this.isConfidential ? 'Turn Off' : 'Turn On';
+ },
+ updateConfidentialBool() {
+ return !this.isConfidential;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="sidebar-item-warning-message-actions">
+ <button
+ type="button"
+ class="btn btn-default append-right-10"
+ @click="toggleForm"
+ >
+ {{ __('Cancel') }}
+ </button>
+ <button
+ type="button"
+ class="btn btn-close"
+ @click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
+ >
+ {{ toggleButtonText }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
new file mode 100644
index 00000000000..bc32e974bc3
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
@@ -0,0 +1,62 @@
+<script>
+ import editFormButtons from './edit_form_buttons.vue';
+ import issuableMixin from '../../../vue_shared/mixins/issuable';
+ import { __, sprintf } from '../../../locale';
+
+ export default {
+ components: {
+ editFormButtons,
+ },
+ mixins: [
+ issuableMixin,
+ ],
+ props: {
+ isLocked: {
+ required: true,
+ type: Boolean,
+ },
+
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+
+ updateLockedAttribute: {
+ required: true,
+ type: Function,
+ },
+ },
+ computed: {
+ lockWarning() {
+ return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName });
+ },
+ unlockWarning() {
+ return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName });
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="dropdown open">
+ <div class="dropdown-menu sidebar-item-warning-message">
+ <p
+ class="text"
+ v-if="isLocked"
+ v-html="unlockWarning">
+ </p>
+
+ <p
+ class="text"
+ v-else
+ v-html="lockWarning">
+ </p>
+
+ <edit-form-buttons
+ :is-locked="isLocked"
+ :toggle-form="toggleForm"
+ :update-locked-attribute="updateLockedAttribute"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
new file mode 100644
index 00000000000..c3a553a7605
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -0,0 +1,50 @@
+<script>
+export default {
+ props: {
+ isLocked: {
+ required: true,
+ type: Boolean,
+ },
+
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+
+ updateLockedAttribute: {
+ required: true,
+ type: Function,
+ },
+ },
+
+ computed: {
+ buttonText() {
+ return this.isLocked ? this.__('Unlock') : this.__('Lock');
+ },
+
+ toggleLock() {
+ return !this.isLocked;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="sidebar-item-warning-message-actions">
+ <button
+ type="button"
+ class="btn btn-default append-right-10"
+ @click="toggleForm"
+ >
+ {{ __('Cancel') }}
+ </button>
+
+ <button
+ type="button"
+ class="btn btn-close"
+ @click.prevent="updateLockedAttribute(toggleLock)"
+ >
+ {{ buttonText }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
new file mode 100644
index 00000000000..0686910fc7e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -0,0 +1,121 @@
+<script>
+ import Flash from '~/flash';
+ import editForm from './edit_form.vue';
+ import issuableMixin from '../../../vue_shared/mixins/issuable';
+ import Icon from '../../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ editForm,
+ Icon,
+ },
+ mixins: [
+ issuableMixin,
+ ],
+
+ props: {
+ isLocked: {
+ required: true,
+ type: Boolean,
+ },
+
+ isEditable: {
+ required: true,
+ type: Boolean,
+ },
+
+ mediator: {
+ required: true,
+ type: Object,
+ validator(mediatorObject) {
+ return mediatorObject.service && mediatorObject.service.update && mediatorObject.store;
+ },
+ },
+ },
+
+ computed: {
+ lockIcon() {
+ return this.isLocked ? 'lock' : 'lock-open';
+ },
+
+ isLockDialogOpen() {
+ return this.mediator.store.isLockDialogOpen;
+ },
+ },
+
+ methods: {
+ toggleForm() {
+ this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
+ },
+
+ updateLockedAttribute(locked) {
+ this.mediator.service.update(this.issuableType, {
+ discussion_locked: locked,
+ })
+ .then(() => location.reload())
+ .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName}`)));
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="block issuable-sidebar-item lock">
+ <div class="sidebar-collapsed-icon">
+ <icon
+ :name="lockIcon"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon is-active"
+ />
+ </div>
+
+ <div class="title hide-collapsed">
+ {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }}
+ <button
+ v-if="isEditable"
+ class="pull-right lock-edit"
+ type="button"
+ @click.prevent="toggleForm"
+ >
+ {{ __('Edit') }}
+ </button>
+ </div>
+
+ <div class="value sidebar-item-value hide-collapsed">
+ <edit-form
+ v-if="isLockDialogOpen"
+ :toggle-form="toggleForm"
+ :is-locked="isLocked"
+ :update-locked-attribute="updateLockedAttribute"
+ :issuable-type="issuableType"
+ />
+
+ <div
+ v-if="isLocked"
+ class="value sidebar-item-value"
+ >
+ <icon
+ name="lock"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon inline is-active"
+ />
+ {{ __('Locked') }}
+ </div>
+
+ <div
+ v-else
+ class="no-value sidebar-item-value hide-collapsed"
+ >
+ <icon
+ name="lock-open"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon inline"
+ />
+ {{ __('Unlocked') }}
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
new file mode 100644
index 00000000000..006a6d2905d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -0,0 +1,134 @@
+<script>
+ import { __, n__, sprintf } from '../../../locale';
+ import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+ import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue';
+
+ export default {
+ components: {
+ loadingIcon,
+ userAvatarImage,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ participants: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ numberOfLessParticipants: {
+ type: Number,
+ required: false,
+ default: 7,
+ },
+ },
+ data() {
+ return {
+ isShowingMoreParticipants: false,
+ };
+ },
+ computed: {
+ lessParticipants() {
+ return this.participants.slice(0, this.numberOfLessParticipants);
+ },
+ visibleParticipants() {
+ return this.isShowingMoreParticipants ? this.participants : this.lessParticipants;
+ },
+ hasMoreParticipants() {
+ return this.participants.length > this.numberOfLessParticipants;
+ },
+ toggleLabel() {
+ let label = '';
+ if (this.isShowingMoreParticipants) {
+ label = __('- show less');
+ } else {
+ label = sprintf(__('+ %{moreCount} more'), {
+ moreCount: this.participants.length - this.numberOfLessParticipants,
+ });
+ }
+
+ return label;
+ },
+ participantLabel() {
+ return sprintf(
+ n__('%{count} participant', '%{count} participants', this.participants.length),
+ { count: this.loading ? '' : this.participantCount },
+ );
+ },
+ participantCount() {
+ return this.participants.length;
+ },
+ },
+ methods: {
+ toggleMoreParticipants() {
+ this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="sidebar-collapsed-icon">
+ <i
+ class="fa fa-users"
+ aria-hidden="true"
+ >
+ </i>
+ <loading-icon
+ v-if="loading"
+ class="js-participants-collapsed-loading-icon"
+ />
+ <span
+ v-else
+ class="js-participants-collapsed-count"
+ >
+ {{ participantCount }}
+ </span>
+ </div>
+ <div class="title hide-collapsed">
+ <loading-icon
+ v-if="loading"
+ :inline="true"
+ class="js-participants-expanded-loading-icon"
+ />
+ {{ participantLabel }}
+ </div>
+ <div class="participants-list hide-collapsed">
+ <div
+ v-for="participant in visibleParticipants"
+ :key="participant.id"
+ class="participants-author js-participants-author"
+ >
+ <a
+ class="author_link"
+ :href="participant.web_url"
+ >
+ <user-avatar-image
+ :lazy="true"
+ :img-src="participant.avatar_url"
+ css-classes="avatar-inline"
+ :size="24"
+ :tooltip-text="participant.name"
+ tooltip-placement="bottom"
+ />
+ </a>
+ </div>
+ </div>
+ <div
+ v-if="hasMoreParticipants"
+ class="participants-more hide-collapsed"
+ >
+ <button
+ type="button"
+ class="btn-transparent btn-blank js-toggle-participants-button"
+ @click="toggleMoreParticipants"
+ >
+ {{ toggleLabel }}
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue
new file mode 100644
index 00000000000..5c1ead1a8ac
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue
@@ -0,0 +1,31 @@
+<script>
+ import Store from '../../stores/sidebar_store';
+ import participants from './participants.vue';
+
+ export default {
+ components: {
+ participants,
+ },
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ };
+ },
+ };
+</script>
+
+<template>
+ <div class="block participants">
+ <participants
+ :loading="store.isFetching.participants"
+ :participants="store.participants"
+ :number-of-less-participants="7"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
new file mode 100644
index 00000000000..3e8cc7a6630
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
@@ -0,0 +1,47 @@
+<script>
+import Store from '../../stores/sidebar_store';
+import eventHub from '../../event_hub';
+import Flash from '../../../flash';
+import { __ } from '../../../locale';
+import subscriptions from './subscriptions.vue';
+
+export default {
+ components: {
+ subscriptions,
+ },
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ };
+ },
+ created() {
+ eventHub.$on('toggleSubscription', this.onToggleSubscription);
+ },
+ beforeDestroy() {
+ eventHub.$off('toggleSubscription', this.onToggleSubscription);
+ },
+ methods: {
+ onToggleSubscription() {
+ this.mediator.toggleSubscription()
+ .catch(() => {
+ Flash(__('Error occurred when toggling the notification subscription'));
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block subscriptions">
+ <subscriptions
+ :loading="store.isFetching.subscriptions"
+ :subscribed="store.subscribed"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
new file mode 100644
index 00000000000..d69d100a26c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -0,0 +1,85 @@
+<script>
+ import { __ } from '~/locale';
+ import icon from '~/vue_shared/components/icon.vue';
+ import toggleButton from '~/vue_shared/components/toggle_button.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import eventHub from '../../event_hub';
+
+ const ICON_ON = 'notifications';
+ const ICON_OFF = 'notifications-off';
+ const LABEL_ON = __('Notifications on');
+ const LABEL_OFF = __('Notifications off');
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ icon,
+ toggleButton,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ subscribed: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ id: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ showLoadingState() {
+ return this.subscribed === null;
+ },
+ notificationIcon() {
+ return this.subscribed ? ICON_ON : ICON_OFF;
+ },
+ notificationTooltip() {
+ return this.subscribed ? LABEL_ON : LABEL_OFF;
+ },
+ },
+ methods: {
+ toggleSubscription() {
+ eventHub.$emit('toggleSubscription', this.id);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="sidebar-collapsed-icon">
+ <span
+ v-tooltip
+ :title="notificationTooltip"
+ data-container="body"
+ data-placement="left"
+ >
+ <icon
+ :name="notificationIcon"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon is-active"
+ />
+ </span>
+ </div>
+ <span class="issuable-header-text hide-collapsed pull-left">
+ {{ __('Notifications') }}
+ </span>
+ <toggle-button
+ ref="toggleButton"
+ class="pull-right hide-collapsed js-issuable-subscribe-button"
+ :is-loading="showLoadingState"
+ :value="subscribed"
+ @change="toggleSubscription"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
deleted file mode 100644
index 0da265053bd..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import stopwatchSvg from 'icons/_icon_stopwatch.svg';
-
-import '../../../lib/utils/pretty_time';
-
-export default {
- name: 'time-tracking-collapsed-state',
- props: {
- showComparisonState: {
- type: Boolean,
- required: true,
- },
- showSpentOnlyState: {
- type: Boolean,
- required: true,
- },
- showEstimateOnlyState: {
- type: Boolean,
- required: true,
- },
- showNoTimeTrackingState: {
- type: Boolean,
- required: true,
- },
- timeSpentHumanReadable: {
- type: String,
- required: false,
- default: '',
- },
- timeEstimateHumanReadable: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- timeSpent() {
- return this.abbreviateTime(this.timeSpentHumanReadable);
- },
- timeEstimate() {
- return this.abbreviateTime(this.timeEstimateHumanReadable);
- },
- divClass() {
- if (this.showComparisonState) {
- return 'compare';
- } else if (this.showEstimateOnlyState) {
- return 'estimate-only';
- } else if (this.showSpentOnlyState) {
- return 'spend-only';
- } else if (this.showNoTimeTrackingState) {
- return 'no-tracking';
- }
-
- return '';
- },
- spanClass() {
- if (this.showComparisonState) {
- return '';
- } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
- return 'bold';
- } else if (this.showNoTimeTrackingState) {
- return 'no-value';
- }
-
- return '';
- },
- text() {
- if (this.showComparisonState) {
- return `${this.timeSpent} / ${this.timeEstimate}`;
- } else if (this.showEstimateOnlyState) {
- return `-- / ${this.timeEstimate}`;
- } else if (this.showSpentOnlyState) {
- return `${this.timeSpent} / --`;
- } else if (this.showNoTimeTrackingState) {
- return 'None';
- }
-
- return '';
- },
- },
- methods: {
- abbreviateTime(timeStr) {
- return gl.utils.prettyTime.abbreviateTime(timeStr);
- },
- },
- template: `
- <div class="sidebar-collapsed-icon">
- ${stopwatchSvg}
- <div class="time-tracking-collapsed-summary">
- <div :class="divClass">
- <span :class="spanClass">
- {{ text }}
- </span>
- </div>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
new file mode 100644
index 00000000000..3b86f1145d1
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -0,0 +1,102 @@
+<script>
+ import icon from '../../../vue_shared/components/icon.vue';
+ import { abbreviateTime } from '../../../lib/utils/pretty_time';
+
+ export default {
+ name: 'TimeTrackingCollapsedState',
+ components: {
+ icon,
+ },
+ props: {
+ showComparisonState: {
+ type: Boolean,
+ required: true,
+ },
+ showSpentOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showEstimateOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showNoTimeTrackingState: {
+ type: Boolean,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ timeSpent() {
+ return this.abbreviateTime(this.timeSpentHumanReadable);
+ },
+ timeEstimate() {
+ return this.abbreviateTime(this.timeEstimateHumanReadable);
+ },
+ divClass() {
+ if (this.showComparisonState) {
+ return 'compare';
+ } else if (this.showEstimateOnlyState) {
+ return 'estimate-only';
+ } else if (this.showSpentOnlyState) {
+ return 'spend-only';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-tracking';
+ }
+
+ return '';
+ },
+ spanClass() {
+ if (this.showComparisonState) {
+ return '';
+ } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
+ return 'bold';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-value';
+ }
+
+ return '';
+ },
+ text() {
+ if (this.showComparisonState) {
+ return `${this.timeSpent} / ${this.timeEstimate}`;
+ } else if (this.showEstimateOnlyState) {
+ return `-- / ${this.timeEstimate}`;
+ } else if (this.showSpentOnlyState) {
+ return `${this.timeSpent} / --`;
+ } else if (this.showNoTimeTrackingState) {
+ return 'None';
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ abbreviateTime(timeStr) {
+ return abbreviateTime(timeStr);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="sidebar-collapsed-icon">
+ <icon name="timer" />
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
index 40f5c89c5bb..b5ebccd3795 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -1,6 +1,4 @@
-import '../../../lib/utils/pretty_time';
-
-const prettyTime = gl.utils.prettyTime;
+import { parseSeconds, stringifyTime } from '../../../lib/utils/pretty_time';
export default {
name: 'time-tracking-comparison-pane',
@@ -23,12 +21,12 @@ export default {
},
},
computed: {
- parsedRemaining() {
+ parsedTimeRemaining() {
const diffSeconds = this.timeEstimate - this.timeSpent;
- return prettyTime.parseSeconds(diffSeconds);
+ return parseSeconds(diffSeconds);
},
timeRemainingHumanReadable() {
- return prettyTime.stringifyTime(this.parsedRemaining);
+ return stringifyTime(this.parsedTimeRemaining);
},
timeRemainingTooltip() {
const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
@@ -44,13 +42,6 @@ export default {
timeRemainingStatusClass() {
return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
},
- /* Parsed time values */
- parsedEstimate() {
- return prettyTime.parseSeconds(this.timeEstimate);
- },
- parsedSpent() {
- return prettyTime.parseSeconds(this.timeSpent);
- },
},
template: `
<div class="time-tracking-comparison-pane">
@@ -77,7 +68,7 @@ export default {
<div class="compare-display-container">
<div class="compare-display pull-left">
<span class="compare-label">
- Spent
+ {{ s__('TimeTracking|Spent') }}
</span>
<span class="compare-value spent">
{{ timeSpentHumanReadable }}
@@ -85,7 +76,7 @@ export default {
</div>
<div class="compare-display estimated pull-right">
<span class="compare-label">
- Est
+ {{ s__('TimeTrackingEstimated|Est') }}
</span>
<span class="compare-value">
{{ timeEstimateHumanReadable }}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
index ad1b9179db0..2d324c71379 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -9,7 +9,7 @@ export default {
template: `
<div class="time-tracking-estimate-only-pane">
<span class="bold">
- Estimated:
+ {{ s__('TimeTracking|Estimated:') }}
</span>
{{ timeEstimateHumanReadable }}
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
index 142ad437509..19f74ad3c6d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -1,3 +1,5 @@
+import { sprintf, s__ } from '../../../locale';
+
export default {
name: 'time-tracking-help-state',
props: {
@@ -10,33 +12,39 @@ export default {
href() {
return `${this.rootPath}help/workflow/time_tracking.md`;
},
+ estimateText() {
+ return sprintf(
+ s__('estimateCommand|%{slash_command} will update the estimated time with the latest command.'), {
+ slash_command: '<code>/estimate</code>',
+ }, false,
+ );
+ },
+ spendText() {
+ return sprintf(
+ s__('spendCommand|%{slash_command} will update the sum of the time spent.'), {
+ slash_command: '<code>/spend</code>',
+ }, false,
+ );
+ },
},
template: `
<div class="time-tracking-help-state">
<div class="time-tracking-info">
<h4>
- Track time with quick actions
+ {{ __('Track time with quick actions') }}
</h4>
<p>
- Quick actions can be used in the issues description and comment boxes.
+ {{ __('Quick actions can be used in the issues description and comment boxes.') }}
</p>
- <p>
- <code>
- /estimate
- </code>
- will update the estimated time with the latest command.
+ <p v-html="estimateText">
</p>
- <p>
- <code>
- /spend
- </code>
- will update the sum of the time spent.
+ <p v-html="spendText">
</p>
<a
class="btn btn-default learn-more-button"
:href="href"
>
- Learn more
+ {{ __('Learn more') }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
index d1dd1dcdd27..38da76c6771 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -3,7 +3,7 @@ export default {
template: `
<div class="time-tracking-no-tracking-pane">
<span class="no-value">
- No estimate or time spent
+ {{ __('No estimate or time spent') }}
</span>
</div>
`,
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
index 650e935b116..5626cccc022 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -1,9 +1,13 @@
+import $ from 'jquery';
+import _ from 'underscore';
+
import '~/smart_interval';
-import timeTracker from './time_tracker';
+import IssuableTimeTracker from './time_tracker.vue';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
+import eventHub from '../../event_hub';
export default {
data() {
@@ -13,11 +17,14 @@ export default {
};
},
components: {
- 'issuable-time-tracker': timeTracker,
+ IssuableTimeTracker,
},
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
+ eventHub.$on('timeTrackingUpdated', (data) => {
+ this.quickActionListened(null, data);
+ });
},
quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate'];
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
deleted file mode 100644
index ed0d71a4f79..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
+++ /dev/null
@@ -1,163 +0,0 @@
-import timeTrackingHelpState from './help_state';
-import timeTrackingCollapsedState from './collapsed_state';
-import timeTrackingSpentOnlyPane from './spent_only_pane';
-import timeTrackingNoTrackingPane from './no_tracking_pane';
-import timeTrackingEstimateOnlyPane from './estimate_only_pane';
-import timeTrackingComparisonPane from './comparison_pane';
-
-import eventHub from '../../event_hub';
-
-export default {
- name: 'issuable-time-tracker',
- props: {
- time_estimate: {
- type: Number,
- required: true,
- },
- time_spent: {
- type: Number,
- required: true,
- },
- human_time_estimate: {
- type: String,
- required: false,
- default: '',
- },
- human_time_spent: {
- type: String,
- required: false,
- default: '',
- },
- rootPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- showHelp: false,
- };
- },
- components: {
- 'time-tracking-collapsed-state': timeTrackingCollapsedState,
- 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
- 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
- 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
- 'time-tracking-comparison-pane': timeTrackingComparisonPane,
- 'time-tracking-help-state': timeTrackingHelpState,
- },
- computed: {
- timeSpent() {
- return this.time_spent;
- },
- timeEstimate() {
- return this.time_estimate;
- },
- timeEstimateHumanReadable() {
- return this.human_time_estimate;
- },
- timeSpentHumanReadable() {
- return this.human_time_spent;
- },
- hasTimeSpent() {
- return !!this.timeSpent;
- },
- hasTimeEstimate() {
- return !!this.timeEstimate;
- },
- showComparisonState() {
- return this.hasTimeEstimate && this.hasTimeSpent;
- },
- showEstimateOnlyState() {
- return this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showSpentOnlyState() {
- return this.hasTimeSpent && !this.hasTimeEstimate;
- },
- showNoTimeTrackingState() {
- return !this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showHelpState() {
- return !!this.showHelp;
- },
- },
- methods: {
- toggleHelpState(show) {
- this.showHelp = show;
- },
- update(data) {
- this.time_estimate = data.time_estimate;
- this.time_spent = data.time_spent;
- this.human_time_estimate = data.human_time_estimate;
- this.human_time_spent = data.human_time_spent;
- },
- },
- created() {
- eventHub.$on('timeTracker:updateData', this.update);
- },
- template: `
- <div
- class="time_tracker time-tracking-component-wrap"
- v-cloak
- >
- <time-tracking-collapsed-state
- :show-comparison-state="showComparisonState"
- :show-no-time-tracking-state="showNoTimeTrackingState"
- :show-help-state="showHelpState"
- :show-spent-only-state="showSpentOnlyState"
- :show-estimate-only-state="showEstimateOnlyState"
- :time-spent-human-readable="timeSpentHumanReadable"
- :time-estimate-human-readable="timeEstimateHumanReadable"
- />
- <div class="title hide-collapsed">
- Time tracking
- <div
- class="help-button pull-right"
- v-if="!showHelpState"
- @click="toggleHelpState(true)"
- >
- <i
- class="fa fa-question-circle"
- aria-hidden="true"
- />
- </div>
- <div
- class="close-help-button pull-right"
- v-if="showHelpState"
- @click="toggleHelpState(false)"
- >
- <i
- class="fa fa-close"
- aria-hidden="true"
- />
- </div>
- </div>
- <div class="time-tracking-content hide-collapsed">
- <time-tracking-estimate-only-pane
- v-if="showEstimateOnlyState"
- :time-estimate-human-readable="timeEstimateHumanReadable"
- />
- <time-tracking-spent-only-pane
- v-if="showSpentOnlyState"
- :time-spent-human-readable="timeSpentHumanReadable"
- />
- <time-tracking-no-tracking-pane
- v-if="showNoTimeTrackingState"
- />
- <time-tracking-comparison-pane
- v-if="showComparisonState"
- :time-estimate="timeEstimate"
- :time-spent="timeSpent"
- :time-spent-human-readable="timeSpentHumanReadable"
- :time-estimate-human-readable="timeEstimateHumanReadable"
- />
- <transition name="help-state-toggle">
- <time-tracking-help-state
- v-if="showHelpState"
- :rootPath="rootPath"
- />
- </transition>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
new file mode 100644
index 00000000000..28240468d2c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -0,0 +1,168 @@
+<script>
+import timeTrackingHelpState from './help_state';
+import TimeTrackingCollapsedState from './collapsed_state.vue';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import timeTrackingComparisonPane from './comparison_pane';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'IssuableTimeTracker',
+ components: {
+ TimeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ 'time-tracking-comparison-pane': timeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ props: {
+ time_estimate: {
+ type: Number,
+ required: true,
+ },
+ time_spent: {
+ type: Number,
+ required: true,
+ },
+ human_time_estimate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ human_time_spent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ computed: {
+ timeSpent() {
+ return this.time_spent;
+ },
+ timeEstimate() {
+ return this.time_estimate;
+ },
+ timeEstimateHumanReadable() {
+ return this.human_time_estimate;
+ },
+ timeSpentHumanReadable() {
+ return this.human_time_spent;
+ },
+ hasTimeSpent() {
+ return !!this.timeSpent;
+ },
+ hasTimeEstimate() {
+ return !!this.timeEstimate;
+ },
+ showComparisonState() {
+ return this.hasTimeEstimate && this.hasTimeSpent;
+ },
+ showEstimateOnlyState() {
+ return this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showSpentOnlyState() {
+ return this.hasTimeSpent && !this.hasTimeEstimate;
+ },
+ showNoTimeTrackingState() {
+ return !this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showHelpState() {
+ return !!this.showHelp;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ methods: {
+ toggleHelpState(show) {
+ this.showHelp = show;
+ },
+ update(data) {
+ this.time_estimate = data.time_estimate;
+ this.time_spent = data.time_spent;
+ this.human_time_estimate = data.human_time_estimate;
+ this.human_time_spent = data.human_time_spent;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="time_tracker time-tracking-component-wrap"
+ v-cloak
+ >
+ <time-tracking-collapsed-state
+ :show-comparison-state="showComparisonState"
+ :show-no-time-tracking-state="showNoTimeTrackingState"
+ :show-help-state="showHelpState"
+ :show-spent-only-state="showSpentOnlyState"
+ :show-estimate-only-state="showEstimateOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <div class="title hide-collapsed">
+ {{ __('Time tracking') }}
+ <div
+ class="help-button pull-right"
+ v-if="!showHelpState"
+ @click="toggleHelpState(true)"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ >
+ </i>
+ </div>
+ <div
+ class="close-help-button pull-right"
+ v-if="showHelpState"
+ @click="toggleHelpState(false)"
+ >
+ <i
+ class="fa fa-close"
+ aria-hidden="true"
+ >
+ </i>
+ </div>
+ </div>
+ <div class="time-tracking-content hide-collapsed">
+ <time-tracking-estimate-only-pane
+ v-if="showEstimateOnlyState"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <time-tracking-spent-only-pane
+ v-if="showSpentOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ />
+ <time-tracking-no-tracking-pane
+ v-if="showNoTimeTrackingState"
+ />
+ <time-tracking-comparison-pane
+ v-if="showComparisonState"
+ :time-estimate="timeEstimate"
+ :time-spent="timeSpent"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
+ v-if="showHelpState"
+ :root-path="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
new file mode 100644
index 00000000000..1eadebc7004
--- /dev/null
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -0,0 +1,85 @@
+import $ from 'jquery';
+
+function isValidProjectId(id) {
+ return id > 0;
+}
+
+class SidebarMoveIssue {
+ constructor(mediator, dropdownToggle, confirmButton) {
+ this.mediator = mediator;
+
+ this.$dropdownToggle = $(dropdownToggle);
+ this.$confirmButton = $(confirmButton);
+
+ this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this);
+ }
+
+ init() {
+ this.initDropdown();
+ this.addEventListeners();
+ }
+
+ destroy() {
+ this.removeEventListeners();
+ }
+
+ initDropdown() {
+ this.$dropdownToggle.glDropdown({
+ search: {
+ fields: ['name_with_namespace'],
+ },
+ showMenuAbove: true,
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ multiSelect: false,
+ // Keep the dropdown open after selecting an option
+ shouldPropagate: false,
+ data: (searchTerm, callback) => {
+ this.mediator.fetchAutocompleteProjects(searchTerm)
+ .then(callback)
+ .catch(() => new window.Flash('An error occurred while fetching projects autocomplete.'));
+ },
+ renderRow: project => `
+ <li>
+ <a href="#" class="js-move-issue-dropdown-item">
+ ${project.name_with_namespace}
+ </a>
+ </li>
+ `,
+ clicked: (options) => {
+ const project = options.selectedObj;
+ const selectedProjectId = options.isMarking ? project.id : 0;
+ this.mediator.setMoveToProjectId(selectedProjectId);
+
+ this.$confirmButton.prop('disabled', !isValidProjectId(selectedProjectId));
+ },
+ });
+ }
+
+ addEventListeners() {
+ this.$confirmButton.on('click', this.onConfirmClickedWrapper);
+ }
+
+ removeEventListeners() {
+ this.$confirmButton.off('click', this.onConfirmClickedWrapper);
+ }
+
+ onConfirmClicked() {
+ if (isValidProjectId(this.mediator.store.moveToProjectId)) {
+ this.$confirmButton
+ .disable()
+ .addClass('is-loading');
+
+ this.mediator.moveIssue()
+ .catch(() => {
+ window.Flash('An error occurred while moving the issue.');
+ this.$confirmButton
+ .enable()
+ .removeClass('is-loading');
+ });
+ }
+ }
+}
+
+export default SidebarMoveIssue;
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
new file mode 100644
index 00000000000..b15ad0e5586
--- /dev/null
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import timeTracker from './components/time_tracking/time_tracker.vue';
+
+export default class SidebarMilestone {
+ constructor() {
+ const el = document.getElementById('issuable-time-tracker');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ timeTracker,
+ },
+ render: createElement => createElement('timeTracker', {
+ props: {
+ time_estimate: parseInt(el.dataset.timeEstimate, 10),
+ time_spent: parseInt(el.dataset.timeSpent, 10),
+ human_time_estimate: el.dataset.humanTimeEstimate,
+ human_time_spent: el.dataset.humanTimeSpent,
+ rootPath: '/',
+ },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
new file mode 100644
index 00000000000..9f5d852260e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -0,0 +1,146 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
+import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
+import SidebarMoveIssue from './lib/sidebar_move_issue';
+import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
+import sidebarParticipants from './components/participants/sidebar_participants.vue';
+import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
+import Translate from '../vue_shared/translate';
+
+Vue.use(Translate);
+
+function mountAssigneesComponent(mediator) {
+ const el = document.getElementById('js-vue-sidebar-assignees');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ SidebarAssignees,
+ },
+ render: createElement => createElement('sidebar-assignees', {
+ props: {
+ mediator,
+ field: el.dataset.field,
+ signedIn: el.hasAttribute('data-signed-in'),
+ },
+ }),
+ });
+}
+
+function mountConfidentialComponent(mediator) {
+ const el = document.getElementById('js-confidential-entry-point');
+
+ if (!el) return;
+
+ const dataNode = document.getElementById('js-confidential-issue-data');
+ const initialData = JSON.parse(dataNode.innerHTML);
+
+ const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
+
+ new ConfidentialComp({
+ propsData: {
+ isConfidential: initialData.is_confidential,
+ isEditable: initialData.is_editable,
+ service: mediator.service,
+ },
+ }).$mount(el);
+}
+
+function mountLockComponent(mediator) {
+ const el = document.getElementById('js-lock-entry-point');
+
+ if (!el) return;
+
+ const dataNode = document.getElementById('js-lock-issue-data');
+ const initialData = JSON.parse(dataNode.innerHTML);
+
+ const LockComp = Vue.extend(LockIssueSidebar);
+
+ new LockComp({
+ propsData: {
+ isLocked: initialData.is_locked,
+ isEditable: initialData.is_editable,
+ mediator,
+ issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
+ },
+ }).$mount(el);
+}
+
+function mountParticipantsComponent(mediator) {
+ const el = document.querySelector('.js-sidebar-participants-entry-point');
+
+ // eslint-disable-next-line no-new
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ sidebarParticipants,
+ },
+ render: createElement => createElement('sidebar-participants', {
+ props: {
+ mediator,
+ },
+ }),
+ });
+}
+
+function mountSubscriptionsComponent(mediator) {
+ const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ sidebarSubscriptions,
+ },
+ render: createElement => createElement('sidebar-subscriptions', {
+ props: {
+ mediator,
+ },
+ }),
+ });
+}
+
+function mountTimeTrackingComponent() {
+ const el = document.getElementById('issuable-time-tracker');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ SidebarTimeTracking,
+ },
+ render: createElement => createElement('sidebar-time-tracking', {}),
+ });
+}
+
+export function mountSidebar(mediator) {
+ mountAssigneesComponent(mediator);
+ mountConfidentialComponent(mediator);
+ mountLockComponent(mediator);
+ mountParticipantsComponent(mediator);
+ mountSubscriptionsComponent(mediator);
+
+ new SidebarMoveIssue(
+ mediator,
+ $('.js-move-issue'),
+ $('.js-move-issue-confirmation-button'),
+ ).init();
+
+ mountTimeTrackingComponent();
+}
+
+export function getSidebarOptions() {
+ return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+}
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index 5a82d01dc41..37c97225bfd 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -4,9 +4,12 @@ import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class SidebarService {
- constructor(endpoint) {
+ constructor(endpointMap) {
if (!SidebarService.singleton) {
- this.endpoint = endpoint;
+ this.endpoint = endpointMap.endpoint;
+ this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
+ this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
+ this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
SidebarService.singleton = this;
}
@@ -25,4 +28,22 @@ export default class SidebarService {
emulateJSON: true,
});
}
+
+ getProjectsAutocomplete(searchTerm) {
+ return Vue.http.get(this.projectsAutocompleteEndpoint, {
+ params: {
+ search: searchTerm,
+ },
+ });
+ }
+
+ toggleSubscription() {
+ return Vue.http.post(this.toggleSubscriptionEndpoint);
+ }
+
+ moveIssue(moveToProjectId) {
+ return Vue.http.post(this.moveIssueEndpoint, {
+ move_to_project_id: moveToProjectId,
+ });
+ }
}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 2b02af87d8a..377846db70e 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -1,24 +1,9 @@
-import Vue from 'vue';
-import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
-import sidebarAssignees from './components/assignees/sidebar_assignees';
-
import Mediator from './sidebar_mediator';
+import { mountSidebar, getSidebarOptions } from './mount_sidebar';
-function domContentLoaded() {
- const mediator = new Mediator(gl.sidebarOptions);
+export default () => {
+ const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
- const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
-
- // Only create the sidebarAssignees vue app if it is found in the DOM
- // We currently do not use sidebarAssignees for the MR page
- if (sidebarAssigneesEl) {
- new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
- }
-
- new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
-}
-
-document.addEventListener('DOMContentLoaded', domContentLoaded);
-
-export default domContentLoaded;
+ mountSidebar(mediator);
+};
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 5ccfb4ee9c1..d86557e870a 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,19 +1,27 @@
-/* global Flash */
-
+import { visitUrl } from '../lib/utils/url_utility';
+import Flash from '../flash';
import Service from './services/sidebar_service';
import Store from './stores/sidebar_store';
export default class SidebarMediator {
constructor(options) {
if (!SidebarMediator.singleton) {
- this.store = new Store(options);
- this.service = new Service(options.endpoint);
- SidebarMediator.singleton = this;
+ this.initSingleton(options);
}
-
return SidebarMediator.singleton;
}
+ initSingleton(options) {
+ this.store = new Store(options);
+ this.service = new Service({
+ endpoint: options.endpoint,
+ toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
+ moveIssueEndpoint: options.moveIssueEndpoint,
+ projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
+ });
+ SidebarMediator.singleton = this;
+ }
+
assignYourself() {
this.store.addAssignee(this.store.currentUser);
}
@@ -26,13 +34,55 @@ export default class SidebarMediator {
return this.service.update(field, selected.length === 0 ? [0] : selected);
}
+ setMoveToProjectId(projectId) {
+ this.store.setMoveToProjectId(projectId);
+ }
+
fetch() {
- this.service.get()
- .then((response) => {
- const data = response.json();
- this.store.setAssigneeData(data);
- this.store.setTimeTrackingData(data);
+ return this.service.get()
+ .then(response => response.json())
+ .then((data) => {
+ this.processFetchedData(data);
})
- .catch(() => new Flash('Error occured when fetching sidebar data'));
+ .catch(() => new Flash('Error occurred when fetching sidebar data'));
+ }
+
+ processFetchedData(data) {
+ this.store.setAssigneeData(data);
+ this.store.setTimeTrackingData(data);
+ this.store.setParticipantsData(data);
+ this.store.setSubscriptionsData(data);
+ }
+
+ toggleSubscription() {
+ this.store.setFetchingState('subscriptions', true);
+ return this.service.toggleSubscription()
+ .then(() => {
+ this.store.setSubscribedState(!this.store.subscribed);
+ this.store.setFetchingState('subscriptions', false);
+ })
+ .catch((err) => {
+ this.store.setFetchingState('subscriptions', false);
+ throw err;
+ });
+ }
+
+ fetchAutocompleteProjects(searchTerm) {
+ return this.service.getProjectsAutocomplete(searchTerm)
+ .then(response => response.json())
+ .then((data) => {
+ this.store.setAutocompleteProjects(data);
+ return this.store.autocompleteProjects;
+ });
+ }
+
+ moveIssue() {
+ return this.service.moveIssue(this.store.moveToProjectId)
+ .then(response => response.json())
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ visitUrl(data.web_url);
+ }
+ });
}
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 3356dd0191f..f20cc6d8cca 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -1,25 +1,37 @@
export default class SidebarStore {
- constructor(store) {
+ constructor(options) {
if (!SidebarStore.singleton) {
- const { currentUser, rootPath, editable } = store;
- this.currentUser = currentUser;
- this.rootPath = rootPath;
- this.editable = editable;
- this.timeEstimate = 0;
- this.totalTimeSpent = 0;
- this.humanTimeEstimate = '';
- this.humanTimeSpent = '';
- this.assignees = [];
- this.isFetching = {
- assignees: true,
- };
-
- SidebarStore.singleton = this;
+ this.initSingleton(options);
}
return SidebarStore.singleton;
}
+ initSingleton(options) {
+ const { currentUser, rootPath, editable } = options;
+ this.currentUser = currentUser;
+ this.rootPath = rootPath;
+ this.editable = editable;
+ this.timeEstimate = 0;
+ this.totalTimeSpent = 0;
+ this.humanTimeEstimate = '';
+ this.humanTimeSpent = '';
+ this.assignees = [];
+ this.isFetching = {
+ assignees: true,
+ participants: true,
+ subscriptions: true,
+ };
+ this.isLoading = {};
+ this.autocompleteProjects = [];
+ this.moveToProjectId = 0;
+ this.isLockDialogOpen = false;
+ this.participants = [];
+ this.subscribed = null;
+
+ SidebarStore.singleton = this;
+ }
+
setAssigneeData(data) {
this.isFetching.assignees = false;
if (data.assignees) {
@@ -34,6 +46,24 @@ export default class SidebarStore {
this.humanTotalTimeSpent = data.human_total_time_spent;
}
+ setParticipantsData(data) {
+ this.isFetching.participants = false;
+ this.participants = data.participants || [];
+ }
+
+ setSubscriptionsData(data) {
+ this.isFetching.subscriptions = false;
+ this.subscribed = data.subscribed || false;
+ }
+
+ setFetchingState(key, value) {
+ this.isFetching[key] = value;
+ }
+
+ setLoadingState(key, value) {
+ this.isLoading[key] = value;
+ }
+
addAssignee(assignee) {
if (!this.findAssignee(assignee)) {
this.assignees.push(assignee);
@@ -53,4 +83,16 @@ export default class SidebarStore {
removeAllAssignees() {
this.assignees = [];
}
+
+ setAutocompleteProjects(projects) {
+ this.autocompleteProjects = projects;
+ }
+
+ setSubscribedState(subscribed) {
+ this.subscribed = subscribed;
+ }
+
+ setMoveToProjectId(moveToProjectId) {
+ this.moveToProjectId = moveToProjectId;
+ }
}
diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js
deleted file mode 100644
index 2587facc582..00000000000
--- a/app/assets/javascripts/signin_tabs_memoizer.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/* eslint no-param-reassign: ["error", { "props": false }]*/
-/* eslint no-new: "off" */
-import AccessorUtilities from './lib/utils/accessor';
-
-((global) => {
- /**
- * Memorize the last selected tab after reloading a page.
- * Does that setting the current selected tab in the localStorage
- */
- class ActiveTabMemoizer {
- constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
- this.currentTabKey = currentTabKey;
- this.tabSelector = tabSelector;
- this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-
- this.bootstrap();
- }
-
- bootstrap() {
- const tabs = document.querySelectorAll(this.tabSelector);
- if (tabs.length > 0) {
- tabs[0].addEventListener('click', (e) => {
- if (e.target && e.target.nodeName === 'A') {
- const anchorName = e.target.getAttribute('href');
- this.saveData(anchorName);
- }
- });
- }
-
- this.showTab();
- }
-
- showTab() {
- const anchorName = this.readData();
- if (anchorName) {
- const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
- if (tab) {
- tab.click();
- }
- }
- }
-
- saveData(val) {
- if (!this.isLocalStorageAvailable) return undefined;
-
- return window.localStorage.setItem(this.currentTabKey, val);
- }
-
- readData() {
- if (!this.isLocalStorageAvailable) return null;
-
- return window.localStorage.getItem(this.currentTabKey);
- }
- }
-
- global.ActiveTabMemoizer = ActiveTabMemoizer;
-})(window);
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index c44892dae3d..1afff0dba38 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,96 +1,96 @@
/* 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() {
- window.SingleFileDiff = (function() {
- var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER;
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import createFlash from './flash';
+import FilesCommentButton from './files_comment_button';
+import imageDiffHelper from './image_diff/helpers/index';
+import syntaxHighlight from './syntax_highlight';
- WRAPPER = '<div class="diff-content"></div>';
+const WRAPPER = '<div class="diff-content"></div>';
+const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
+const ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>';
+const COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <a class="click-to-expand">Click to expand it.</a></div>';
- LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
-
- ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>';
+export default class SingleFileDiff {
+ constructor(file) {
+ this.file = file;
+ 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('diffForPath');
+ this.isOpen = !this.diffForPath;
+ if (this.diffForPath) {
+ this.collapsedContent = this.content;
+ this.loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide();
+ this.content = null;
+ this.collapsedContent.after(this.loadingContent);
+ this.$toggleIcon.addClass('fa-caret-right');
+ } else {
+ this.collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide();
+ this.content.after(this.collapsedContent);
+ this.$toggleIcon.addClass('fa-caret-down');
+ }
- COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <a class="click-to-expand">Click to expand it.</a></div>';
+ $('.js-file-title, .click-to-expand', this.file).on('click', (function (e) {
+ this.toggleDiff($(e.target));
+ }).bind(this));
+ }
- function SingleFileDiff(file) {
- this.file = file;
- 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');
- this.isOpen = !this.diffForPath;
- if (this.diffForPath) {
- this.collapsedContent = this.content;
- this.loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide();
- this.content = null;
- this.collapsedContent.after(this.loadingContent);
- this.$toggleIcon.addClass('fa-caret-right');
- } else {
- this.collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide();
- this.content.after(this.collapsedContent);
- this.$toggleIcon.addClass('fa-caret-down');
+ toggleDiff($target, cb) {
+ if (!$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return;
+ this.isOpen = !this.isOpen;
+ if (!this.isOpen && !this.hasError) {
+ this.content.hide();
+ this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
+ this.collapsedContent.show();
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
}
-
- $('.js-file-title, .click-to-expand', this.file).on('click', (function (e) {
- this.toggleDiff($(e.target));
- }).bind(this));
+ } else if (this.content) {
+ this.collapsedContent.hide();
+ this.content.show();
+ this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
+ }
+ } else {
+ this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ return this.getContentHTML(cb);
}
+ }
- SingleFileDiff.prototype.toggleDiff = function($target, cb) {
- if (!$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return;
- this.isOpen = !this.isOpen;
- if (!this.isOpen && !this.hasError) {
- this.content.hide();
- this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
- this.collapsedContent.show();
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
+ getContentHTML(cb) {
+ this.collapsedContent.hide();
+ this.loadingContent.show();
+
+ axios.get(this.diffForPath)
+ .then(({ data }) => {
+ this.loadingContent.hide();
+ if (data.html) {
+ this.content = $(data.html);
+ syntaxHighlight(this.content);
+ } else {
+ this.hasError = true;
+ this.content = $(ERROR_HTML);
}
- } else if (this.content) {
- this.collapsedContent.hide();
- this.content.show();
- this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ this.collapsedContent.after(this.content);
+
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
- } else {
- this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
- return this.getContentHTML(cb);
- }
- };
- SingleFileDiff.prototype.getContentHTML = function(cb) {
- this.collapsedContent.hide();
- this.loadingContent.show();
- $.get(this.diffForPath, (function(_this) {
- return function(data) {
- _this.loadingContent.hide();
- if (data.html) {
- _this.content = $(data.html);
- _this.content.syntaxHighlight();
- } else {
- _this.hasError = true;
- _this.content = $(ERROR_HTML);
- }
- _this.collapsedContent.after(_this.content);
+ const $file = $(this.file);
+ FilesCommentButton.init($file);
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
+ const canCreateNote = $file.closest('.files').is('[data-can-create-note]');
+ imageDiffHelper.initImageDiff($file[0], canCreateNote);
- if (cb) cb();
- };
- })(this));
- };
-
- return SingleFileDiff;
- })();
-
- $.fn.singleFileDiff = function() {
- return this.each(function() {
- if (!$.data(this, 'singleFileDiff')) {
- return $.data(this, 'singleFileDiff', new window.SingleFileDiff(this));
- }
- });
- };
-}).call(window);
+ if (cb) cb();
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while retrieving diff'));
+ });
+ }
+}
diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js
index d1bdc353be2..77ab7c964e6 100644
--- a/app/assets/javascripts/smart_interval.js
+++ b/app/assets/javascripts/smart_interval.js
@@ -1,158 +1,174 @@
-/*
-* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
-* and controllable by a public API.
-*
-* */
-
-(() => {
- class SmartInterval {
- /**
- * @param { function } opts.callback Function to be called on each iteration (required)
- * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially
- * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this
- * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this
- * when the page is hidden
- * @param { integer } opts.incrementByFactorOf `currentInterval` is incremented by this factor
- * @param { boolean } opts.lazyStart Configure if timer is initialized on
- * instantiation or lazily
- * @param { boolean } opts.immediateExecution Configure if callback should
- * be executed before the first interval.
- */
- constructor(opts = {}) {
- this.cfg = {
- callback: opts.callback,
- startingInterval: opts.startingInterval,
- maxInterval: opts.maxInterval,
- hiddenInterval: opts.hiddenInterval,
- incrementByFactorOf: opts.incrementByFactorOf,
- lazyStart: opts.lazyStart,
- immediateExecution: opts.immediateExecution,
- };
-
- this.state = {
- intervalId: null,
- currentInterval: this.cfg.startingInterval,
- pageVisibility: 'visible',
- };
-
- this.initInterval();
- }
- /* public */
-
- start() {
- const cfg = this.cfg;
- const state = this.state;
-
- if (cfg.immediateExecution) {
- cfg.immediateExecution = false;
- cfg.callback();
- }
+import $ from 'jquery';
+
+/**
+ * Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
+ * and controllable by a public API.
+ */
+
+export default class SmartInterval {
+ /**
+ * @param { function } opts.callback Function that returns a promise, called on each iteration
+ * unless still in progress (required)
+ * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially
+ * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this
+ * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this
+ * when the page is hidden
+ * @param { integer } opts.incrementByFactorOf `currentInterval` is incremented by this factor
+ * @param { boolean } opts.lazyStart Configure if timer is initialized on
+ * instantiation or lazily
+ * @param { boolean } opts.immediateExecution Configure if callback should
+ * be executed before the first interval.
+ */
+ constructor(opts = {}) {
+ this.cfg = {
+ callback: opts.callback,
+ startingInterval: opts.startingInterval,
+ maxInterval: opts.maxInterval,
+ hiddenInterval: opts.hiddenInterval,
+ incrementByFactorOf: opts.incrementByFactorOf,
+ lazyStart: opts.lazyStart,
+ immediateExecution: opts.immediateExecution,
+ };
+
+ this.state = {
+ intervalId: null,
+ currentInterval: this.cfg.startingInterval,
+ pageVisibility: 'visible',
+ };
+
+ this.initInterval();
+ }
- state.intervalId = window.setInterval(() => {
- cfg.callback();
+ /* public */
- if (this.getCurrentInterval() === cfg.maxInterval) {
- return;
- }
+ start() {
+ const cfg = this.cfg;
+ const state = this.state;
- this.incrementInterval();
- this.resume();
- }, this.getCurrentInterval());
+ if (cfg.immediateExecution && !this.isLoading) {
+ cfg.immediateExecution = false;
+ this.triggerCallback();
}
- // cancel the existing timer, setting the currentInterval back to startingInterval
- cancel() {
- this.setCurrentInterval(this.cfg.startingInterval);
- this.stopTimer();
- }
+ state.intervalId = window.setInterval(() => {
+ if (this.isLoading) {
+ return;
+ }
+ this.triggerCallback();
- onVisibilityHidden() {
- if (this.cfg.hiddenInterval) {
- this.setCurrentInterval(this.cfg.hiddenInterval);
- this.resume();
- } else {
- this.cancel();
+ if (this.getCurrentInterval() === cfg.maxInterval) {
+ return;
}
- }
- // start a timer, using the existing interval
- resume() {
- this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
- this.start();
- }
+ this.incrementInterval();
+ this.resume();
+ }, this.getCurrentInterval());
+ }
- onVisibilityVisible() {
- this.cancel();
- this.start();
- }
+ // cancel the existing timer, setting the currentInterval back to startingInterval
+ cancel() {
+ this.setCurrentInterval(this.cfg.startingInterval);
+ this.stopTimer();
+ }
- destroy() {
+ onVisibilityHidden() {
+ if (this.cfg.hiddenInterval) {
+ this.setCurrentInterval(this.cfg.hiddenInterval);
+ this.resume();
+ } else {
this.cancel();
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- $(document).off('visibilitychange').off('beforeunload');
}
+ }
- /* private */
+ // start a timer, using the existing interval
+ resume() {
+ this.stopTimer(); // stop existing timer, in case timer was not previously stopped
+ this.start();
+ }
- initInterval() {
- const cfg = this.cfg;
+ onVisibilityVisible() {
+ this.cancel();
+ this.start();
+ }
- if (!cfg.lazyStart) {
- this.start();
- }
+ destroy() {
+ this.cancel();
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
+ $(document).off('visibilitychange').off('beforeunload');
+ }
- this.initVisibilityChangeHandling();
- this.initPageUnloadHandling();
- }
+ /* private */
- initVisibilityChangeHandling() {
- // cancel interval when tab no longer shown (prevents cached pages from polling)
- document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
- }
+ initInterval() {
+ const cfg = this.cfg;
- initPageUnloadHandling() {
- // TODO: Consider refactoring in light of turbolinks removal.
- // prevent interval continuing after page change, when kept in cache by Turbolinks
- $(document).on('beforeunload', () => this.cancel());
+ if (!cfg.lazyStart) {
+ this.start();
}
- handleVisibilityChange(e) {
- this.state.pageVisibility = e.target.visibilityState;
- const intervalAction = this.isPageVisible() ?
- this.onVisibilityVisible :
- this.onVisibilityHidden;
+ this.initVisibilityChangeHandling();
+ this.initPageUnloadHandling();
+ }
- intervalAction.apply(this);
- }
+ triggerCallback() {
+ this.isLoading = true;
+ this.cfg.callback()
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch((err) => {
+ this.isLoading = false;
+ throw err;
+ });
+ }
- getCurrentInterval() {
- return this.state.currentInterval;
- }
+ initVisibilityChangeHandling() {
+ // cancel interval when tab no longer shown (prevents cached pages from polling)
+ document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
+ }
- setCurrentInterval(newInterval) {
- this.state.currentInterval = newInterval;
- }
+ initPageUnloadHandling() {
+ // TODO: Consider refactoring in light of turbolinks removal.
+ // prevent interval continuing after page change, when kept in cache by Turbolinks
+ $(document).on('beforeunload', () => this.cancel());
+ }
- incrementInterval() {
- const cfg = this.cfg;
- const currentInterval = this.getCurrentInterval();
- if (cfg.hiddenInterval && !this.isPageVisible()) return;
- let nextInterval = currentInterval * cfg.incrementByFactorOf;
+ handleVisibilityChange(e) {
+ this.state.pageVisibility = e.target.visibilityState;
+ const intervalAction = this.isPageVisible() ?
+ this.onVisibilityVisible :
+ this.onVisibilityHidden;
- if (nextInterval > cfg.maxInterval) {
- nextInterval = cfg.maxInterval;
- }
+ intervalAction.apply(this);
+ }
- this.setCurrentInterval(nextInterval);
- }
+ getCurrentInterval() {
+ return this.state.currentInterval;
+ }
- isPageVisible() { return this.state.pageVisibility === 'visible'; }
+ setCurrentInterval(newInterval) {
+ this.state.currentInterval = newInterval;
+ }
- stopTimer() {
- const state = this.state;
+ incrementInterval() {
+ const cfg = this.cfg;
+ const currentInterval = this.getCurrentInterval();
+ if (cfg.hiddenInterval && !this.isPageVisible()) return;
+ let nextInterval = currentInterval * cfg.incrementByFactorOf;
- state.intervalId = window.clearInterval(state.intervalId);
+ if (nextInterval > cfg.maxInterval) {
+ nextInterval = cfg.maxInterval;
}
+
+ this.setCurrentInterval(nextInterval);
}
- gl.SmartInterval = SmartInterval;
-})(window.gl || (window.gl = {}));
+
+ isPageVisible() { return this.state.pageVisibility === 'visible'; }
+
+ stopTimer() {
+ const state = this.state;
+
+ state.intervalId = window.clearInterval(state.intervalId);
+ }
+}
+
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
index a98403f4cf2..dcee17453b8 100644
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -1,12 +1,11 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, max-len */
/* global ace */
-(function() {
- $(function() {
- var editor = ace.edit("editor");
+import $ from 'jquery';
- $(".snippet-form-holder form").on('submit', function() {
- $(".snippet-file-content").val(editor.getValue());
- });
+export default () => {
+ const editor = ace.edit('editor');
+
+ $('.snippet-form-holder form').on('submit', () => {
+ $('.snippet-file-content').val(editor.getValue());
});
-}).call(window);
+};
diff --git a/app/assets/javascripts/snippets_list.js b/app/assets/javascripts/snippets_list.js
deleted file mode 100644
index 2128007113f..00000000000
--- a/app/assets/javascripts/snippets_list.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/* eslint-disable arrow-parens, no-param-reassign, space-before-function-paren, func-names, no-var, max-len */
-
-(global => {
- global.gl = global.gl || {};
-
- gl.SnippetsList = function() {
- var $holder = $('.snippets-list-holder');
-
- $holder.find('.pagination').on('ajax:success', (e, data) => {
- $holder.replaceWith(data.html);
- });
- };
-})(window);
diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js
new file mode 100644
index 00000000000..43ef5d66422
--- /dev/null
+++ b/app/assets/javascripts/sortable/sortable_config.js
@@ -0,0 +1,7 @@
+export default {
+ animation: 200,
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+};
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index c75b44cc2fd..f5a7fdae5d7 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -1,30 +1,32 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-unused-vars, one-var, no-var, one-var-declaration-per-line, prefer-arrow-callback, no-new, max-len */
-/* global Flash */
+import $ from 'jquery';
+import Flash from './flash';
+import { __, s__ } from './locale';
+import { spriteIcon } from './lib/utils/common_utils';
+import axios from './lib/utils/axios_utils';
-(function() {
- this.Star = (function() {
- function Star() {
- $('.project-home-panel .toggle-star').on('ajax:success', function(e, data, status, xhr) {
- var $starIcon, $starSpan, $this, toggleStar;
- $this = $(this);
- $starSpan = $this.find('span');
- $starIcon = $this.find('i');
- toggleStar = function(isStarred) {
+export default class Star {
+ constructor() {
+ $('.project-home-panel .toggle-star').on('click', function toggleStarClickCallback() {
+ const $this = $(this);
+ const $starSpan = $this.find('span');
+ const $startIcon = $this.find('svg');
+
+ axios.post($this.data('endpoint'))
+ .then(({ data }) => {
+ const isStarred = $starSpan.hasClass('starred');
$this.parent().find('.star-count').text(data.star_count);
+
if (isStarred) {
- $starSpan.removeClass('starred').text('Star');
- $starIcon.removeClass('fa-star').addClass('fa-star-o');
+ $starSpan.removeClass('starred').text(s__('StarProject|Star'));
+ $startIcon.remove();
+ $this.prepend(spriteIcon('star-o'));
} else {
- $starSpan.addClass('starred').text('Unstar');
- $starIcon.removeClass('fa-star-o').addClass('fa-star');
+ $starSpan.addClass('starred').text(__('Unstar'));
+ $startIcon.remove();
+ $this.prepend(spriteIcon('star'));
}
- };
- toggleStar($starSpan.hasClass('starred'));
- }).on('ajax:error', function(e, xhr, status, error) {
- new Flash('Star toggle failed. Try again later.', 'alert');
- });
- }
-
- return Star;
- })();
-}).call(window);
+ })
+ .catch(() => Flash('Star toggle failed. Try again later.'));
+ });
+ }
+}
diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js
deleted file mode 100644
index 5f9a3e00c22..00000000000
--- a/app/assets/javascripts/subscription.js
+++ /dev/null
@@ -1,47 +0,0 @@
-(() => {
- class Subscription {
- constructor(containerElm) {
- this.containerElm = containerElm;
-
- const subscribeButton = containerElm.querySelector('.js-subscribe-button');
- if (subscribeButton) {
- // remove class so we don't bind twice
- subscribeButton.classList.remove('js-subscribe-button');
- subscribeButton.addEventListener('click', this.toggleSubscription.bind(this));
- }
- }
-
- toggleSubscription(event) {
- const button = event.currentTarget;
- const buttonSpan = button.querySelector('span');
- if (!buttonSpan || button.classList.contains('disabled')) {
- return;
- }
- button.classList.add('disabled');
-
- const isSubscribed = buttonSpan.innerHTML.trim().toLowerCase() !== 'subscribe';
- const toggleActionUrl = this.containerElm.dataset.url;
-
- $.post(toggleActionUrl, () => {
- button.classList.remove('disabled');
-
- // hack to allow this to work with the issue boards Vue object
- if (document.querySelector('html').classList.contains('issue-boards-page')) {
- gl.issueBoards.boardStoreIssueSet(
- 'subscribed',
- !gl.issueBoards.BoardsStore.detail.issue.subscribed,
- );
- } else {
- buttonSpan.innerHTML = isSubscribed ? 'Subscribe' : 'Unsubscribe';
- }
- });
- }
-
- static bindAll(selector) {
- [].forEach.call(document.querySelectorAll(selector), elm => new Subscription(elm));
- }
- }
-
- window.gl = window.gl || {};
- window.gl.Subscription = Subscription;
-})();
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index 0cd591c7320..ebe1c6dd02d 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -1,34 +1,26 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, object-shorthand, no-unused-vars, no-shadow, one-var, one-var-declaration-per-line, comma-dangle, max-len */
-(function() {
- this.SubscriptionSelect = (function() {
- function SubscriptionSelect() {
- $('.js-subscription-event').each(function(i, el) {
- var fieldName;
- fieldName = $(el).data("field-name");
- return $(el).glDropdown({
- selectable: true,
- fieldName: fieldName,
- toggleLabel: (function(_this) {
- return function(selected, el, instance) {
- var $item, label;
- label = 'Subscription';
- $item = instance.dropdown.find('.is-active');
- if ($item.length) {
- label = $item.text();
- }
- return label;
- };
- })(this),
- clicked: function(options) {
- return options.e.preventDefault();
- },
- id: function(obj, el) {
- return $(el).data("id");
- }
- });
- });
- }
+import $ from 'jquery';
- return SubscriptionSelect;
- })();
-}).call(window);
+export default function subscriptionSelect() {
+ $('.js-subscription-event').each((i, element) => {
+ const fieldName = $(element).data('fieldName');
+
+ return $(element).glDropdown({
+ selectable: true,
+ fieldName,
+ toggleLabel(selected, el, instance) {
+ let label = 'Subscription';
+ const $item = instance.dropdown.find('.is-active');
+ if ($item.length) {
+ label = $item.text();
+ }
+ return label;
+ },
+ clicked(options) {
+ return options.e.preventDefault();
+ },
+ id(obj, el) {
+ return $(el).data('id');
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 7c063fae045..f52990ba232 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,5 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len */
+import $ from 'jquery';
+
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
@@ -9,19 +11,16 @@
//
// <div class="js-syntax-highlight"></div>
//
-(function() {
- $.fn.syntaxHighlight = function() {
- var $children;
- if ($(this).hasClass('js-syntax-highlight')) {
- // Given the element itself, apply highlighting
- return $(this).addClass(gon.user_color_scheme);
- } else {
- // Given a parent element, recurse to any of its applicable children
- $children = $(this).find('.js-syntax-highlight');
- if ($children.length) {
- return $children.syntaxHighlight();
- }
+export default function syntaxHighlight(el) {
+ if ($(el).hasClass('js-syntax-highlight')) {
+ // Given the element itself, apply highlighting
+ return $(el).addClass(gon.user_color_scheme);
+ } else {
+ // Given a parent element, recurse to any of its applicable children
+ const $children = $(el).find('.js-syntax-highlight');
+ if ($children.length) {
+ return syntaxHighlight($children);
}
- };
-}).call(window);
+ }
+}
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index 419c458ff34..48782e63b9b 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,18 +1,19 @@
-/* global Flash */
-
+import $ from 'jquery';
import 'deckar01-task_list';
+import axios from './lib/utils/axios_utils';
+import Flash from './flash';
-class TaskList {
+export default class TaskList {
constructor(options = {}) {
this.selector = options.selector;
this.dataType = options.dataType;
this.fieldName = options.fieldName;
this.onSuccess = options.onSuccess || (() => {});
- this.onError = function showFlash(response) {
+ this.onError = function showFlash(e) {
let errorMessages = '';
- if (response.responseJSON) {
- errorMessages = response.responseJSON.errors.join(' ');
+ if (e.response.data && typeof e.response.data === 'object') {
+ errorMessages = e.response.data.errors.join(' ');
}
return new Flash(errorMessages || 'Update failed', 'alert');
@@ -39,15 +40,9 @@ class TaskList {
patchData[this.dataType] = {
[this.fieldName]: $target.val(),
};
- return $.ajax({
- type: 'PATCH',
- url: $target.data('update-url') || $('form.js-issuable-update').attr('action'),
- data: patchData,
- success: this.onSuccess,
- error: this.onError,
- });
+
+ return axios.patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData)
+ .then(({ data }) => this.onSuccess(data))
+ .catch(err => this.onError(err));
}
}
-
-window.gl = window.gl || {};
-window.gl.TaskList = TaskList;
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 9dd14488f22..6fea03af46a 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -1,60 +1,57 @@
-/* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */
-import Api from '../api';
+/* eslint-disable no-useless-return, max-len */
+import $ from 'jquery';
+import Api from '../api';
import TemplateSelector from '../blob/template_selector';
-((global) => {
- class IssuableTemplateSelector extends TemplateSelector {
- constructor(...args) {
- super(...args);
- this.projectPath = this.dropdown.data('project-path');
- this.namespacePath = this.dropdown.data('namespace-path');
- this.issuableType = this.$dropdownContainer.data('issuable-type');
- this.titleInput = $(`#${this.issuableType}_title`);
-
- const initialQuery = {
- name: this.dropdown.data('selected')
- };
-
- if (initialQuery.name) this.requestFile(initialQuery);
-
- $('.reset-template', this.dropdown.parent()).on('click', () => {
- this.setInputValueToTemplateContent();
- });
-
- $('.no-template', this.dropdown.parent()).on('click', () => {
- this.currentTemplate.content = '';
- this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text('Choose a template');
- });
- }
+export default class IssuableTemplateSelector extends TemplateSelector {
+ constructor(...args) {
+ super(...args);
+ this.projectPath = this.dropdown.data('projectPath');
+ this.namespacePath = this.dropdown.data('namespacePath');
+ this.issuableType = this.$dropdownContainer.data('issuableType');
+ this.titleInput = $(`#${this.issuableType}_title`);
+
+ const initialQuery = {
+ name: this.dropdown.data('selected'),
+ };
+
+ if (initialQuery.name) this.requestFile(initialQuery);
+
+ $('.reset-template', this.dropdown.parent()).on('click', () => {
+ this.setInputValueToTemplateContent();
+ });
+
+ $('.no-template', this.dropdown.parent()).on('click', () => {
+ this.currentTemplate.content = '';
+ this.setInputValueToTemplateContent();
+ $('.dropdown-toggle-text', this.dropdown).text('Choose a template');
+ });
+ }
- requestFile(query) {
- this.startLoadingSpinner();
- Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => {
- this.currentTemplate = currentTemplate;
- if (err) return; // Error handled by global AJAX error handler
- this.stopLoadingSpinner();
- this.setInputValueToTemplateContent();
- });
- return;
- }
+ requestFile(query) {
+ this.startLoadingSpinner();
+ Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => {
+ this.currentTemplate = currentTemplate;
+ this.stopLoadingSpinner();
+ if (err) return; // Error handled by global AJAX error handler
+ this.setInputValueToTemplateContent();
+ });
+ return;
+ }
- setInputValueToTemplateContent() {
- // `this.setEditorContent` sets the value of the description input field
- // to the content of the template selected.
- if (this.titleInput.val() === '') {
- // If the title has not yet been set, focus the title input and
- // skip focusing the description input by setting `true` as the
- // `skipFocus` option to `setEditorContent`.
- this.setEditorContent(this.currentTemplate, { skipFocus: true });
- this.titleInput.focus();
- } else {
- this.setEditorContent(this.currentTemplate, { skipFocus: false });
- }
- return;
+ setInputValueToTemplateContent() {
+ // `this.setEditorContent` sets the value of the description input field
+ // to the content of the template selected.
+ if (this.titleInput.val() === '') {
+ // If the title has not yet been set, focus the title input and
+ // skip focusing the description input by setting `true` as the
+ // `skipFocus` option to `setEditorContent`.
+ this.setEditorContent(this.currentTemplate, { skipFocus: true });
+ this.titleInput.focus();
+ } else {
+ this.setEditorContent(this.currentTemplate, { skipFocus: false });
}
+ return;
}
-
- global.IssuableTemplateSelector = IssuableTemplateSelector;
-})(window.gl || (window.gl = {}));
+}
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/templates/issuable_template_selectors.js
index 97f6d37364d..50e58ec5c46 100644
--- a/app/assets/javascripts/templates/issuable_template_selectors.js
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js
@@ -1,31 +1,30 @@
-/* eslint-disable no-new, comma-dangle, class-methods-use-this, no-param-reassign */
+/* eslint-disable no-new, class-methods-use-this */
-((global) => {
- class IssuableTemplateSelectors {
- constructor({ $dropdowns, editor } = {}) {
- this.$dropdowns = $dropdowns || $('.js-issuable-selector');
- this.editor = editor || this.initEditor();
+import $ from 'jquery';
+import IssuableTemplateSelector from './issuable_template_selector';
- this.$dropdowns.each((i, dropdown) => {
- const $dropdown = $(dropdown);
- new gl.IssuableTemplateSelector({
- pattern: /(\.md)/,
- data: $dropdown.data('data'),
- wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
- dropdown: $dropdown,
- editor: this.editor
- });
- });
- }
+export default class IssuableTemplateSelectors {
+ constructor({ $dropdowns, editor } = {}) {
+ this.$dropdowns = $dropdowns || $('.js-issuable-selector');
+ this.editor = editor || this.initEditor();
- initEditor() {
- const editor = $('.markdown-area');
- // Proxy ace-editor's .setValue to jQuery's .val
- editor.setValue = editor.val;
- editor.getValue = editor.val;
- return editor;
- }
+ this.$dropdowns.each((i, dropdown) => {
+ const $dropdown = $(dropdown);
+ new IssuableTemplateSelector({
+ pattern: /(\.md)/,
+ data: $dropdown.data('data'),
+ wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
+ dropdown: $dropdown,
+ editor: this.editor,
+ });
+ });
}
- global.IssuableTemplateSelectors = IssuableTemplateSelectors;
-})(window.gl || (window.gl = {}));
+ initEditor() {
+ const editor = $('.markdown-area');
+ // Proxy ace-editor's .setValue to jQuery's .val
+ editor.setValue = editor.val;
+ editor.getValue = editor.val;
+ return editor;
+ }
+}
diff --git a/app/assets/javascripts/terminal/index.js b/app/assets/javascripts/terminal/index.js
new file mode 100644
index 00000000000..1a75e072c4e
--- /dev/null
+++ b/app/assets/javascripts/terminal/index.js
@@ -0,0 +1,9 @@
+import 'vendor/xterm/encoding-indexes';
+import 'vendor/xterm/encoding';
+import Terminal from 'vendor/xterm/xterm';
+import 'vendor/xterm/fit';
+import './terminal';
+
+window.Terminal = Terminal;
+
+export default () => new gl.Terminal({ selector: '#terminal' });
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index 6b9422b1816..caffcddf3b0 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -1,13 +1,21 @@
/* global Terminal */
+import $ from 'jquery';
+
(() => {
class GLTerminal {
constructor(options) {
this.options = options || {};
- this.options.cursorBlink = options.cursorBlink || true;
- this.options.screenKeys = options.screenKeys || true;
+ if (!Object.prototype.hasOwnProperty.call(this.options, 'cursorBlink')) {
+ this.options.cursorBlink = true;
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(this.options, 'screenKeys')) {
+ this.options.screenKeys = true;
+ }
+
this.container = document.querySelector(options.selector);
this.setSocketUrl();
diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js
deleted file mode 100644
index 134522ef961..00000000000
--- a/app/assets/javascripts/terminal/terminal_bundle.js
+++ /dev/null
@@ -1,9 +0,0 @@
-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/test.js b/app/assets/javascripts/test.js
deleted file mode 100644
index c4c7918a68f..00000000000
--- a/app/assets/javascripts/test.js
+++ /dev/null
@@ -1 +0,0 @@
-$.fx.off = true;
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index ef401abce2d..a55a338eea8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,4 +1,8 @@
+import 'core-js/es6/map';
+import 'core-js/es6/set';
import simulateDrag from './simulate_drag';
+import simulateInput from './simulate_input';
// Export to global space for rspec to use
window.simulateDrag = simulateDrag;
+window.simulateInput = simulateInput;
diff --git a/app/assets/javascripts/test_utils/simulate_input.js b/app/assets/javascripts/test_utils/simulate_input.js
new file mode 100644
index 00000000000..90c1b7cb57e
--- /dev/null
+++ b/app/assets/javascripts/test_utils/simulate_input.js
@@ -0,0 +1,23 @@
+function triggerEvents(input) {
+ input.dispatchEvent(new Event('keydown'));
+ input.dispatchEvent(new Event('keypress'));
+ input.dispatchEvent(new Event('input'));
+ input.dispatchEvent(new Event('keyup'));
+}
+
+export default function simulateInput(target, text) {
+ const input = document.querySelector(target);
+ if (!input || !input.matches('textarea, input')) {
+ return false;
+ }
+
+ if (text.length > 0) {
+ Array.prototype.forEach.call(text, (char) => {
+ input.value += char;
+ triggerEvents(input);
+ });
+ } else {
+ triggerEvents(input);
+ }
+ return true;
+}
diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js
deleted file mode 100644
index 7230946b484..00000000000
--- a/app/assets/javascripts/todos.js
+++ /dev/null
@@ -1,164 +0,0 @@
-/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
-
-import UsersSelect from './users_select';
-
-class Todos {
- constructor() {
- this.initFilters();
- this.bindEvents();
- this.todo_ids = [];
-
- this.cleanupWrapper = this.cleanup.bind(this);
- document.addEventListener('beforeunload', this.cleanupWrapper);
- }
-
- cleanup() {
- this.unbindEvents();
- document.removeEventListener('beforeunload', this.cleanupWrapper);
- }
-
- unbindEvents() {
- $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
- $('.todo').off('click', this.goToTodoUrl);
- }
-
- bindEvents() {
- this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this);
- this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this);
-
- $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
- $('.todo').on('click', this.goToTodoUrl);
- }
-
- initFilters() {
- this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
- this.initFilterDropdown($('.js-type-search'), 'type');
- this.initFilterDropdown($('.js-action-search'), 'action_id');
-
- $('form.filter-form').on('submit', function applyFilters(event) {
- event.preventDefault();
- gl.utils.visitUrl(`${this.action}&${$(this).serialize()}`);
- });
- return new UsersSelect();
- }
-
- initFilterDropdown($dropdown, fieldName, searchFields) {
- $dropdown.glDropdown({
- fieldName,
- selectable: true,
- filterable: searchFields ? true : false,
- search: { fields: searchFields },
- data: $dropdown.data('data'),
- clicked: () => $dropdown.closest('form.filter-form').submit(),
- });
- }
-
- updateRowStateClicked(e) {
- e.preventDefault();
-
- const target = e.target;
- target.setAttribute('disabled', true);
- target.classList.add('disabled');
- $.ajax({
- type: 'POST',
- url: target.dataset.href,
- dataType: 'json',
- data: {
- '_method': target.dataset.method,
- },
- success: (data) => {
- this.updateRowState(target);
- return this.updateBadges(data);
- },
- });
- }
-
- updateRowState(target) {
- const row = target.closest('li');
- const restoreBtn = row.querySelector('.js-undo-todo');
- const doneBtn = row.querySelector('.js-done-todo');
-
- target.classList.add('hidden');
- target.removeAttribute('disabled');
- target.classList.remove('disabled');
-
- if (target === doneBtn) {
- row.classList.add('done-reversible');
- restoreBtn.classList.remove('hidden');
- } else if (target === restoreBtn) {
- row.classList.remove('done-reversible');
- doneBtn.classList.remove('hidden');
- } else {
- row.parentNode.removeChild(row);
- }
- }
-
- updateAllStateClicked(e) {
- e.preventDefault();
-
- const target = e.currentTarget;
- const requestData = { '_method': target.dataset.method, ids: this.todo_ids };
- target.setAttribute('disabled', true);
- target.classList.add('disabled');
- $.ajax({
- type: 'POST',
- url: target.dataset.href,
- dataType: 'json',
- data: requestData,
- success: (data) => {
- this.updateAllState(target, data);
- return this.updateBadges(data);
- },
- });
- }
-
- updateAllState(target, data) {
- const markAllDoneBtn = document.querySelector('.js-todos-mark-all');
- const undoAllBtn = document.querySelector('.js-todos-undo-all');
- const todoListContainer = document.querySelector('.js-todos-list-container');
- const nothingHereContainer = document.querySelector('.js-nothing-here-container');
-
- target.removeAttribute('disabled');
- target.classList.remove('disabled');
-
- this.todo_ids = (target === markAllDoneBtn) ? data.updated_ids : [];
- undoAllBtn.classList.toggle('hidden');
- markAllDoneBtn.classList.toggle('hidden');
- todoListContainer.classList.toggle('hidden');
- nothingHereContainer.classList.toggle('hidden');
- }
-
- updateBadges(data) {
- $(document).trigger('todo:toggle', data.count);
- document.querySelector('.todos-pending .badge').innerHTML = data.count;
- document.querySelector('.todos-done .badge').innerHTML = data.done_count;
- }
-
- goToTodoUrl(e) {
- const todoLink = this.dataset.url;
-
- if (!todoLink) {
- return;
- }
-
- if (gl.utils.isMetaClick(e)) {
- const windowTarget = '_blank';
- const selected = e.target;
- e.preventDefault();
-
- if (selected.tagName === 'IMG') {
- const avatarUrl = selected.parentElement.getAttribute('href');
- window.open(avatarUrl, windowTarget);
- } else {
- window.open(todoLink, windowTarget);
- }
- } else {
- gl.utils.visitUrl(todoLink);
- }
- }
-}
-
-window.gl = window.gl || {};
-gl.Todos = Todos;
diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js
new file mode 100644
index 00000000000..199b14458ed
--- /dev/null
+++ b/app/assets/javascripts/toggle_buttons.js
@@ -0,0 +1,61 @@
+import $ from 'jquery';
+import Flash from './flash';
+import { __ } from './locale';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
+
+/*
+ example HAML:
+ ```
+ %button.js-project-feature-toggle.project-feature-toggle{ type: "button",
+ class: "#{'is-checked' if enabled?}",
+ 'aria-label': _('Toggle Kubernetes Cluster') }
+ %input{ type: "hidden", class: 'js-project-feature-toggle-input', value: enabled? }
+ ```
+*/
+
+function updateToggle(toggle, isOn) {
+ toggle.classList.toggle('is-checked', isOn);
+}
+
+function onToggleClicked(toggle, input, clickCallback) {
+ const previousIsOn = convertPermissionToBoolean(input.value);
+
+ // Visually change the toggle and start loading
+ updateToggle(toggle, !previousIsOn);
+ toggle.setAttribute('disabled', true);
+ toggle.classList.toggle('is-loading', true);
+
+ Promise.resolve(clickCallback(!previousIsOn, toggle))
+ .then(() => {
+ // Actually change the input value
+ input.setAttribute('value', !previousIsOn);
+ })
+ .catch(() => {
+ // Revert the visuals if something goes wrong
+ updateToggle(toggle, previousIsOn);
+ })
+ .then(() => {
+ // Remove the loading indicator in any case
+ toggle.removeAttribute('disabled');
+ toggle.classList.toggle('is-loading', false);
+
+ $(input).trigger('trigger-change');
+ })
+ .catch(() => {
+ Flash(__('Something went wrong when toggling the button'));
+ });
+}
+
+export default function setupToggleButtons(container, clickCallback = () => {}) {
+ const toggles = container.querySelectorAll('.js-project-feature-toggle');
+
+ toggles.forEach((toggle) => {
+ const input = toggle.querySelector('.js-project-feature-toggle-input');
+ const isOn = convertPermissionToBoolean(input.value);
+
+ // Get the visible toggle in sync with the hidden input
+ updateToggle(toggle, isOn);
+
+ toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback));
+ });
+}
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 76a821c7a17..afbb958d058 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,68 +1,67 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, quotes, consistent-return, no-var, one-var, one-var-declaration-per-line, no-else-return, prefer-arrow-callback, max-len */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, quotes, consistent-return, no-var, one-var, one-var-declaration-per-line, no-else-return, prefer-arrow-callback, class-methods-use-this */
-(function() {
- this.TreeView = (function() {
- function TreeView() {
- this.initKeyNav();
- // Code browser tree slider
- // Make the entire tree-item row clickable, but not if clicking another link (like a commit message)
- $(".tree-content-holder .tree-item").on('click', function(e) {
- var $clickedEl, path;
- $clickedEl = $(e.target);
- path = $('.tree-item-file-name a', this).attr('href');
- if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
- if (e.metaKey || e.which === 2) {
- e.preventDefault();
- return window.open(path, '_blank');
- } else {
- return gl.utils.visitUrl(path);
- }
- }
- });
- // Show the "Loading commit data" for only the first element
- $('span.log_loading:first').removeClass('hide');
- }
+import $ from 'jquery';
+import { visitUrl } from './lib/utils/url_utility';
- TreeView.prototype.initKeyNav = function() {
- var li, liSelected;
- li = $("tr.tree-item");
- liSelected = null;
- return $('body').keydown(function(e) {
- var next, path;
- if ($("input:focus").length > 0 && (e.which === 38 || e.which === 40)) {
- return false;
+export default class TreeView {
+ constructor() {
+ this.initKeyNav();
+ // Code browser tree slider
+ // Make the entire tree-item row clickable, but not if clicking another link (like a commit message)
+ $(".tree-content-holder .tree-item").on('click', function(e) {
+ var $clickedEl, path;
+ $clickedEl = $(e.target);
+ path = $('.tree-item-file-name a', this).attr('href');
+ if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
+ if (e.metaKey || e.which === 2) {
+ e.preventDefault();
+ return window.open(path, '_blank');
+ } else {
+ return visitUrl(path);
}
- if (e.which === 40) {
- if (liSelected) {
- next = liSelected.next();
- if (next.length > 0) {
- liSelected.removeClass("selected");
- liSelected = next.addClass("selected");
- }
- } else {
- liSelected = li.eq(0).addClass("selected");
- }
- return $(liSelected).focus();
- } else if (e.which === 38) {
- if (liSelected) {
- next = liSelected.prev();
- if (next.length > 0) {
- liSelected.removeClass("selected");
- liSelected = next.addClass("selected");
- }
- } else {
- liSelected = li.last().addClass("selected");
+ }
+ });
+ // Show the "Loading commit data" for only the first element
+ $('span.log_loading:first').removeClass('hide');
+ }
+
+ initKeyNav() {
+ var li, liSelected;
+ li = $("tr.tree-item");
+ liSelected = null;
+ return $('body').keydown(function(e) {
+ var next, path;
+ if ($("input:focus").length > 0 && (e.which === 38 || e.which === 40)) {
+ return false;
+ }
+ if (e.which === 40) {
+ if (liSelected) {
+ next = liSelected.next();
+ if (next.length > 0) {
+ liSelected.removeClass("selected");
+ liSelected = next.addClass("selected");
}
- return $(liSelected).focus();
- } else if (e.which === 13) {
- path = $('.tree-item.selected .tree-item-file-name a').attr('href');
- if (path) {
- return gl.utils.visitUrl(path);
+ } else {
+ liSelected = li.eq(0).addClass("selected");
+ }
+ return $(liSelected).focus();
+ } else if (e.which === 38) {
+ if (liSelected) {
+ next = liSelected.prev();
+ if (next.length > 0) {
+ liSelected.removeClass("selected");
+ liSelected = next.addClass("selected");
}
+ } else {
+ liSelected = li.last().addClass("selected");
}
- });
- };
-
- return TreeView;
- })();
-}).call(window);
+ return $(liSelected).focus();
+ } else if (e.which === 13) {
+ path = $('.tree-item.selected .tree-item-file-name a').attr('href');
+ if (path) {
+ return visitUrl(path);
+ }
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js
index cd5280948fd..96af6d2fcca 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/u2f/authenticate.js
@@ -1,116 +1,109 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, prefer-arrow-callback, no-else-return, quotes, quote-props, comma-dangle, one-var, one-var-declaration-per-line, max-len */
-/* global u2f */
-/* global U2FError */
-/* global U2FUtil */
+import $ from 'jquery';
+import _ from 'underscore';
+import importU2FLibrary from './util';
+import U2FError from './error';
// Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
//
// State Flow #1: setup -> in_progress -> authenticated -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
-(function() {
- const global = window.gl || (window.gl = {});
-
- global.U2FAuthenticate = (function() {
- function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) {
- this.container = container;
- 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;
- this.fallbackButton = fallbackButton;
- this.fallbackUI = fallbackUI;
- if (this.fallbackButton) this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this));
- this.signRequests = u2fParams.sign_requests.map(function(request) {
- // The U2F Javascript API v1.1 requires a single challenge, with
- // _no challenges per-request_. The U2F Javascript API v1.0 requires a
- // challenge per-request, which is done by copying the single challenge
- // into every request.
- //
- // In either case, we don't need the per-request challenges that the server
- // has generated, so we can remove them.
- //
- // Note: The server library fixes this behaviour in (unreleased) version 1.0.0.
- // This can be removed once we upgrade.
- // https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4
- return _(request).omit('challenge');
- });
+export default class U2FAuthenticate {
+ constructor(container, form, u2fParams, fallbackButton, fallbackUI) {
+ this.u2fUtils = null;
+ this.container = container;
+ 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;
+ this.fallbackButton = fallbackButton;
+ this.fallbackUI = fallbackUI;
+ if (this.fallbackButton) {
+ this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this));
}
- U2FAuthenticate.prototype.start = function() {
- if (U2FUtil.isU2FSupported()) {
- return this.renderInProgress();
- } else {
- return this.renderNotSupported();
- }
- };
+ // The U2F Javascript API v1.1 requires a single challenge, with
+ // _no challenges per-request_. The U2F Javascript API v1.0 requires a
+ // challenge per-request, which is done by copying the single challenge
+ // into every request.
+ //
+ // In either case, we don't need the per-request challenges that the server
+ // has generated, so we can remove them.
+ //
+ // Note: The server library fixes this behaviour in (unreleased) version 1.0.0.
+ // This can be removed once we upgrade.
+ // https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4
+ this.signRequests = u2fParams.sign_requests.map(request => _(request).omit('challenge'));
- U2FAuthenticate.prototype.authenticate = function() {
- return u2f.sign(this.appId, this.challenge, this.signRequests, (function(_this) {
- return function(response) {
- var error;
- if (response.errorCode) {
- error = new U2FError(response.errorCode, 'authenticate');
- return _this.renderError(error);
- } else {
- return _this.renderAuthenticated(JSON.stringify(response));
- }
- };
- })(this), 10);
+ this.templates = {
+ notSupported: '#js-authenticate-u2f-not-supported',
+ setup: '#js-authenticate-u2f-setup',
+ inProgress: '#js-authenticate-u2f-in-progress',
+ error: '#js-authenticate-u2f-error',
+ authenticated: '#js-authenticate-u2f-authenticated',
};
+ }
- // Rendering #
- U2FAuthenticate.prototype.templates = {
- "notSupported": "#js-authenticate-u2f-not-supported",
- "setup": '#js-authenticate-u2f-setup',
- "inProgress": '#js-authenticate-u2f-in-progress',
- "error": '#js-authenticate-u2f-error',
- "authenticated": '#js-authenticate-u2f-authenticated'
- };
+ start() {
+ return importU2FLibrary()
+ .then((utils) => {
+ this.u2fUtils = utils;
+ this.renderInProgress();
+ })
+ .catch(() => this.renderNotSupported());
+ }
- U2FAuthenticate.prototype.renderTemplate = function(name, params) {
- var template, templateString;
- templateString = $(this.templates[name]).html();
- template = _.template(templateString);
- return this.container.html(template(params));
- };
+ authenticate() {
+ return this.u2fUtils.sign(this.appId, this.challenge, this.signRequests,
+ (response) => {
+ if (response.errorCode) {
+ const error = new U2FError(response.errorCode, 'authenticate');
+ return this.renderError(error);
+ }
+ return this.renderAuthenticated(JSON.stringify(response));
+ }, 10);
+ }
- U2FAuthenticate.prototype.renderInProgress = function() {
- this.renderTemplate('inProgress');
- return this.authenticate();
- };
+ renderTemplate(name, params) {
+ const templateString = $(this.templates[name]).html();
+ const template = _.template(templateString);
+ return this.container.html(template(params));
+ }
- U2FAuthenticate.prototype.renderError = function(error) {
- this.renderTemplate('error', {
- error_message: error.message(),
- error_code: error.errorCode
- });
- return this.container.find('#js-u2f-try-again').on('click', this.renderInProgress);
- };
+ renderInProgress() {
+ this.renderTemplate('inProgress');
+ return this.authenticate();
+ }
- U2FAuthenticate.prototype.renderAuthenticated = function(deviceResponse) {
- this.renderTemplate('authenticated');
- const container = this.container[0];
- container.querySelector('#js-device-response').value = deviceResponse;
- container.querySelector(this.form).submit();
- this.fallbackButton.classList.add('hidden');
- };
+ renderError(error) {
+ this.renderTemplate('error', {
+ error_message: error.message(),
+ error_code: error.errorCode,
+ });
+ return this.container.find('#js-u2f-try-again').on('click', this.renderInProgress);
+ }
- U2FAuthenticate.prototype.renderNotSupported = function() {
- return this.renderTemplate('notSupported');
- };
+ renderAuthenticated(deviceResponse) {
+ this.renderTemplate('authenticated');
+ const container = this.container[0];
+ container.querySelector('#js-device-response').value = deviceResponse;
+ container.querySelector(this.form).submit();
+ this.fallbackButton.classList.add('hidden');
+ }
- U2FAuthenticate.prototype.switchToFallbackUI = function() {
- this.fallbackButton.classList.add('hidden');
- this.container[0].classList.add('hidden');
- this.fallbackUI.classList.remove('hidden');
- };
+ renderNotSupported() {
+ return this.renderTemplate('notSupported');
+ }
+
+ switchToFallbackUI() {
+ this.fallbackButton.classList.add('hidden');
+ this.container[0].classList.add('hidden');
+ this.fallbackUI.classList.remove('hidden');
+ }
- return U2FAuthenticate;
- })();
-})();
+}
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js
index 3119b3480c3..1a98564ff55 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/u2f/error.js
@@ -1,25 +1,22 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-console, quotes, prefer-template, max-len */
-/* global u2f */
+export default class U2FError {
+ constructor(errorCode, u2fFlowType) {
+ this.errorCode = errorCode;
+ this.message = this.message.bind(this);
+ this.httpsDisabled = window.location.protocol !== 'https:';
+ this.u2fFlowType = u2fFlowType;
+ }
-(function() {
- this.U2FError = (function() {
- function U2FError(errorCode, u2fFlowType) {
- this.errorCode = errorCode;
- this.message = this.message.bind(this);
- this.httpsDisabled = window.location.protocol !== 'https:';
- this.u2fFlowType = u2fFlowType;
- }
-
- U2FError.prototype.message = function() {
- if (this.errorCode === u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) {
- return 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.';
- } else if (this.errorCode === u2f.ErrorCodes.DEVICE_INELIGIBLE) {
- if (this.u2fFlowType === 'authenticate') return 'This device has not been registered with us.';
- if (this.u2fFlowType === 'register') return 'This device has already been registered with us.';
+ message() {
+ if (this.errorCode === window.u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) {
+ return 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.';
+ } else if (this.errorCode === window.u2f.ErrorCodes.DEVICE_INELIGIBLE) {
+ if (this.u2fFlowType === 'authenticate') {
+ return 'This device has not been registered with us.';
}
- return "There was a problem communicating with your device.";
- };
-
- return U2FError;
- })();
-}).call(window);
+ if (this.u2fFlowType === 'register') {
+ return 'This device has already been registered with us.';
+ }
+ }
+ return 'There was a problem communicating with your device.';
+ }
+}
diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js
index 1234d17b8fd..01e259a741d 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/u2f/register.js
@@ -1,96 +1,89 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-else-return, quotes, quote-props, comma-dangle, one-var, one-var-declaration-per-line, max-len */
-/* global u2f */
-/* global U2FError */
-/* global U2FUtil */
+import $ from 'jquery';
+import _ from 'underscore';
+import importU2FLibrary from './util';
+import U2FError from './error';
// Register U2F (universal 2nd factor) devices for users to authenticate with.
//
// State Flow #1: setup -> in_progress -> registered -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
-(function() {
- this.U2FRegister = (function() {
- function U2FRegister(container, u2fParams) {
- this.container = container;
- 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;
- }
+export default class U2FRegister {
+ constructor(container, u2fParams) {
+ this.u2fUtils = null;
+ this.container = container;
+ 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;
- U2FRegister.prototype.start = function() {
- if (U2FUtil.isU2FSupported()) {
- return this.renderSetup();
- } else {
- return this.renderNotSupported();
- }
+ this.templates = {
+ notSupported: '#js-register-u2f-not-supported',
+ setup: '#js-register-u2f-setup',
+ inProgress: '#js-register-u2f-in-progress',
+ error: '#js-register-u2f-error',
+ registered: '#js-register-u2f-registered',
};
+ }
- U2FRegister.prototype.register = function() {
- return u2f.register(this.appId, this.registerRequests, this.signRequests, (function(_this) {
- return function(response) {
- var error;
- if (response.errorCode) {
- error = new U2FError(response.errorCode, 'register');
- return _this.renderError(error);
- } else {
- return _this.renderRegistered(JSON.stringify(response));
- }
- };
- })(this), 10);
- };
+ start() {
+ return importU2FLibrary()
+ .then((utils) => {
+ this.u2fUtils = utils;
+ this.renderSetup();
+ })
+ .catch(() => this.renderNotSupported());
+ }
- // Rendering #
- U2FRegister.prototype.templates = {
- "notSupported": "#js-register-u2f-not-supported",
- "setup": '#js-register-u2f-setup',
- "inProgress": '#js-register-u2f-in-progress',
- "error": '#js-register-u2f-error',
- "registered": '#js-register-u2f-registered'
- };
+ register() {
+ return this.u2fUtils.register(this.appId, this.registerRequests, this.signRequests,
+ (response) => {
+ if (response.errorCode) {
+ const error = new U2FError(response.errorCode, 'register');
+ return this.renderError(error);
+ }
+ return this.renderRegistered(JSON.stringify(response));
+ }, 10);
+ }
- U2FRegister.prototype.renderTemplate = function(name, params) {
- var template, templateString;
- templateString = $(this.templates[name]).html();
- template = _.template(templateString);
- return this.container.html(template(params));
- };
+ renderTemplate(name, params) {
+ const templateString = $(this.templates[name]).html();
+ const template = _.template(templateString);
+ return this.container.html(template(params));
+ }
- U2FRegister.prototype.renderSetup = function() {
- this.renderTemplate('setup');
- return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress);
- };
+ renderSetup() {
+ this.renderTemplate('setup');
+ return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress);
+ }
- U2FRegister.prototype.renderInProgress = function() {
- this.renderTemplate('inProgress');
- return this.register();
- };
+ renderInProgress() {
+ this.renderTemplate('inProgress');
+ return this.register();
+ }
- U2FRegister.prototype.renderError = function(error) {
- this.renderTemplate('error', {
- error_message: error.message(),
- error_code: error.errorCode
- });
- return this.container.find('#js-u2f-try-again').on('click', this.renderSetup);
- };
-
- U2FRegister.prototype.renderRegistered = function(deviceResponse) {
- this.renderTemplate('registered');
- // Prefer to do this instead of interpolating using Underscore templates
- // because of JSON escaping issues.
- return this.container.find("#js-device-response").val(deviceResponse);
- };
+ renderError(error) {
+ this.renderTemplate('error', {
+ error_message: error.message(),
+ error_code: error.errorCode,
+ });
+ return this.container.find('#js-u2f-try-again').on('click', this.renderSetup);
+ }
- U2FRegister.prototype.renderNotSupported = function() {
- return this.renderTemplate('notSupported');
- };
+ renderRegistered(deviceResponse) {
+ this.renderTemplate('registered');
+ // Prefer to do this instead of interpolating using Underscore templates
+ // because of JSON escaping issues.
+ return this.container.find('#js-device-response').val(deviceResponse);
+ }
- return U2FRegister;
- })();
-}).call(window);
+ renderNotSupported() {
+ return this.renderTemplate('notSupported');
+ }
+}
diff --git a/app/assets/javascripts/u2f/util.js b/app/assets/javascripts/u2f/util.js
index 813d363db00..5778f00332d 100644
--- a/app/assets/javascripts/u2f/util.js
+++ b/app/assets/javascripts/u2f/util.js
@@ -1,12 +1,41 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife */
-(function() {
- this.U2FUtil = (function() {
- function U2FUtil() {}
-
- U2FUtil.isU2FSupported = function() {
- return window.u2f;
- };
-
- return U2FUtil;
- })();
-}).call(window);
+function isOpera(userAgent) {
+ return userAgent.indexOf('Opera') >= 0 || userAgent.indexOf('OPR') >= 0;
+}
+
+function getOperaVersion(userAgent) {
+ const match = userAgent.match(/OPR[^0-9]*([0-9]+)[^0-9]+/);
+ return match ? parseInt(match[1], 10) : false;
+}
+
+function isChrome(userAgent) {
+ return userAgent.indexOf('Chrom') >= 0 && !isOpera(userAgent);
+}
+
+function getChromeVersion(userAgent) {
+ const match = userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
+ return match ? parseInt(match[1], 10) : false;
+}
+
+export function canInjectU2fApi(userAgent) {
+ const isSupportedChrome = isChrome(userAgent) && getChromeVersion(userAgent) >= 41;
+ const isSupportedOpera = isOpera(userAgent) && getOperaVersion(userAgent) >= 40;
+ const isMobile = (
+ userAgent.indexOf('droid') >= 0 ||
+ userAgent.indexOf('CriOS') >= 0 ||
+ /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent)
+ );
+ return (isSupportedChrome || isSupportedOpera) && !isMobile;
+}
+
+export default function importU2FLibrary() {
+ if (window.u2f) {
+ return Promise.resolve(window.u2f);
+ }
+
+ const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
+ if (canInjectU2fApi(userAgent) || (gon && gon.test_env)) {
+ return import(/* webpackMode: "eager" */ 'vendor/u2f').then(() => window.u2f);
+ }
+
+ return Promise.reject();
+}
diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js
new file mode 100644
index 00000000000..9b242ea779d
--- /dev/null
+++ b/app/assets/javascripts/ui_development_kit.js
@@ -0,0 +1,23 @@
+import $ from 'jquery';
+import Api from './api';
+
+export default () => {
+ $('#js-project-dropdown').glDropdown({
+ data: (term, callback) => {
+ Api.projects(term, {
+ order_by: 'last_activity_at',
+ }, (data) => {
+ callback(data);
+ });
+ },
+ text: project => (project.name_with_namespace || project.name),
+ selectable: true,
+ fieldName: 'author_id',
+ filterable: true,
+ search: {
+ fields: ['name_with_namespace'],
+ },
+ id: data => data.id,
+ isSelected: data => (data.id === 2),
+ });
+};
diff --git a/app/assets/javascripts/usage_ping.js b/app/assets/javascripts/usage_ping.js
deleted file mode 100644
index fd3af7d7ab6..00000000000
--- a/app/assets/javascripts/usage_ping.js
+++ /dev/null
@@ -1,15 +0,0 @@
-function UsagePing() {
- const usageDataUrl = $('.usage-data').data('endpoint');
-
- $.ajax({
- type: 'GET',
- url: usageDataUrl,
- dataType: 'html',
- success(html) {
- $('.usage-data').html(html);
- },
- });
-}
-
-window.gl = window.gl || {};
-window.gl.UsagePing = UsagePing;
diff --git a/app/assets/javascripts/user.js b/app/assets/javascripts/user.js
deleted file mode 100644
index 19c9efe7fbd..00000000000
--- a/app/assets/javascripts/user.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/* eslint-disable class-methods-use-this, comma-dangle, arrow-parens, no-param-reassign */
-
-import Cookies from 'js-cookie';
-
-((global) => {
- global.User = class {
- constructor({ action }) {
- this.action = action;
- this.placeProfileAvatarsToTop();
- this.initTabs();
- this.hideProjectLimitMessage();
- }
-
- placeProfileAvatarsToTop() {
- $('.profile-groups-avatars').tooltip({
- placement: 'top'
- });
- }
-
- initTabs() {
- return new global.UserTabs({
- parentEl: '.user-profile',
- action: this.action
- });
- }
-
- hideProjectLimitMessage() {
- $('.hide-project-limit-message').on('click', e => {
- e.preventDefault();
- Cookies.set('hide_project_limit_message', 'false');
- $(this).parents('.project-limit-message').remove();
- });
- }
- };
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index ff2208baeab..97d5cf96bcb 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,7 +1,12 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
export default class UserCallout {
- constructor(className = 'user-callout') {
+ constructor(options = {}) {
+ this.options = options;
+
+ const className = this.options.className || 'user-callout';
+
this.userCalloutBody = $(`.${className}`);
this.cookieName = this.userCalloutBody.data('uid');
this.isCalloutDismissed = Cookies.get(this.cookieName);
@@ -17,7 +22,11 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
- Cookies.set(this.cookieName, 'true', { expires: 365 });
+ if (this.options.setCalloutPerProject) {
+ Cookies.set(this.cookieName, 'true', { expires: 365, path: this.userCalloutBody.data('projectPath') });
+ } else {
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
+ }
if ($currentTarget.hasClass('close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/user_tabs.js b/app/assets/javascripts/user_tabs.js
deleted file mode 100644
index ce7eb76dc71..00000000000
--- a/app/assets/javascripts/user_tabs.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/* eslint-disable max-len, space-before-function-paren, no-underscore-dangle, consistent-return, comma-dangle, no-unused-vars, dot-notation, no-new, no-return-assign, camelcase, no-param-reassign, class-methods-use-this */
-
-/*
-UserTabs
-
-Handles persisting and restoring the current tab selection and lazily-loading
-content on the Users#show page.
-
-### Example Markup
-
- <ul class="nav-links">
- <li class="activity-tab active">
- <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username">
- Activity
- </a>
- </li>
- <li class="groups-tab">
- <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups">
- Groups
- </a>
- </li>
- <li class="contributed-tab">
- <a data-action="contributed" data-target="#contributed" data-toggle="tab" href="/u/username/contributed">
- Contributed projects
- </a>
- </li>
- <li class="projects-tab">
- <a data-action="projects" data-target="#projects" data-toggle="tab" href="/u/username/projects">
- Personal projects
- </a>
- </li>
- <li class="snippets-tab">
- <a data-action="snippets" data-target="#snippets" data-toggle="tab" href="/u/username/snippets">
- </a>
- </li>
- </ul>
-
- <div class="tab-content">
- <div class="tab-pane" id="activity">
- Activity Content
- </div>
- <div class="tab-pane" id="groups">
- Groups Content
- </div>
- <div class="tab-pane" id="contributed">
- Contributed projects content
- </div>
- <div class="tab-pane" id="projects">
- Projects content
- </div>
- <div class="tab-pane" id="snippets">
- Snippets content
- </div>
- </div>
-
- <div class="loading-status">
- <div class="loading">
- Loading Animation
- </div>
- </div>
-*/
-((global) => {
- class UserTabs {
- constructor ({ defaultAction, action, parentEl }) {
- this.loaded = {};
- this.defaultAction = defaultAction || 'activity';
- this.action = action || this.defaultAction;
- this.$parentEl = $(parentEl) || $(document);
- this._location = window.location;
- this.$parentEl.find('.nav-links a')
- .each((i, navLink) => {
- this.loaded[$(navLink).attr('data-action')] = false;
- });
- this.actions = Object.keys(this.loaded);
- this.bindEvents();
-
- if (this.action === 'show') {
- this.action = this.defaultAction;
- }
-
- this.activateTab(this.action);
- }
-
- bindEvents() {
- this.changeProjectsPageWrapper = this.changeProjectsPage.bind(this);
-
- this.$parentEl.off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
- .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event));
-
- this.$parentEl.on('click', '.gl-pagination a', this.changeProjectsPageWrapper);
- }
-
- changeProjectsPage(e) {
- e.preventDefault();
-
- $('.tab-pane.active').empty();
- const endpoint = $(e.target).attr('href');
- this.loadTab(this.getCurrentAction(), endpoint);
- }
-
- tabShown(event) {
- const $target = $(event.target);
- const action = $target.data('action');
- const source = $target.attr('href');
- const endpoint = $target.data('endpoint');
- this.setTab(action, endpoint);
- return this.setCurrentAction(source);
- }
-
- activateTab(action) {
- return this.$parentEl.find(`.nav-links .js-${action}-tab a`)
- .tab('show');
- }
-
- setTab(action, endpoint) {
- if (this.loaded[action]) {
- return;
- }
- if (action === 'activity') {
- this.loadActivities();
- }
-
- const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
- if (loadableActions.indexOf(action) > -1) {
- return this.loadTab(action, endpoint);
- }
- }
-
- loadTab(action, endpoint) {
- return $.ajax({
- beforeSend: () => this.toggleLoading(true),
- complete: () => this.toggleLoading(false),
- dataType: 'json',
- type: 'GET',
- url: endpoint,
- success: (data) => {
- const tabSelector = `div#${action}`;
- this.$parentEl.find(tabSelector).html(data.html);
- this.loaded[action] = true;
- return gl.utils.localTimeAgo($('.js-timeago', tabSelector));
- }
- });
- }
-
- loadActivities() {
- if (this.loaded['activity']) {
- return;
- }
- const $calendarWrap = this.$parentEl.find('.user-calendar');
- $calendarWrap.load($calendarWrap.data('href'));
- new gl.Activities();
- return this.loaded['activity'] = true;
- }
-
- toggleLoading(status) {
- return this.$parentEl.find('.loading-status .loading')
- .toggle(status);
- }
-
- setCurrentAction(source) {
- let new_state = source;
- new_state = new_state.replace(/\/+$/, '');
- new_state += this._location.search + this._location.hash;
- history.replaceState({
- url: new_state
- }, document.title, new_state);
- return new_state;
- }
-
- getCurrentAction() {
- return this.$parentEl.find('.nav-links .active a').data('action');
- }
- }
- global.UserTabs = UserTabs;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/username_validator.js b/app/assets/javascripts/username_validator.js
deleted file mode 100644
index 137cefa3b8e..00000000000
--- a/app/assets/javascripts/username_validator.js
+++ /dev/null
@@ -1,135 +0,0 @@
-/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */
-
-((global) => {
- const debounceTimeoutDuration = 1000;
- const invalidInputClass = 'gl-field-error-outline';
- const successInputClass = 'gl-field-success-outline';
- const unavailableMessageSelector = '.username .validation-error';
- const successMessageSelector = '.username .validation-success';
- const pendingMessageSelector = '.username .validation-pending';
- const invalidMessageSelector = '.username .gl-field-error';
-
- class UsernameValidator {
- constructor() {
- this.inputElement = $('#new_user_username');
- this.inputDomElement = this.inputElement.get(0);
- this.state = {
- available: false,
- valid: false,
- pending: false,
- empty: true
- };
-
- const debounceTimeout = _.debounce((username) => {
- this.validateUsername(username);
- }, debounceTimeoutDuration);
-
- this.inputElement.on('keyup.username_check', () => {
- const username = this.inputElement.val();
-
- this.state.valid = this.inputDomElement.validity.valid;
- this.state.empty = !username.length;
-
- if (this.state.valid) {
- return debounceTimeout(username);
- }
-
- this.renderState();
- });
-
- // Override generic field validation
- this.inputElement.on('invalid', this.interceptInvalid.bind(this));
- }
-
- renderState() {
- // Clear all state
- this.clearFieldValidationState();
-
- if (this.state.valid && this.state.available) {
- return this.setSuccessState();
- }
-
- if (this.state.empty) {
- return this.clearFieldValidationState();
- }
-
- if (this.state.pending) {
- return this.setPendingState();
- }
-
- if (!this.state.available) {
- return this.setUnavailableState();
- }
-
- if (!this.state.valid) {
- return this.setInvalidState();
- }
- }
-
- interceptInvalid(event) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- validateUsername(username) {
- if (this.state.valid) {
- this.state.pending = true;
- this.state.available = false;
- this.renderState();
- return $.ajax({
- type: 'GET',
- url: `${gon.relative_url_root}/users/${username}/exists`,
- dataType: 'json',
- success: (res) => this.setAvailabilityState(res.exists)
- });
- }
- }
-
- setAvailabilityState(usernameTaken) {
- if (usernameTaken) {
- this.state.valid = false;
- this.state.available = false;
- } else {
- this.state.available = true;
- }
- this.state.pending = false;
- this.renderState();
- }
-
- clearFieldValidationState() {
- this.inputElement.siblings('p').hide();
-
- this.inputElement.removeClass(invalidInputClass)
- .removeClass(successInputClass);
- }
-
- setUnavailableState() {
- const $usernameUnavailableMessage = this.inputElement.siblings(unavailableMessageSelector);
- this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
- $usernameUnavailableMessage.show();
- }
-
- setSuccessState() {
- const $usernameSuccessMessage = this.inputElement.siblings(successMessageSelector);
- this.inputElement.addClass(successInputClass).removeClass(invalidInputClass);
- $usernameSuccessMessage.show();
- }
-
- setPendingState() {
- const $usernamePendingMessage = $(pendingMessageSelector);
- if (this.state.pending) {
- $usernamePendingMessage.show();
- } else {
- $usernamePendingMessage.hide();
- }
- }
-
- setInvalidState() {
- const $inputErrorMessage = $(invalidMessageSelector);
- this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
- $inputErrorMessage.show();
- }
- }
-
- global.UsernameValidator = UsernameValidator;
-})(window);
diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js
deleted file mode 100644
index b11f691e424..00000000000
--- a/app/assets/javascripts/users/calendar.js
+++ /dev/null
@@ -1,231 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, camelcase, vars-on-top, object-shorthand, comma-dangle, eqeqeq, no-mixed-operators, no-return-assign, newline-per-chained-call, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, no-else-return, max-len */
-
-import d3 from 'd3';
-
-(function() {
- this.Calendar = (function() {
- function Calendar(timestamps, calendar_activities_path) {
- this.calendar_activities_path = calendar_activities_path;
- this.clickDay = this.clickDay.bind(this);
- this.currentSelectedDate = '';
- this.daySpace = 1;
- this.daySize = 15;
- this.daySizeWithSpace = this.daySize + (this.daySpace * 2);
- this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
- this.months = [];
- // Loop through the timestamps to create a group of objects
- // The group of objects will be grouped based on the day of the week they are
- this.timestampsTmp = [];
- var group = 0;
-
- var today = new Date();
- today.setHours(0, 0, 0, 0, 0);
-
- var oneYearAgo = new Date(today);
- oneYearAgo.setFullYear(today.getFullYear() - 1);
-
- var days = gl.utils.getDayDifference(oneYearAgo, today);
-
- for (var i = 0; i <= days; i += 1) {
- var date = new Date(oneYearAgo);
- date.setDate(date.getDate() + i);
-
- var day = date.getDay();
- var count = timestamps[date.format('yyyy-mm-dd')];
-
- // Create a new group array if this is the first day of the week
- // or if is first object
- if ((day === 0 && i !== 0) || i === 0) {
- this.timestampsTmp.push([]);
- group += 1;
- }
-
- var innerArray = this.timestampsTmp[group - 1];
- // Push to the inner array the values that will be used to render map
- innerArray.push({
- count: count || 0,
- date: date,
- day: day
- });
- }
-
- // Init color functions
- this.colorKey = this.initColorKey();
- this.color = this.initColor();
- // Init the svg element
- this.renderSvg(group);
- this.renderDays();
- this.renderMonths();
- this.renderDayTitles();
- this.renderKey();
- this.initTooltips();
- }
-
- // Add extra padding for the last month label if it is also the last column
- Calendar.prototype.getExtraWidthPadding = function(group) {
- var extraWidthPadding = 0;
- var lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth();
- var secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth();
-
- if (lastColMonth != secondLastColMonth) {
- extraWidthPadding = 3;
- }
-
- return extraWidthPadding;
- };
-
- Calendar.prototype.renderSvg = function(group) {
- var width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
- return this.svg = d3.select('.js-contrib-calendar').append('svg').attr('width', width).attr('height', 167).attr('class', 'contrib-calendar');
- };
-
- Calendar.prototype.renderDays = function() {
- return this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g').attr('transform', (function(_this) {
- return function(group, i) {
- _.each(group, function(stamp, a) {
- var lastMonth, lastMonthX, month, x;
- if (a === 0 && stamp.day === 0) {
- month = stamp.date.getMonth();
- x = (_this.daySizeWithSpace * i + 1) + _this.daySizeWithSpace;
- lastMonth = _.last(_this.months);
- if (lastMonth != null) {
- lastMonthX = lastMonth.x;
- }
- if (lastMonth == null) {
- return _this.months.push({
- month: month,
- x: x
- });
- } else if (month !== lastMonth.month && x - _this.daySizeWithSpace !== lastMonthX) {
- return _this.months.push({
- month: month,
- x: x
- });
- }
- }
- });
- return "translate(" + ((_this.daySizeWithSpace * i + 1) + _this.daySizeWithSpace) + ", 18)";
- };
- })(this)).selectAll('rect').data(function(stamp) {
- return stamp;
- }).enter().append('rect').attr('x', '0').attr('y', (function(_this) {
- return function(stamp, i) {
- return _this.daySizeWithSpace * stamp.day;
- };
- })(this)).attr('width', this.daySize).attr('height', this.daySize).attr('title', (function(_this) {
- return function(stamp) {
- var contribText, date, dateText;
- date = new Date(stamp.date);
- contribText = 'No contributions';
- if (stamp.count > 0) {
- contribText = stamp.count + " contribution" + (stamp.count > 1 ? 's' : '');
- }
- dateText = date.format('mmm d, yyyy');
- return contribText + "<br />" + (gl.utils.getDayName(date)) + " " + dateText;
- };
- })(this)).attr('class', 'user-contrib-cell js-tooltip').attr('fill', (function(_this) {
- return function(stamp) {
- if (stamp.count !== 0) {
- return _this.color(Math.min(stamp.count, 40));
- } else {
- return '#ededed';
- }
- };
- })(this)).attr('data-container', 'body').on('click', this.clickDay);
- };
-
- Calendar.prototype.renderDayTitles = function() {
- var days;
- days = [
- {
- text: 'M',
- y: 29 + (this.daySizeWithSpace * 1)
- }, {
- text: 'W',
- y: 29 + (this.daySizeWithSpace * 3)
- }, {
- text: 'F',
- y: 29 + (this.daySizeWithSpace * 5)
- }
- ];
- return this.svg.append('g').selectAll('text').data(days).enter().append('text').attr('text-anchor', 'middle').attr('x', 8).attr('y', function(day) {
- return day.y;
- }).text(function(day) {
- return day.text;
- }).attr('class', 'user-contrib-text');
- };
-
- Calendar.prototype.renderMonths = function() {
- return this.svg.append('g').attr('direction', 'ltr').selectAll('text').data(this.months).enter().append('text').attr('x', function(date) {
- return date.x;
- }).attr('y', 10).attr('class', 'user-contrib-text').text((function(_this) {
- return function(date) {
- return _this.monthNames[date.month];
- };
- })(this));
- };
-
- Calendar.prototype.renderKey = function() {
- const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions'];
- const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
-
- this.svg.append('g')
- .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
- .selectAll('rect')
- .data(keyColors)
- .enter()
- .append('rect')
- .attr('width', this.daySize)
- .attr('height', this.daySize)
- .attr('x', (color, i) => this.daySizeWithSpace * i)
- .attr('y', 0)
- .attr('fill', color => color)
- .attr('class', 'js-tooltip')
- .attr('title', (color, i) => keyValues[i])
- .attr('data-container', 'body');
- };
-
- Calendar.prototype.initColor = function() {
- var colorRange;
- colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
- return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange);
- };
-
- Calendar.prototype.initColorKey = function() {
- return d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]);
- };
-
- Calendar.prototype.clickDay = function(stamp) {
- var formatted_date;
- if (this.currentSelectedDate !== stamp.date) {
- this.currentSelectedDate = stamp.date;
- formatted_date = this.currentSelectedDate.getFullYear() + "-" + (this.currentSelectedDate.getMonth() + 1) + "-" + this.currentSelectedDate.getDate();
- return $.ajax({
- url: this.calendar_activities_path,
- data: {
- date: formatted_date
- },
- cache: false,
- dataType: 'html',
- beforeSend: function() {
- return $('.user-calendar-activities').html('<div class="text-center"><i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i></div>');
- },
- success: function(data) {
- return $('.user-calendar-activities').html(data);
- }
- });
- } else {
- this.currentSelectedDate = '';
- return $('.user-calendar-activities').html('');
- }
- };
-
- Calendar.prototype.initTooltips = function() {
- return $('.js-contrib-calendar .js-tooltip').tooltip({
- html: true
- });
- };
-
- return Calendar;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js
deleted file mode 100644
index a38ce4eb25e..00000000000
--- a/app/assets/javascripts/users/users_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-import './calendar';
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 46efdcf4202..f3b961eb109 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -2,10 +2,14 @@
/* global Issuable */
/* global emitSidebarEvent */
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
-function UsersSelect(currentUser, els) {
+function UsersSelect(currentUser, els, options = {}) {
var $els;
this.users = this.users.bind(this);
this.user = this.user.bind(this);
@@ -19,6 +23,8 @@ function UsersSelect(currentUser, els) {
}
}
+ const { handleClick } = options;
+
$els = $(els);
if (!els) {
@@ -30,23 +36,22 @@ function UsersSelect(currentUser, els) {
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');
+ options.projectId = $dropdown.data('projectId');
+ options.groupId = $dropdown.data('groupId');
+ options.showCurrentUser = $dropdown.data('currentUser');
+ options.todoFilter = $dropdown.data('todoFilter');
+ options.todoStateFilter = $dropdown.data('todoStateFilter');
+ showNullUser = $dropdown.data('nullUser');
+ defaultNullUser = $dropdown.data('nullUserDefault');
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');
+ showAnyUser = $dropdown.data('anyUser');
+ firstUser = $dropdown.data('firstUser');
+ options.authorId = $dropdown.data('authorId');
+ defaultLabel = $dropdown.data('defaultLabel');
issueURL = $dropdown.data('issueUpdate');
$selectbox = $dropdown.closest('.selectbox');
$block = $selectbox.closest('.block');
- abilityName = $dropdown.data('ability-name');
+ abilityName = $dropdown.data('abilityName');
$value = $block.find('.value');
$collapsedSidebar = $block.find('.sidebar-collapsed-user');
$loading = $block.find('.block-loading').fadeOut();
@@ -59,7 +64,7 @@ function UsersSelect(currentUser, els) {
const assignYourself = function () {
const unassignedSelected = $dropdown.closest('.selectbox')
- .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
if (unassignedSelected) {
unassignedSelected.remove();
@@ -68,13 +73,13 @@ function UsersSelect(currentUser, els) {
// Save current selected user to the DOM
const input = document.createElement('input');
input.type = 'hidden';
- input.name = $dropdown.data('field-name');
+ input.name = $dropdown.data('fieldName');
const currentUserInfo = $dropdown.data('currentUserInfo');
if (currentUserInfo) {
input.value = currentUserInfo.id;
- input.dataset.meta = currentUserInfo.name;
+ input.dataset.meta = _.escape(currentUserInfo.name);
} else if (_this.currentUser) {
input.value = _this.currentUser.id;
}
@@ -92,7 +97,7 @@ function UsersSelect(currentUser, els) {
const getSelectedUserInputs = function() {
return $selectbox
- .find(`input[name="${$dropdown.data('field-name')}"]`);
+ .find(`input[name="${$dropdown.data('fieldName')}"]`);
};
const getSelected = function() {
@@ -102,14 +107,14 @@ function UsersSelect(currentUser, els) {
};
const checkMaxSelect = function() {
- const maxSelect = $dropdown.data('max-select');
+ const maxSelect = $dropdown.data('maxSelect');
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}]`);
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
firstSelected.remove();
emitSidebarEvent('sidebar.removeAssignee', {
@@ -154,7 +159,7 @@ function UsersSelect(currentUser, els) {
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')}"]`);
+ const $input = $(`input[name="${$dropdown.data('fieldName')}"]`);
$input.val(gon.current_user_id);
selectedId = $input.val();
$dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
@@ -174,40 +179,34 @@ function UsersSelect(currentUser, els) {
$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));
- });
+ return axios.put(issueURL, data)
+ .then(({ 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', _.escape(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
@@ -295,10 +294,10 @@ function UsersSelect(currentUser, els) {
const selected = getSelected().filter(i => i !== 0);
if (selected.length > 0) {
- if ($dropdown.data('dropdown-header')) {
+ if ($dropdown.data('dropdownHeader')) {
showDivider += 1;
users.splice(showDivider, 0, {
- header: $dropdown.data('dropdown-header'),
+ header: $dropdown.data('dropdownHeader'),
});
}
@@ -329,7 +328,7 @@ function UsersSelect(currentUser, els) {
fields: ['name', 'username']
},
selectable: true,
- fieldName: $dropdown.data('field-name'),
+ fieldName: $dropdown.data('fieldName'),
toggleLabel: function(selected, el, glDropdown) {
const inputValue = glDropdown.filterInput.val();
@@ -364,7 +363,7 @@ function UsersSelect(currentUser, els) {
emitSidebarEvent('sidebar.saveAssignees');
}
- if (!$dropdown.data('always-show-selectbox')) {
+ if (!$dropdown.data('alwaysShowSelectbox')) {
$selectbox.hide();
// Recalculate where .value is because vue might have changed it
@@ -375,7 +374,7 @@ function UsersSelect(currentUser, els) {
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
- inputMeta: $dropdown.data('input-meta'),
+ inputMeta: $dropdown.data('inputMeta'),
clicked: function(options) {
const { $el, e, isMarking } = options;
const user = options.selectedObj;
@@ -383,7 +382,7 @@ function UsersSelect(currentUser, els) {
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]");
+ .find("input[name='" + ($dropdown.data('fieldName')) + "'][value!=0]");
// Enables support for limiting the number of users selected
// Automatically removes the first on the list if more users are selected
@@ -402,7 +401,7 @@ function UsersSelect(currentUser, els) {
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown.closest('.selectbox')
- .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
+ .find("input[name='" + ($dropdown.data('fieldName')) + "'][value=0]");
if (unassignedSelected) {
unassignedSelected.remove();
@@ -410,7 +409,7 @@ function UsersSelect(currentUser, els) {
} else {
if (previouslySelected.length === 0) {
// Select unassigned because there is no more selected users
- this.addInput($dropdown.data('field-name'), 0, {});
+ this.addInput($dropdown.data('fieldName'), 0, {});
}
// User unselected
@@ -425,7 +424,7 @@ function UsersSelect(currentUser, els) {
}
var isIssueIndex, isMRIndex, page, selected;
- page = $('body').data('page');
+ page = $('body').attr('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')) {
@@ -442,21 +441,24 @@ function UsersSelect(currentUser, els) {
return;
}
if ($el.closest('.add-issues-modal').length) {
- gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
+ gl.issueBoards.ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
+ } else if (handleClick) {
+ e.preventDefault();
+ handleClick(user, isMarking);
} 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();
+ selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('fieldName')) + "']").val();
return assignTo(selected);
}
// 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')) {
+ if ($dropdown.data('maxSelect') &&
+ getSelected().length === $dropdown.data('maxSelect')) {
// Close the dropdown
$dropdown.dropdown('toggle');
}
@@ -468,7 +470,7 @@ function UsersSelect(currentUser, els) {
const $el = $(e.currentTarget);
const selected = getSelected();
if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
- this.addInput($dropdown.data('field-name'), 0, {});
+ this.addInput($dropdown.data('fieldName'), 0, {});
}
$el.find('.is-active').removeClass('is-active');
@@ -484,11 +486,11 @@ function UsersSelect(currentUser, els) {
highlightSelected(selectedId);
}
},
- updateLabel: $dropdown.data('dropdown-title'),
+ updateLabel: $dropdown.data('dropdownTitle'),
renderRow: function(user) {
var avatar, img, listClosingTags, listWithName, listWithUserName, username;
username = user.username ? "@" + user.username : "";
- avatar = user.avatar_url ? user.avatar_url : false;
+ avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
let selected = false;
@@ -507,11 +509,9 @@ function UsersSelect(currentUser, els) {
img = "";
if (user.beforeDivider != null) {
- `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(user.name)}</a></li>`;
} else {
- if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
- }
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
}
return `
@@ -519,7 +519,7 @@ function UsersSelect(currentUser, els) {
<a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
${img}
<strong class='dropdown-menu-user-full-name'>
- ${user.name}
+ ${_.escape(user.name)}
</strong>
${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
</a>
@@ -534,16 +534,15 @@ function UsersSelect(currentUser, els) {
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');
+ options.projectId = $(select).data('projectId');
+ options.groupId = $(select).data('groupId');
+ options.showCurrentUser = $(select).data('currentUser');
+ options.authorId = $(select).data('authorId');
+ options.skipUsers = $(select).data('skipUsers');
+ showNullUser = $(select).data('nullUser');
+ showAnyUser = $(select).data('anyUser');
+ showEmailUser = $(select).data('emailUser');
+ firstUser = $(select).data('firstUser');
return $(select).select2({
placeholder: "Search for a user",
multiple: $(select).hasClass('multiselect'),
@@ -589,9 +588,10 @@ function UsersSelect(currentUser, els) {
if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
var trimmed = query.term.trim();
emailUser = {
- name: "Invite \"" + query.term + "\"",
+ name: "Invite \"" + query.term + "\" by email",
username: trimmed,
- id: trimmed
+ id: trimmed,
+ invite: true
};
data.results.unshift(emailUser);
}
@@ -643,11 +643,11 @@ UsersSelect.prototype.formatResult = function(user) {
} else {
avatar = gon.default_avatar_url;
}
- return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar avatar-inline s32' src='" + avatar + "'></div> <div class='user-name dropdown-menu-user-full-name'>" + user.name + "</div> <div class='user-username dropdown-menu-user-username'>" + ("@" + user.username || "") + "</div> </div>";
+ return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar avatar-inline s32' src='" + avatar + "'></div> <div class='user-name dropdown-menu-user-full-name'>" + _.escape(user.name) + "</div> <div class='user-username dropdown-menu-user-username'>" + (!user.invite ? "@" + _.escape(user.username) : "") + "</div> </div>";
};
UsersSelect.prototype.formatSelection = function(user) {
- return user.name;
+ return _.escape(user.name);
};
UsersSelect.prototype.user = function(user_id, callback) {
@@ -658,39 +658,32 @@ UsersSelect.prototype.user = function(user_id, callback) {
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 axios.get(url)
+ .then(({ data }) => {
+ callback(data);
+ });
};
// 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);
- });
+ const url = this.buildUrl(this.usersPath);
+ const params = {
+ search: query,
+ 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,
+ author_id: options.authorId || null,
+ skip_users: options.skipUsers || null
+ };
+ return axios.get(url, { params })
+ .then(({ data }) => {
+ callback(data);
+ });
};
UsersSelect.prototype.buildUrl = function(url) {
diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js
index 88ba991af47..ec515e892c6 100644
--- a/app/assets/javascripts/version_check_image.js
+++ b/app/assets/javascripts/version_check_image.js
@@ -3,6 +3,3 @@ export default class VersionCheckImage {
imageElement.off('error').on('error', () => imageElement.hide());
}
}
-
-window.gl = window.gl || {};
-gl.VersionCheckImage = VersionCheckImage;
diff --git a/app/assets/javascripts/visibility_select.js b/app/assets/javascripts/visibility_select.js
index f712d7ba930..0c928d0d5f6 100644
--- a/app/assets/javascripts/visibility_select.js
+++ b/app/assets/javascripts/visibility_select.js
@@ -1,27 +1,21 @@
-(() => {
- const gl = window.gl || (window.gl = {});
-
- class VisibilitySelect {
- constructor(container) {
- if (!container) throw new Error('VisibilitySelect requires a container element as argument 1');
- this.container = container;
- this.helpBlock = this.container.querySelector('.help-block');
- this.select = this.container.querySelector('select');
- }
-
- init() {
- if (this.select) {
- this.updateHelpText();
- this.select.addEventListener('change', this.updateHelpText.bind(this));
- } else {
- this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock;
- }
- }
+export default class VisibilitySelect {
+ constructor(container) {
+ if (!container) throw new Error('VisibilitySelect requires a container element as argument 1');
+ this.container = container;
+ this.helpBlock = this.container.querySelector('.help-block');
+ this.select = this.container.querySelector('select');
+ }
- updateHelpText() {
- this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description;
+ init() {
+ if (this.select) {
+ this.updateHelpText();
+ this.select.addEventListener('change', this.updateHelpText.bind(this));
+ } else {
+ this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock;
}
}
- gl.VisibilitySelect = VisibilitySelect;
-})();
+ updateHelpText() {
+ this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description;
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
new file mode 100644
index 00000000000..7bef2e97349
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -0,0 +1,144 @@
+<script>
+import timeagoMixin from '../../vue_shared/mixins/timeago';
+import tooltip from '../../vue_shared/directives/tooltip';
+import LoadingButton from '../../vue_shared/components/loading_button.vue';
+import { visitUrl } from '../../lib/utils/url_utility';
+import createFlash from '../../flash';
+import MemoryUsage from './memory_usage.vue';
+import StatusIcon from './mr_widget_status_icon.vue';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'Deployment',
+ components: {
+ LoadingButton,
+ MemoryUsage,
+ StatusIcon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
+ props: {
+ deployment: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isStopping: false,
+ };
+ },
+ computed: {
+ deployTimeago() {
+ return this.timeFormated(this.deployment.deployed_at);
+ },
+ hasExternalUrls() {
+ return !!(this.deployment.external_url && this.deployment.external_url_formatted);
+ },
+ hasDeploymentTime() {
+ return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
+ },
+ hasDeploymentMeta() {
+ return !!(this.deployment.url && this.deployment.name);
+ },
+ hasMetrics() {
+ return !!(this.deployment.metrics_url);
+ },
+ },
+ methods: {
+ stopEnvironment() {
+ const msg = 'Are you sure you want to stop this environment?';
+ const isConfirmed = confirm(msg); // eslint-disable-line
+
+ if (isConfirmed) {
+ this.isStopping = true;
+
+ MRWidgetService.stopEnvironment(this.deployment.stop_url)
+ .then(res => res.data)
+ .then((data) => {
+ if (data.redirect_url) {
+ visitUrl(data.redirect_url);
+ }
+
+ this.isStopping = false;
+ })
+ .catch(() => {
+ createFlash('Something went wrong while stopping this environment. Please try again.');
+ this.isStopping = false;
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-heading deploy-heading">
+ <div class="ci-widget media">
+ <div class="ci-status-icon ci-status-icon-success">
+ <span class="js-icon-link icon-link">
+ <status-icon status="success" />
+ </span>
+ </div>
+ <div class="media-body">
+ <div class="deploy-body">
+ <template v-if="hasDeploymentMeta">
+ <span>
+ Deployed to
+ </span>
+ <a
+ :href="deployment.url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="deploy-link js-deploy-meta"
+ >
+ {{ deployment.name }}
+ </a>
+ </template>
+ <template v-if="hasExternalUrls">
+ <span>
+ on
+ </span>
+ <a
+ :href="deployment.external_url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="deploy-link js-deploy-url"
+ >
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true"
+ >
+ </i>
+ {{ deployment.external_url_formatted }}
+ </a>
+ </template>
+ <span
+ v-if="hasDeploymentTime"
+ v-tooltip
+ :title="deployment.deployed_at_formatted"
+ class="js-deploy-time"
+ >
+ {{ deployTimeago }}
+ </span>
+ <loading-button
+ v-if="deployment.stop_url"
+ container-class="btn btn-default btn-xs prepend-left-default"
+ label="Stop environment"
+ :loading="isStopping"
+ @click="stopEnvironment"
+ />
+ </div>
+ <memory-usage
+ v-if="hasMetrics"
+ :metrics-url="deployment.metrics_url"
+ :metrics-monitoring-url="deployment.metrics_monitoring_url"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
new file mode 100644
index 00000000000..95c8b0a4c55
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
@@ -0,0 +1,170 @@
+<script>
+import { sprintf, s__ } from '~/locale';
+import statusCodes from '../../lib/utils/http_status';
+import { bytesToMiB } from '../../lib/utils/number_utils';
+import { backOff } from '../../lib/utils/common_utils';
+import MemoryGraph from '../../vue_shared/components/memory_graph.vue';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'MemoryUsage',
+ components: {
+ MemoryGraph,
+ },
+ props: {
+ metricsUrl: {
+ type: String,
+ required: true,
+ },
+ metricsMonitoringUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ memoryFrom: 0,
+ memoryTo: 0,
+ memoryMetrics: [],
+ deploymentTime: 0,
+ hasMetrics: false,
+ loadFailed: false,
+ loadingMetrics: true,
+ backOffRequestCounter: 0,
+ };
+ },
+ computed: {
+ shouldShowLoading() {
+ return this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
+ },
+ shouldShowMemoryGraph() {
+ return !this.loadingMetrics && this.hasMetrics && !this.loadFailed;
+ },
+ shouldShowLoadFailure() {
+ return !this.loadingMetrics && !this.hasMetrics && this.loadFailed;
+ },
+ shouldShowMetricsUnavailable() {
+ return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
+ },
+ memoryChangeMessage() {
+ const messageProps = {
+ memoryFrom: this.memoryFrom,
+ memoryTo: this.memoryTo,
+ metricsLinkStart: `<a href="${this.metricsMonitoringUrl}">`,
+ metricsLinkEnd: '</a>',
+ emphasisStart: '<b>',
+ emphasisEnd: '</b>',
+ };
+ const memoryTo = Number(this.memoryTo);
+ const memoryFrom = Number(this.memoryFrom);
+ let memoryUsageMsg = '';
+
+ if (memoryTo > memoryFrom) {
+ memoryUsageMsg = sprintf(s__('mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB'), messageProps, false);
+ } else if (memoryTo < memoryFrom) {
+ memoryUsageMsg = sprintf(s__('mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB'), messageProps, false);
+ } else {
+ memoryUsageMsg = sprintf(s__('mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB'), messageProps, false);
+ }
+
+ return memoryUsageMsg;
+ },
+ },
+ mounted() {
+ this.loadingMetrics = true;
+ this.loadMetrics();
+ },
+ methods: {
+ getMegabytes(bytesString) {
+ const valueInBytes = Number(bytesString).toFixed(2);
+ return (bytesToMiB(valueInBytes)).toFixed(2);
+ },
+ computeGraphData(metrics, deploymentTime) {
+ this.loadingMetrics = false;
+ 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;
+ this.memoryMetrics = memory_values[0].values;
+ this.deploymentTime = deploymentTime;
+ }
+ },
+ loadMetrics() {
+ backOff((next, stop) => {
+ MRWidgetService.fetchMetrics(this.metricsUrl)
+ .then((res) => {
+ if (res.status === statusCodes.NO_CONTENT) {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ /* eslint-disable no-unused-expressions */
+ this.backOffRequestCounter < 3 ? next() : stop(res);
+ } else {
+ stop(res);
+ }
+ })
+ .catch(stop);
+ })
+ .then((res) => {
+ if (res.status === statusCodes.NO_CONTENT) {
+ return res;
+ }
+
+ return res.data;
+ })
+ .then((data) => {
+ this.computeGraphData(data.metrics, data.deployment_time);
+ return data;
+ })
+ .catch(() => {
+ this.loadFailed = true;
+ this.loadingMetrics = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage">
+ <p
+ v-if="shouldShowLoading"
+ class="usage-info js-usage-info usage-info-loading">
+ <i
+ class="fa fa-spinner fa-spin usage-info-load-spinner"
+ aria-hidden="true">
+ </i>{{ s__('mrWidget|Loading deployment statistics') }}
+ </p>
+ <p
+ v-if="shouldShowMemoryGraph"
+ class="usage-info js-usage-info">
+ {{ memoryChangeMessage }}
+ </p>
+ <p
+ v-if="shouldShowLoadFailure"
+ class="usage-info js-usage-info usage-info-failed">
+ {{ s__('mrWidget|Failed to load deployment statistics') }}
+ </p>
+ <p
+ v-if="shouldShowMetricsUnavailable"
+ class="usage-info js-usage-info usage-info-unavailable">
+ {{ s__('mrWidget|Deployment statistics are not available currently') }}
+ </p>
+ <memory-graph
+ v-if="shouldShowMemoryGraph"
+ :metrics="memoryMetrics"
+ :deployment-time="deploymentTime"
+ height="25"
+ width="100"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
deleted file mode 100644
index a01cb8cc202..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
+++ /dev/null
@@ -1,23 +0,0 @@
-export default {
- name: 'MRWidgetAuthor',
- props: {
- author: { type: Object, required: true },
- showAuthorName: { type: Boolean, required: false, default: true },
- showAuthorTooltip: { type: Boolean, required: false, default: false },
- },
- template: `
- <a
- :href="author.webUrl || author.web_url"
- class="author-link"
- :class="{ 'has-tooltip': showAuthorTooltip }"
- :title="author.name">
- <img
- :src="author.avatarUrl || author.avatar_url"
- class="avatar avatar-inline s16" />
- <span
- v-if="showAuthorName"
- class="author">{{author.name}}
- </span>
- </a>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
new file mode 100644
index 00000000000..cb6e9858736
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
@@ -0,0 +1,53 @@
+<script>
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ name: 'MRWidgetAuthor',
+ directives: {
+ tooltip,
+ },
+ props: {
+ author: {
+ type: Object,
+ required: true,
+ },
+ showAuthorName: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showAuthorTooltip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ authorUrl() {
+ return this.author.webUrl || this.author.web_url;
+ },
+ avatarUrl() {
+ return this.author.avatarUrl || this.author.avatar_url;
+ },
+ },
+ };
+</script>
+<template>
+ <a
+ :href="authorUrl"
+ class="author-link inline"
+ :v-tooltip="showAuthorTooltip"
+ :title="author.name"
+ >
+ <img
+ :src="avatarUrl"
+ class="avatar avatar-inline s16"
+ />
+ <span
+ class="author"
+ v-if="showAuthorName"
+ >
+ {{ author.name }}
+ </span>
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
deleted file mode 100644
index 6d2ed5fda64..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import MRWidgetAuthor from './mr_widget_author';
-
-export default {
- name: 'MRWidgetAuthorTime',
- props: {
- actionText: { type: String, required: true },
- author: { type: Object, required: true },
- dateTitle: { type: String, required: true },
- dateReadable: { type: String, required: true },
- },
- components: {
- 'mr-widget-author': MRWidgetAuthor,
- },
- template: `
- <h4 class="js-mr-widget-author">
- {{actionText}}
- <mr-widget-author :author="author" />
- <time
- :title="dateTitle"
- data-toggle="tooltip"
- data-placement="top"
- data-container="body">
- {{dateReadable}}
- </time>
- </h4>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
new file mode 100644
index 00000000000..8f1fd809a81
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
@@ -0,0 +1,42 @@
+<script>
+ import mrWidgetAuthor from './mr_widget_author.vue';
+
+ export default {
+ name: 'MRWidgetAuthorTime',
+ components: {
+ mrWidgetAuthor,
+ },
+ props: {
+ actionText: {
+ type: String,
+ required: true,
+ },
+ author: {
+ type: Object,
+ required: true,
+ },
+ dateTitle: {
+ type: String,
+ required: true,
+ },
+ dateReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+<template>
+ <h4 class="js-mr-widget-author">
+ {{ actionText }}
+ <mr-widget-author :author="author" />
+ <time
+ :title="dateTitle"
+ data-toggle="tooltip"
+ data-placement="top"
+ data-container="body"
+ >
+ {{ dateReadable }}
+ </time>
+ </h4>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
deleted file mode 100644
index e8e22ad93a5..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/* global Flash */
-
-import '~/lib/utils/datetime_utility';
-import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
-import MemoryUsage from './mr_widget_memory_usage';
-import MRWidgetService from '../services/mr_widget_service';
-
-export default {
- name: 'MRWidgetDeployment',
- props: {
- mr: { type: Object, required: true },
- service: { type: Object, required: true },
- },
- components: {
- 'mr-widget-memory-usage': MemoryUsage,
- },
- computed: {
- svg() {
- return statusIconEntityMap.icon_status_success;
- },
- },
- methods: {
- formatDate(date) {
- return gl.utils.getTimeago().format(date);
- },
- hasExternalUrls(deployment = {}) {
- return deployment.external_url && deployment.external_url_formatted;
- },
- hasDeploymentTime(deployment = {}) {
- return deployment.deployed_at && deployment.deployed_at_formatted;
- },
- hasDeploymentMeta(deployment = {}) {
- return deployment.url && deployment.name;
- },
- stopEnvironment(deployment) {
- const msg = 'Are you sure you want to stop this environment?';
- const isConfirmed = confirm(msg); // eslint-disable-line
-
- if (isConfirmed) {
- MRWidgetService.stopEnvironment(deployment.stop_url)
- .then(res => res.json())
- .then((res) => {
- if (res.redirect_url) {
- gl.utils.visitUrl(res.redirect_url);
- }
- })
- .catch(() => {
- new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line
- });
- }
- },
- },
- template: `
- <div class="mr-widget-heading">
- <div v-for="deployment in mr.deployments">
- <div class="ci-widget">
- <div class="ci-status-icon ci-status-icon-success">
- <span class="js-icon-link icon-link">
- <span class="ci-status-icon"
- v-html="svg"
- aria-hidden="true"></span>
- </span>
- </div>
- <span>
- <span
- v-if="hasDeploymentMeta(deployment)">
- Deployed to
- </span>
- <a
- v-if="hasDeploymentMeta(deployment)"
- :href="deployment.url"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="js-deploy-meta">
- {{deployment.name}}
- </a>
- <span
- v-if="hasExternalUrls(deployment)">
- on
- </span>
- <a
- v-if="hasExternalUrls(deployment)"
- :href="deployment.external_url"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="js-deploy-url">
- <i
- class="fa fa-external-link"
- aria-hidden="true" />
- {{deployment.external_url_formatted}}
- </a>
- <span
- v-if="hasDeploymentTime(deployment)"
- :data-title="deployment.deployed_at_formatted"
- class="js-deploy-time"
- data-toggle="tooltip"
- data-placement="top">
- {{formatDate(deployment.deployed_at)}}
- </span>
- <button
- type="button"
- v-if="deployment.stop_url"
- @click="stopEnvironment(deployment)"
- class="btn btn-default btn-xs">
- Stop environment
- </button>
- </span>
- </div>
- <mr-widget-memory-usage
- v-if="deployment.metrics_url"
- :metricsUrl="deployment.metrics_url"
- />
- </div>
- </div>
- `,
-};
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
deleted file mode 100644
index f8b3fb748ae..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import '../../lib/utils/text_utility';
-
-export default {
- name: 'MRWidgetHeader',
- props: {
- mr: { type: Object, required: true },
- },
- computed: {
- shouldShowCommitsBehindText() {
- return this.mr.divergedCommitsCount > 0;
- },
- commitsText() {
- return gl.text.pluralize('commit', this.mr.divergedCommitsCount);
- },
- branchNameClipboardData() {
- // This supports code in app/assets/javascripts/copy_to_clipboard.js that
- // works around ClipboardJS limitations to allow the context-specific
- // copy/pasting of plain text or GFM.
- return JSON.stringify({
- text: this.mr.sourceBranch,
- gfm: `\`${this.mr.sourceBranch}\``,
- });
- },
- },
- methods: {
- isBranchTitleLong(branchTitle) {
- return branchTitle.length > 32;
- },
- },
- template: `
- <div class="mr-source-target">
- <div
- v-if="mr.isOpen"
- class="pull-right">
- <a
- href="#modal_merge_info"
- data-toggle="modal"
- class="btn inline btn-grouped btn-sm">
- Check out branch
- </a>
- <span class="dropdown inline prepend-left-5">
- <a
- class="btn btn-sm dropdown-toggle"
- data-toggle="dropdown"
- aria-label="Download as"
- role="button">
- <i
- class="fa fa-download"
- aria-hidden="true" />
- <i
- class="fa fa-caret-down"
- aria-hidden="true" />
- </a>
- <ul class="dropdown-menu dropdown-menu-align-right">
- <li>
- <a
- :href="mr.emailPatchesPath"
- download>
- Email patches
- </a>
- </li>
- <li>
- <a
- :href="mr.plainDiffPath"
- download>
- Plain diff
- </a>
- </li>
- </ul>
- </span>
- </div>
- <div class="normal">
- <strong>
- Request to merge
- <span
- class="label-branch"
- :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.sourceBranch)}"
- :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''"
- data-placement="bottom"
- v-html="mr.sourceBranchLink"></span>
- <button
- class="btn btn-transparent btn-clipboard has-tooltip"
- data-title="Copy branch name to clipboard"
- :data-clipboard-text="branchNameClipboardData">
- <i
- aria-hidden="true"
- class="fa fa-clipboard"></i>
- </button>
- into
- <span
- class="label-branch"
- :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}"
- :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
- data-placement="bottom">
- <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
- </span>
- </strong>
- <span
- v-if="shouldShowCommitsBehindText"
- class="diverged-commits-count">
- ({{mr.divergedCommitsCount}} {{commitsText}} behind)
- </span>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
new file mode 100644
index 00000000000..3d886e7d628
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -0,0 +1,146 @@
+<script>
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import { n__ } from '~/locale';
+ import icon from '~/vue_shared/components/icon.vue';
+ import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+ export default {
+ name: 'MRWidgetHeader',
+ directives: {
+ tooltip,
+ },
+ components: {
+ icon,
+ clipboardButton,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ shouldShowCommitsBehindText() {
+ return this.mr.divergedCommitsCount > 0;
+ },
+ commitsText() {
+ return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount);
+ },
+ branchNameClipboardData() {
+ // This supports code in app/assets/javascripts/copy_to_clipboard.js that
+ // works around ClipboardJS limitations to allow the context-specific
+ // copy/pasting of plain text or GFM.
+ return JSON.stringify({
+ text: this.mr.sourceBranch,
+ gfm: `\`${this.mr.sourceBranch}\``,
+ });
+ },
+ isSourceBranchLong() {
+ return this.isBranchTitleLong(this.mr.sourceBranch);
+ },
+ isTargetBranchLong() {
+ return this.isBranchTitleLong(this.mr.targetBranch);
+ },
+ },
+ methods: {
+ isBranchTitleLong(branchTitle) {
+ return branchTitle.length > 32;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-source-target">
+ <div class="normal">
+ <strong>
+ {{ s__("mrWidget|Request to merge") }}
+ <span
+ class="label-branch js-source-branch"
+ :class="{ 'label-truncated': isSourceBranchLong }"
+ :title="isSourceBranchLong ? mr.sourceBranch : ''"
+ data-placement="bottom"
+ :v-tooltip="isSourceBranchLong"
+ v-html="mr.sourceBranchLink"
+ >
+ </span>
+
+ <clipboard-button
+ :text="branchNameClipboardData"
+ :title="__('Copy branch name to clipboard')"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+
+ {{ s__("mrWidget|into") }}
+
+ <span
+ class="label-branch"
+ :v-tooltip="isTargetBranchLong"
+ :class="{ 'label-truncatedtooltip': isTargetBranchLong }"
+ :title="isTargetBranchLong ? mr.targetBranch : ''"
+ data-placement="bottom"
+ >
+ <a
+ :href="mr.targetBranchTreePath"
+ class="js-target-branch"
+ >
+ {{ mr.targetBranch }}
+ </a>
+ </span>
+ </strong>
+ <span
+ v-if="shouldShowCommitsBehindText"
+ class="diverged-commits-count"
+ >
+ (<a :href="mr.targetBranchPath">{{ commitsText }}</a>)
+ </span>
+ </div>
+
+ <div v-if="mr.isOpen">
+ <button
+ data-target="#modal_merge_info"
+ data-toggle="modal"
+ :disabled="mr.sourceBranchRemoved"
+ class="btn btn-sm btn-default inline js-check-out-branch"
+ type="button"
+ >
+ {{ s__("mrWidget|Check out branch") }}
+ </button>
+ <span class="dropdown prepend-left-10">
+ <button
+ type="button"
+ class="btn btn-sm inline dropdown-toggle"
+ data-toggle="dropdown"
+ aria-label="Download as"
+ aria-haspopup="true"
+ aria-expanded="false"
+ >
+ <icon name="download" />
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true">
+ </i>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li>
+ <a
+ class="js-download-email-patches"
+ :href="mr.emailPatchesPath"
+ download
+ >
+ {{ s__("mrWidget|Email patches") }}
+ </a>
+ </li>
+ <li>
+ <a
+ class="js-download-plain-diff"
+ :href="mr.plainDiffPath"
+ download
+ >
+ {{ s__("mrWidget|Plain diff") }}
+ </a>
+ </li>
+ </ul>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue
new file mode 100644
index 00000000000..f0298f732ea
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ name: 'MRWidgetMaintainerEdit',
+ props: {
+ maintainerEditAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ };
+</script>
+
+<template>
+ <section class="mr-info-list mr-links">
+ <p v-if="maintainerEditAllowed">
+ {{ s__("mrWidget|Allows edits from maintainers") }}
+ </p>
+ </section>
+</template>
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
deleted file mode 100644
index 76cb71b6c12..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
+++ /dev/null
@@ -1,147 +0,0 @@
-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';
-
-export default {
- name: 'MemoryUsage',
- props: {
- metricsUrl: { type: String, required: true },
- },
- data() {
- return {
- memoryFrom: 0,
- memoryTo: 0,
- memoryMetrics: [],
- deploymentTime: 0,
- hasMetrics: false,
- loadFailed: false,
- loadingMetrics: true,
- backOffRequestCounter: 0,
- };
- },
- components: {
- 'mr-memory-graph': MemoryGraph,
- },
- computed: {
- shouldShowLoading() {
- return this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
- },
- shouldShowMemoryGraph() {
- return !this.loadingMetrics && this.hasMetrics && !this.loadFailed;
- },
- shouldShowLoadFailure() {
- return !this.loadingMetrics && !this.hasMetrics && this.loadFailed;
- },
- 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_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;
- this.memoryMetrics = memory_values[0].values;
- this.deploymentTime = deploymentTime;
- }
- },
- loadMetrics() {
- gl.utils.backOff((next, stop) => {
- MRWidgetService.fetchMetrics(this.metricsUrl)
- .then((res) => {
- if (res.status === statusCodes.NO_CONTENT) {
- this.backOffRequestCounter = this.backOffRequestCounter += 1;
- /* eslint-disable no-unused-expressions */
- this.backOffRequestCounter < 3 ? next() : stop(res);
- } else {
- stop(res);
- }
- })
- .catch(stop);
- })
- .then((res) => {
- if (res.status === statusCodes.NO_CONTENT) {
- return res;
- }
-
- return res.json();
- })
- .then((res) => {
- this.computeGraphData(res.metrics, res.deployment_time);
- return res;
- })
- .catch(() => {
- this.loadFailed = true;
- this.loadingMetrics = false;
- });
- },
- },
- mounted() {
- this.loadingMetrics = true;
- this.loadMetrics();
- },
- template: `
- <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage">
- <div class="legend"></div>
- <p
- v-if="shouldShowLoading"
- class="usage-info js-usage-info usage-info-loading">
- <i
- class="fa fa-spinner fa-spin usage-info-load-spinner"
- aria-hidden="true" />Loading deployment statistics.
- </p>
- <p
- v-if="shouldShowMemoryGraph"
- class="usage-info js-usage-info">
- Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB
- </p>
- <p
- v-if="shouldShowLoadFailure"
- class="usage-info js-usage-info usage-info-failed">
- Failed to load deployment statistics.
- </p>
- <p
- v-if="shouldShowMetricsUnavailable"
- class="usage-info js-usage-info usage-info-unavailable">
- Deployment statistics are not available currently.
- </p>
- <mr-memory-graph
- v-if="shouldShowMemoryGraph"
- :metrics="memoryMetrics"
- :deploymentTime="deploymentTime"
- height="25"
- width="100" />
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
deleted file mode 100644
index 2fecebce7a0..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
+++ /dev/null
@@ -1,23 +0,0 @@
-export default {
- name: 'MRWidgetMergeHelp',
- props: {
- missingBranch: { type: String, required: false, default: '' },
- },
- template: `
- <section class="mr-widget-help">
- <template
- v-if="missingBranch">
- If the {{missingBranch}} branch exists in your local repository, you
- </template>
- <template v-else>
- You
- </template>
- can merge this merge request manually using the
- <a
- data-toggle="modal"
- href="#modal_merge_info">
- command line.
- </a>
- </section>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
new file mode 100644
index 00000000000..62b61e1f41f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
@@ -0,0 +1,41 @@
+<script>
+ import { sprintf, s__ } from '~/locale';
+
+ export default {
+ name: 'MRWidgetMergeHelp',
+ props: {
+ missingBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ missingBranchInfo() {
+ return sprintf(
+ s__('mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the'),
+ { branch: this.missingBranch },
+ );
+ },
+ },
+ };
+</script>
+<template>
+ <section class="mr-widget-help">
+ <template v-if="missingBranch">
+ {{ missingBranchInfo }}
+ </template>
+ <template v-else>
+ {{ s__("mrWidget|You can merge this merge request manually using the") }}
+ </template>
+
+ <button
+ type="button"
+ class="btn-link btn-blank js-open-modal-help"
+ data-toggle="modal"
+ data-target="#modal_merge_info"
+ >
+ {{ s__("mrWidget|command line") }}
+ </button>
+ </section>
+</template>
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
deleted file mode 100644
index e8b3cf2f729..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import PipelineStage from '../../pipelines/components/stage.vue';
-import ciIcon from '../../vue_shared/components/ci_icon.vue';
-import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
-
-export default {
- name: 'MRWidgetPipeline',
- props: {
- mr: { type: Object, required: true },
- },
- components: {
- 'pipeline-stage': PipelineStage,
- ciIcon,
- },
- computed: {
- hasCIError() {
- const { hasCI, ciStatus } = this.mr;
-
- return hasCI && !ciStatus;
- },
- hasPipeline() {
- return Object.keys(this.mr.pipeline || {}).length > 0;
- },
- svg() {
- return statusIconEntityMap.icon_status_failed;
- },
- stageText() {
- return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage';
- },
- status() {
- return this.mr.pipeline.details.status || {};
- },
- },
- template: `
- <div class="mr-widget-heading">
- <div class="ci-widget">
- <template v-if="!hasPipeline">
- <i class="fa fa-spinner fa-spin append-right-10" aria-hidden="true"></i>
- Waiting for pipeline...
- </template>
- <template v-else-if="hasCIError">
- <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error">
- <span class="js-icon-link icon-link">
- <span
- v-html="svg"
- aria-hidden="true"></span>
- </span>
- </div>
- <span>Could not connect to the CI server. Please check your settings and try again.</span>
- </template>
- <template v-else>
- <div>
- <a
- class="icon-link"
- :href="this.status.details_path">
- <ci-icon :status="status" />
- </a>
- </div>
- <span>
- Pipeline
- <a
- :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">
- <div class="stage-cell">
- <div
- v-if="mr.pipeline.details.stages.length > 0"
- v-for="stage in mr.pipeline.details.stages"
- class="stage-container dropdown js-mini-pipeline-graph">
- <pipeline-stage :stage="stage" />
- </div>
- </div>
- </div>
- <span>
- for
- <a
- :href="mr.pipeline.commit.commit_path"
- class="commit-sha js-commit-link">
- {{mr.pipeline.commit.short_id}}</a>.
- </span>
- <span
- v-if="mr.pipeline.coverage"
- class="js-mr-coverage">
- Coverage {{mr.pipeline.coverage}}%.
- </span>
- </template>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
new file mode 100644
index 00000000000..54a98abf860
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -0,0 +1,110 @@
+<script>
+ /* eslint-disable vue/require-default-prop */
+ import pipelineStage from '~/pipelines/components/stage.vue';
+ import ciIcon from '~/vue_shared/components/ci_icon.vue';
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ name: 'MRWidgetPipeline',
+ components: {
+ pipelineStage,
+ ciIcon,
+ icon,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ // This prop needs to be camelCase, html attributes are case insensive
+ // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case
+ hasCi: {
+ type: Boolean,
+ required: false,
+ },
+ ciStatus: {
+ type: String,
+ required: false,
+ },
+ },
+ computed: {
+ hasPipeline() {
+ return this.pipeline && Object.keys(this.pipeline).length > 0;
+ },
+ hasCIError() {
+ return this.hasCi && !this.ciStatus;
+ },
+ status() {
+ return this.pipeline.details &&
+ this.pipeline.details.status ? this.pipeline.details.status : {};
+ },
+ hasStages() {
+ return this.pipeline.details &&
+ this.pipeline.details.stages &&
+ this.pipeline.details.stages.length;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ v-if="hasPipeline || hasCIError"
+ class="mr-widget-heading">
+ <div class="ci-widget media">
+ <template v-if="hasCIError">
+ <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10">
+ <icon name="status_failed" />
+ </div>
+ <div class="media-body">
+ Could not connect to the CI server. Please check your settings and try again
+ </div>
+ </template>
+ <template v-else-if="hasPipeline">
+ <a
+ class="append-right-10"
+ :href="status.details_path"
+ >
+ <ci-icon :status="status" />
+ </a>
+
+ <div class="media-body">
+ Pipeline
+ <a
+ :href="pipeline.path"
+ class="pipeline-id"
+ >
+ #{{ pipeline.id }}
+ </a>
+
+ {{ pipeline.details.status.label }} for
+
+ <a
+ :href="pipeline.commit.commit_path"
+ class="commit-sha js-commit-link"
+ >
+ {{ pipeline.commit.short_id }}</a>.
+
+ <span class="mr-widget-pipeline-graph">
+ <span
+ class="stage-cell"
+ v-if="hasStages"
+ >
+ <div
+ v-for="(stage, i) in pipeline.details.stages"
+ :key="i"
+ class="stage-container dropdown js-mini-pipeline-graph"
+ >
+ <pipeline-stage :stage="stage" />
+ </div>
+ </span>
+ </span>
+
+ <template v-if="pipeline.coverage">
+ Coverage {{ pipeline.coverage }}%
+ </template>
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
deleted file mode 100644
index 205804670fa..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export default {
- name: 'MRWidgetRelatedLinks',
- props: {
- relatedLinks: { type: Object, required: true },
- },
- computed: {
- hasLinks() {
- const { closing, mentioned, assignToMe } = this.relatedLinks;
- return closing || mentioned || assignToMe;
- },
- },
- methods: {
- hasMultipleIssues(text) {
- return !text ? false : text.match(/<\/a> and <a/);
- },
- issueLabel(field) {
- return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue';
- },
- verbLabel(field) {
- return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is';
- },
- },
- template: `
- <section
- v-if="hasLinks"
- class="mr-info-list mr-links">
- <div class="legend"></div>
- <p v-if="relatedLinks.closing">
- Closes {{issueLabel('closing')}}
- <span v-html="relatedLinks.closing"></span>.
- </p>
- <p v-if="relatedLinks.mentioned">
- <span class="capitalize">{{issueLabel('mentioned')}}</span>
- <span v-html="relatedLinks.mentioned"></span>
- {{verbLabel('mentioned')}} mentioned but will not be closed.
- </p>
- <p v-if="relatedLinks.assignToMe">
- <span v-html="relatedLinks.assignToMe"></span>
- </p>
- </section>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
new file mode 100644
index 00000000000..88d0fcd70f5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -0,0 +1,43 @@
+<script>
+ import { s__ } from '~/locale';
+
+ export default {
+ name: 'MRWidgetRelatedLinks',
+ props: {
+ relatedLinks: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ state: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ closesText() {
+ if (this.state === 'merged') {
+ return s__('mrWidget|Closed');
+ }
+ if (this.state === 'closed') {
+ return s__('mrWidget|Did not close');
+ }
+ return s__('mrWidget|Closes');
+ },
+ },
+ };
+</script>
+<template>
+ <section class="mr-info-list mr-links">
+ <p v-if="relatedLinks.closing">
+ {{ closesText }} <span v-html="relatedLinks.closing"></span>
+ </p>
+ <p v-if="relatedLinks.mentioned">
+ {{ s__("mrWidget|Mentions") }} <span v-html="relatedLinks.mentioned"></span>
+ </p>
+ <p v-if="relatedLinks.assignToMe">
+ <span v-html="relatedLinks.assignToMe"></span>
+ </p>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
new file mode 100644
index 00000000000..1fdc3218671
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -0,0 +1,57 @@
+<script>
+ import ciIcon from '../../vue_shared/components/ci_icon.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ components: {
+ ciIcon,
+ loadingIcon,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ showDisabledButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.status === 'loading';
+ },
+ statusObj() {
+ return {
+ group: this.status,
+ icon: `status_${this.status}`,
+ };
+ },
+ },
+ };
+</script>
+<template>
+ <div class="space-children flex-container-block append-right-10">
+ <div
+ v-if="isLoading"
+ class="mr-widget-icon"
+ >
+ <loading-icon />
+ </div>
+
+ <ci-icon
+ v-else
+ :status="statusObj"
+ />
+
+ <button
+ v-if="showDisabledButton"
+ type="button"
+ class="js-disabled-merge-button btn btn-success btn-sm"
+ disabled="true"
+ >
+ {{ s__("mrWidget|Merge") }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
new file mode 100644
index 00000000000..460437ceeff
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
@@ -0,0 +1,34 @@
+<script>
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { __ } from '../../locale';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ created() {
+ this.removesBranchText = __('<strong>Removes</strong> source branch');
+ this.tooltipTitle = __('A user with write access to the source branch selected this option');
+ },
+ };
+</script>
+
+<template>
+ <p
+ v-once
+ class="mr-info-list mr-links source-branch-removal-status append-bottom-0"
+ >
+ <span
+ class="status-text"
+ v-html="removesBranchText"
+ >
+ </span>
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ :title="tooltipTitle"
+ :aria-label="tooltipTitle"
+ >
+ </i>
+ </p>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
deleted file mode 100644
index c7f25a1697c..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
+++ /dev/null
@@ -1,16 +0,0 @@
-export default {
- name: 'MRWidgetArchived',
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- This project is archived, write access has been disabled.
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
new file mode 100644
index 00000000000..cfbd44d41b2
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -0,0 +1,31 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetArchived',
+ components: {
+ statusIcon,
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <div class="space-children">
+ <status-icon
+ status="warning"
+ />
+ <button
+ type="button"
+ class="btn btn-success btn-sm"
+ disabled="true"
+ >
+ {{ s__("mrWidget|Merge") }}
+ </button>
+ </div>
+ <div class="media-body">
+ <span class="bold">
+ {{ s__("mrWidget|This project is archived, write access has been disabled") }}
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
deleted file mode 100644
index 4063859d5d0..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import eventHub from '../../event_hub';
-
-export default {
- name: 'MRWidgetAutoMergeFailed',
- props: {
- mr: { type: Object, required: true },
- },
- data() {
- return {
- isRefreshing: false,
- };
- },
- methods: {
- refreshWidget() {
- this.isRefreshing = true;
- eventHub.$emit('MRWidgetUpdateRequested', () => {
- this.isRefreshing = false;
- });
- },
- },
- template: `
- <div class="mr-widget-body">
- <button
- class="btn btn-success btn-small"
- disabled="true"
- type="button">
- Merge
- </button>
- <span class="bold danger">
- This merge request failed to be merged automatically.
- <button
- @click="refreshWidget"
- :class="{ disabled: isRefreshing }"
- type="button"
- class="btn btn-xs btn-default">
- <i
- v-if="isRefreshing"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- Refresh
- </button>
- </span>
- <div class="merge-error-text danger bold">
- {{mr.mergeError}}
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
new file mode 100644
index 00000000000..ebaf2b972eb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -0,0 +1,55 @@
+<script>
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import eventHub from '../../event_hub';
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetAutoMergeFailed',
+ components: {
+ statusIcon,
+ loadingIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isRefreshing: false,
+ };
+ },
+ methods: {
+ refreshWidget() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isRefreshing = false;
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="warning" />
+ <div class="media-body space-children">
+ <span class="bold">
+ <template v-if="mr.mergeError">{{ mr.mergeError }}.</template>
+ {{ s__("mrWidget|This merge request failed to be merged automatically") }}
+ </span>
+ <button
+ @click="refreshWidget"
+ :disabled="isRefreshing"
+ type="button"
+ class="btn btn-xs btn-default"
+ >
+ <loading-icon
+ v-if="isRefreshing"
+ :inline="true"
+ />
+ {{ s__("mrWidget|Refresh") }}
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
deleted file mode 100644
index 8515b54e62d..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
+++ /dev/null
@@ -1,19 +0,0 @@
-export default {
- name: 'MRWidgetChecking',
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- Checking ability to merge automatically.
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
new file mode 100644
index 00000000000..caeaac75b45
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -0,0 +1,23 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetChecking',
+ components: {
+ statusIcon,
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="loading"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__("mrWidget|Checking ability to merge automatically") }}
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
deleted file mode 100644
index fc2e42c6821..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
-
-export default {
- name: 'MRWidgetClosed',
- props: {
- mr: { type: Object, required: true },
- },
- components: {
- 'mr-widget-author-and-time': mrWidgetAuthorTime,
- },
- template: `
- <div class="mr-widget-body">
- <mr-widget-author-and-time
- actionText="Closed by"
- :author="mr.closedBy"
- :dateTitle="mr.updatedAt"
- :dateReadable="mr.closedAt"
- />
- <section>
- <p>
- The changes were not merged into
- <a
- :href="mr.targetBranchPath"
- class="label-branch">
- {{mr.targetBranch}}</a>.
- </p>
- </section>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
new file mode 100644
index 00000000000..68b691fc914
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -0,0 +1,48 @@
+<script>
+ import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetClosed',
+ components: {
+ mrWidgetAuthorTime,
+ statusIcon,
+ },
+ props: {
+ /* TODO: This is providing all store and service down when it
+ only needs metrics and targetBranch */
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ />
+ <div class="media-body">
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Closed by')"
+ :author="mr.metrics.closedBy"
+ :date-title="mr.metrics.closedAt"
+ :date-readable="mr.metrics.readableClosedAt"
+ />
+
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes were not merged into") }}
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch"
+ >
+ {{ mr.targetBranch }}
+ </a>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
deleted file mode 100644
index 36596c6f37e..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
+++ /dev/null
@@ -1,39 +0,0 @@
-export default {
- name: 'MRWidgetConflicts',
- props: {
- mr: { type: Object, required: true },
- },
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- There are merge conflicts.
- <span v-if="!mr.canMerge">
- Resolve these conflicts or ask someone with write access to this repository to merge it locally.
- </span>
- </span>
- <div
- v-if="mr.canMerge"
- class="btn-group">
- <a
- v-if="mr.conflictResolutionPath"
- :href="mr.conflictResolutionPath"
- class="btn btn-default btn-xs js-resolve-conflicts-button">
- Resolve conflicts
- </a>
- <a
- v-if="mr.canMerge"
- class="btn btn-default btn-xs js-merge-locally-button"
- data-toggle="modal"
- href="#modal_merge_info">
- Merge locally
- </a>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
new file mode 100644
index 00000000000..dad4b0fe49d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -0,0 +1,61 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetConflicts',
+ components: {
+ statusIcon,
+ },
+ props: {
+ /* TODO: This is providing all store and service down when it
+ only needs a few props */
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+
+ <div class="media-body space-children">
+ <span
+ v-if="mr.shouldBeRebased"
+ class="bold"
+ >
+ {{ s__(`mrWidget|Fast-forward merge is not possible.
+To merge this request, first rebase locally.`) }}
+ </span>
+ <template v-else>
+ <span class="bold">
+ {{ s__("mrWidget|There are merge conflicts") }}<span v-if="!mr.canMerge">.</span>
+ <span v-if="!mr.canMerge">
+ {{ s__(`mrWidget|Resolve these conflicts or ask someone
+ with write access to this repository to merge it locally`) }}
+ </span>
+ </span>
+ <a
+ v-if="mr.canMerge && mr.conflictResolutionPath"
+ :href="mr.conflictResolutionPath"
+ class="js-resolve-conflicts-button btn btn-default btn-xs"
+ >
+ {{ s__("mrWidget|Resolve conflicts") }}
+ </a>
+ <button
+ v-if="mr.canMerge"
+ class="js-merge-locally-button btn btn-default btn-xs"
+ data-toggle="modal"
+ data-target="#modal_merge_info"
+ >
+ {{ s__("mrWidget|Merge locally") }}
+ </button>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
deleted file mode 100644
index 600b4d42e3d..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import eventHub from '../../event_hub';
-
-export default {
- name: 'MRWidgetFailedToMerge',
- props: {
- mr: { type: Object, required: true },
- },
- data() {
- return {
- timer: 10,
- isRefreshing: false,
- };
- },
- mounted() {
- setInterval(() => {
- this.updateTimer();
- }, 1000);
- },
- created() {
- eventHub.$emit('DisablePolling');
- },
- computed: {
- timerText() {
- return this.timer > 1 ? `${this.timer} seconds` : 'a second';
- },
- },
- methods: {
- refresh() {
- this.isRefreshing = true;
- eventHub.$emit('MRWidgetUpdateRequested');
- eventHub.$emit('EnablePolling');
- },
- updateTimer() {
- this.timer = this.timer - 1;
-
- if (this.timer === 0) {
- this.refresh();
- }
- },
- },
- template: `
- <div class="mr-widget-body">
- <button
- class="btn btn-success btn-small"
- disabled="true"
- type="button">
- Merge
- </button>
- <span
- v-if="!isRefreshing"
- class="bold danger">
- <span
- class="has-error-message"
- v-if="mr.mergeError">
- {{mr.mergeError}}
- </span>
- <span v-else>Merge failed.</span>
- <span
- :class="{ 'has-custom-error': mr.mergeError }">
- Refreshing in {{timerText}} to show the updated status...
- </span>
- <button
- @click="refresh"
- class="btn btn-default btn-xs js-refresh-button"
- type="button">
- Refresh now
- </button>
- </span>
- <span
- v-if="isRefreshing"
- class="bold js-refresh-label">
- Refreshing now...
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
new file mode 100644
index 00000000000..602b68ea572
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -0,0 +1,105 @@
+<script>
+ import { n__ } from '~/locale';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import eventHub from '../../event_hub';
+
+ export default {
+ name: 'MRWidgetFailedToMerge',
+
+ components: {
+ statusIcon,
+ },
+
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+
+ data() {
+ return {
+ timer: 10,
+ isRefreshing: false,
+ };
+ },
+
+ computed: {
+ timerText() {
+ return n__(
+ 'Refreshing in a second to show the updated status...',
+ 'Refreshing in %d seconds to show the updated status...',
+ this.timer,
+ );
+ },
+ },
+
+ mounted() {
+ setInterval(() => {
+ this.updateTimer();
+ }, 1000);
+ },
+
+ created() {
+ eventHub.$emit('DisablePolling');
+ },
+
+ methods: {
+ refresh() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('EnablePolling');
+ },
+ updateTimer() {
+ this.timer = this.timer - 1;
+
+ if (this.timer === 0) {
+ this.refresh();
+ }
+ },
+ },
+
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <template v-if="isRefreshing">
+ <status-icon status="loading" />
+ <span class="media-body bold js-refresh-label">
+ {{ s__("mrWidget|Refreshing now") }}
+ </span>
+ </template>
+ <template v-else>
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ <span
+ class="has-error-message"
+ v-if="mr.mergeError"
+ >
+ {{ mr.mergeError }}.
+ </span>
+ <span v-else>
+ {{ s__("mrWidget|Merge failed.") }}
+ </span>
+ <span
+ :class="{ 'has-custom-error': mr.mergeError }"
+ >
+ {{ timerText }}
+ </span>
+ </span>
+ <button
+ @click="refresh"
+ class="btn btn-default btn-xs js-refresh-button"
+ type="button"
+ >
+ {{ s__("mrWidget|Refresh now") }}
+ </button>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js
deleted file mode 100644
index 0bd31731a0b..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export default {
- name: 'MRWidgetLocked',
- props: {
- mr: { type: Object, required: true },
- },
- template: `
- <div class="mr-widget-body mr-state-locked">
- <span class="state-label">Locked</span>
- This merge request is in the process of being merged, during which time it is locked and cannot be closed.
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- <section class="mr-info-list mr-links">
- <div class="legend"></div>
- <p>
- The changes will be merged into
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
- </span>.
- </p>
- </section>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
deleted file mode 100644
index 419d174f3ff..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/* global Flash */
-
-import MRWidgetAuthor from '../../components/mr_widget_author';
-import eventHub from '../../event_hub';
-
-export default {
- name: 'MRWidgetMergeWhenPipelineSucceeds',
- props: {
- mr: { type: Object, required: true },
- service: { type: Object, required: true },
- },
- components: {
- 'mr-widget-author': MRWidgetAuthor,
- },
- data() {
- return {
- isCancellingAutoMerge: false,
- isRemovingSourceBranch: false,
- };
- },
- computed: {
- canRemoveSourceBranch() {
- const { shouldRemoveSourceBranch, canRemoveSourceBranch,
- mergeUserId, currentUserId } = this.mr;
-
- return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId;
- },
- },
- methods: {
- cancelAutomaticMerge() {
- this.isCancellingAutoMerge = true;
- this.service.cancelAutomaticMerge()
- .then(res => res.json())
- .then((res) => {
- eventHub.$emit('UpdateWidgetData', res);
- })
- .catch(() => {
- this.isCancellingAutoMerge = false;
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
- },
- removeSourceBranch() {
- const options = {
- sha: this.mr.sha,
- merge_when_pipeline_succeeds: true,
- should_remove_source_branch: true,
- };
-
- this.isRemovingSourceBranch = true;
- this.service.mergeResource.save(options)
- .then(res => res.json())
- .then((res) => {
- if (res.status === 'merge_when_pipeline_succeeds') {
- eventHub.$emit('MRWidgetUpdateRequested');
- }
- })
- .catch(() => {
- this.isRemovingSourceBranch = false;
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
- },
- },
- template: `
- <div class="mr-widget-body">
- <h4>
- Set by
- <mr-widget-author :author="mr.setToMWPSBy" />
- to be merged automatically when the pipeline succeeds.
- <a
- v-if="mr.canCancelAutomaticMerge"
- @click.prevent="cancelAutomaticMerge"
- :disabled="isCancellingAutoMerge"
- role="button"
- href="#"
- class="btn btn-xs btn-default js-cancel-auto-merge">
- <i
- v-if="isCancellingAutoMerge"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- Cancel automatic merge
- </a>
- </h4>
- <section class="mr-info-list">
- <div class="legend"></div>
- <p>The changes will be merged into
- <a
- :href="mr.targetBranchPath"
- class="label-branch">
- {{mr.targetBranch}}
- </a>.
- </p>
- <p v-if="mr.shouldRemoveSourceBranch">
- The source branch will be removed.
- </p>
- <p
- v-else
- class="with-button">
- The source branch will not be removed.
- <a
- v-if="canRemoveSourceBranch"
- :disabled="isRemovingSourceBranch"
- @click.prevent="removeSourceBranch"
- role="button"
- class="btn btn-xs btn-default js-remove-source-branch"
- href="#">
- <i
- v-if="isRemovingSourceBranch"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- Remove source branch
- </a>
- </p>
- </section>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
new file mode 100644
index 00000000000..7ff7fc7988a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
@@ -0,0 +1,147 @@
+<script>
+ import Flash from '../../../flash';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import mrWidgetAuthor from '../../components/mr_widget_author.vue';
+ import eventHub from '../../event_hub';
+
+ export default {
+ name: 'MRWidgetMergeWhenPipelineSucceeds',
+ components: {
+ mrWidgetAuthor,
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ service: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isCancellingAutoMerge: false,
+ isRemovingSourceBranch: false,
+ };
+ },
+ computed: {
+ canRemoveSourceBranch() {
+ const {
+ shouldRemoveSourceBranch,
+ canRemoveSourceBranch,
+ mergeUserId,
+ currentUserId,
+ } = this.mr;
+
+ return !shouldRemoveSourceBranch &&
+ canRemoveSourceBranch &&
+ mergeUserId === currentUserId;
+ },
+ },
+ methods: {
+ cancelAutomaticMerge() {
+ this.isCancellingAutoMerge = true;
+ this.service.cancelAutomaticMerge()
+ .then(res => res.data)
+ .then((data) => {
+ eventHub.$emit('UpdateWidgetData', data);
+ })
+ .catch(() => {
+ this.isCancellingAutoMerge = false;
+ Flash('Something went wrong. Please try again.');
+ });
+ },
+ removeSourceBranch() {
+ const options = {
+ sha: this.mr.sha,
+ merge_when_pipeline_succeeds: true,
+ should_remove_source_branch: true,
+ };
+
+ this.isRemovingSourceBranch = true;
+ this.service.merge(options)
+ .then(res => res.data)
+ .then((data) => {
+ if (data.status === 'merge_when_pipeline_succeeds') {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ }
+ })
+ .catch(() => {
+ this.isRemovingSourceBranch = false;
+ Flash('Something went wrong. Please try again.');
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <h4 class="flex-container-block">
+ <span class="append-right-10">
+ {{ s__("mrWidget|Set by") }}
+ <mr-widget-author :author="mr.setToMWPSBy" />
+ {{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }}
+ </span>
+ <a
+ v-if="mr.canCancelAutomaticMerge"
+ @click.prevent="cancelAutomaticMerge"
+ :disabled="isCancellingAutoMerge"
+ role="button"
+ href="#"
+ class="btn btn-xs btn-default js-cancel-auto-merge">
+ <i
+ v-if="isCancellingAutoMerge"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ >
+ </i>
+ {{ s__("mrWidget|Cancel automatic merge") }}
+ </a>
+ </h4>
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes will be merged into") }}
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch"
+ >
+ {{ mr.targetBranch }}
+ </a>
+ </p>
+ <p v-if="mr.shouldRemoveSourceBranch">
+ {{ s__("mrWidget|The source branch will be removed") }}
+ </p>
+ <p
+ v-else
+ class="flex-container-block"
+ >
+ <span class="append-right-10">
+ {{ s__("mrWidget|The source branch will not be removed") }}
+ </span>
+ <a
+ v-if="canRemoveSourceBranch"
+ :disabled="isRemovingSourceBranch"
+ @click.prevent="removeSourceBranch"
+ role="button"
+ class="btn btn-xs btn-default js-remove-source-branch"
+ href="#"
+ >
+ <i
+ v-if="isRemovingSourceBranch"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ >
+ </i>
+ {{ s__("mrWidget|Remove source branch") }}
+ </a>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
deleted file mode 100644
index c7d32d18141..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/* global Flash */
-
-import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
-import eventHub from '../../event_hub';
-
-export default {
- name: 'MRWidgetMerged',
- props: {
- mr: { type: Object, required: true },
- service: { type: Object, required: true },
- },
- components: {
- 'mr-widget-author-and-time': mrWidgetAuthorTime,
- },
- data() {
- return {
- isMakingRequest: false,
- };
- },
- computed: {
- shouldShowRemoveSourceBranch() {
- const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr;
-
- return !sourceBranchRemoved && canRemoveSourceBranch &&
- !this.isMakingRequest && !isRemovingSourceBranch;
- },
- shouldShowSourceBranchRemoving() {
- const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr;
- return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest);
- },
- shouldShowMergedButtons() {
- const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath,
- cherryPickInForkPath } = this.mr;
-
- return canRevertInCurrentMR || canCherryPickInCurrentMR ||
- revertInForkPath || cherryPickInForkPath;
- },
- },
- methods: {
- removeSourceBranch() {
- this.isMakingRequest = true;
- this.service.removeSourceBranch()
- .then(res => res.json())
- .then((res) => {
- if (res.message === 'Branch was removed') {
- eventHub.$emit('MRWidgetUpdateRequested', () => {
- this.isMakingRequest = false;
- });
- }
- })
- .catch(() => {
- this.isMakingRequest = false;
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
- },
- },
- template: `
- <div class="mr-widget-body">
- <mr-widget-author-and-time
- actionText="Merged by"
- :author="mr.mergedBy"
- :dateTitle="mr.updatedAt"
- :dateReadable="mr.mergedAt" />
- <section class="mr-info-list">
- <div class="legend"></div>
- <p>
- The changes were merged into
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
- </span>
- </p>
- <p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p>
- <p v-if="shouldShowRemoveSourceBranch">
- You can remove source branch now.
- <button
- @click="removeSourceBranch"
- :class="{ disabled: isMakingRequest }"
- type="button"
- class="btn btn-xs btn-default js-remove-branch-button">
- Remove Source Branch
- </button>
- </p>
- <p v-if="shouldShowSourceBranchRemoving">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- The source branch is being removed.
- </p>
- </section>
- <div
- v-if="shouldShowMergedButtons"
- class="merged-buttons clearfix">
- <a
- v-if="mr.canRevertInCurrentMR"
- class="btn btn-close btn-sm has-tooltip"
- href="#modal-revert-commit"
- data-toggle="modal"
- data-container="body"
- title="Revert this merge request in a new merge request">
- Revert
- </a>
- <a
- v-else-if="mr.revertInForkPath"
- class="btn btn-close btn-sm has-tooltip"
- data-method="post"
- :href="mr.revertInForkPath"
- title="Revert this merge request in a new merge request">
- Revert
- </a>
- <a
- v-if="mr.canCherryPickInCurrentMR"
- class="btn btn-default btn-sm has-tooltip"
- href="#modal-cherry-pick-commit"
- data-toggle="modal"
- data-container="body"
- title="Cherry-pick this merge request in a new merge request">
- Cherry-pick
- </a>
- <a
- v-else-if="mr.cherryPickInForkPath"
- class="btn btn-default btn-sm has-tooltip"
- data-method="post"
- :href="mr.cherryPickInForkPath"
- title="Cherry-pick this merge request in a new merge request">
- Cherry-pick
- </a>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
new file mode 100644
index 00000000000..c1618bc6ea0
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -0,0 +1,192 @@
+<script>
+ import Flash from '~/flash';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import { s__, __ } from '~/locale';
+ import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import eventHub from '../../event_hub';
+
+ export default {
+ name: 'MRWidgetMerged',
+ directives: {
+ tooltip,
+ },
+ components: {
+ mrWidgetAuthorTime,
+ loadingIcon,
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ service: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ };
+ },
+ computed: {
+ shouldShowRemoveSourceBranch() {
+ const {
+ sourceBranchRemoved,
+ isRemovingSourceBranch,
+ canRemoveSourceBranch,
+ } = this.mr;
+
+ return !sourceBranchRemoved &&
+ canRemoveSourceBranch &&
+ !this.isMakingRequest &&
+ !isRemovingSourceBranch;
+ },
+ shouldShowSourceBranchRemoving() {
+ const {
+ sourceBranchRemoved,
+ isRemovingSourceBranch,
+ } = this.mr;
+ return !sourceBranchRemoved &&
+ (isRemovingSourceBranch || this.isMakingRequest);
+ },
+ shouldShowMergedButtons() {
+ const {
+ canRevertInCurrentMR,
+ canCherryPickInCurrentMR,
+ revertInForkPath,
+ cherryPickInForkPath,
+ } = this.mr;
+
+ return canRevertInCurrentMR ||
+ canCherryPickInCurrentMR ||
+ revertInForkPath ||
+ cherryPickInForkPath;
+ },
+ revertTitle() {
+ return s__('mrWidget|Revert this merge request in a new merge request');
+ },
+ cherryPickTitle() {
+ return s__('mrWidget|Cherry-pick this merge request in a new merge request');
+ },
+ revertLabel() {
+ return s__('mrWidget|Revert');
+ },
+ cherryPickLabel() {
+ return s__('mrWidget|Cherry-pick');
+ },
+ },
+ methods: {
+ removeSourceBranch() {
+ this.isMakingRequest = true;
+
+ this.service.removeSourceBranch()
+ .then(res => res.data)
+ .then((data) => {
+ if (data.message === 'Branch was removed') {
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isMakingRequest = false;
+ });
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ Flash(__('Something went wrong. Please try again.'));
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <div class="space-children">
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Merged by')"
+ :author="mr.metrics.mergedBy"
+ :date-title="mr.metrics.mergedAt"
+ :date-readable="mr.metrics.readableMergedAt"
+ />
+ <a
+ v-if="mr.canRevertInCurrentMR"
+ v-tooltip
+ class="btn btn-close btn-xs"
+ href="#modal-revert-commit"
+ data-toggle="modal"
+ data-container="body"
+ :title="revertTitle"
+ >
+ {{ revertLabel }}
+ </a>
+ <a
+ v-else-if="mr.revertInForkPath"
+ v-tooltip
+ class="btn btn-close btn-xs"
+ data-method="post"
+ :href="mr.revertInForkPath"
+ :title="revertTitle"
+ >
+ {{ revertLabel }}
+ </a>
+ <a
+ v-if="mr.canCherryPickInCurrentMR"
+ v-tooltip
+ class="btn btn-default btn-xs"
+ href="#modal-cherry-pick-commit"
+ data-toggle="modal"
+ data-container="body"
+ :title="cherryPickTitle"
+ >
+ {{ cherryPickLabel }}
+ </a>
+ <a
+ v-else-if="mr.cherryPickInForkPath"
+ v-tooltip
+ class="btn btn-default btn-xs"
+ data-method="post"
+ :href="mr.cherryPickInForkPath"
+ :title="cherryPickTitle"
+ >
+ {{ cherryPickLabel }}
+ </a>
+ </div>
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes were merged into") }}
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
+ </span>
+ </p>
+ <p v-if="mr.sourceBranchRemoved">
+ {{ s__("mrWidget|The source branch has been removed") }}
+ </p>
+ <p
+ v-if="shouldShowRemoveSourceBranch"
+ class="space-children"
+ >
+ <span>{{ s__("mrWidget|You can remove source branch now") }}</span>
+ <button
+ @click="removeSourceBranch"
+ :disabled="isMakingRequest"
+ type="button"
+ class="btn btn-xs btn-default js-remove-branch-button"
+ >
+ {{ s__("mrWidget|Remove Source Branch") }}
+ </button>
+ </p>
+ <p v-if="shouldShowSourceBranchRemoving">
+ <loading-icon :inline="true" />
+ <span>
+ {{ s__("mrWidget|The source branch is being removed") }}
+ </span>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
new file mode 100644
index 00000000000..953ddf40a51
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -0,0 +1,35 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetMerging',
+ components: {
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body mr-state-locked media">
+ <status-icon status="loading" />
+ <div class="media-body">
+ <h4>
+ {{ s__("mrWidget|This merge request is in the process of being merged") }}
+ </h4>
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes will be merged into") }}
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
+ </span>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
deleted file mode 100644
index 328382485f6..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import mrWidgetMergeHelp from '../../components/mr_widget_merge_help';
-
-export default {
- name: 'MRWidgetMissingBranch',
- props: {
- mr: { type: Object, required: true },
- },
- components: {
- 'mr-widget-merge-help': mrWidgetMergeHelp,
- },
- computed: {
- missingBranchName() {
- return this.mr.sourceBranchRemoved ? 'source' : 'target';
- },
- },
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold js-branch-text">
- <span class="capitalize">
- {{missingBranchName}}
- </span> branch does not exist.
- Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch.
- </span>
- <mr-widget-merge-help
- :missing-branch="missingBranchName" />
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
new file mode 100644
index 00000000000..718c0e4b3c6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -0,0 +1,62 @@
+<script>
+ import { sprintf, s__ } from '~/locale';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue';
+
+ export default {
+ name: 'MRWidgetMissingBranch',
+ directives: {
+ tooltip,
+ },
+ components: {
+ mrWidgetMergeHelp,
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ missingBranchName() {
+ return this.mr.sourceBranchRemoved ? 'source' : 'target';
+ },
+ missingBranchNameMessage() {
+ return sprintf(s__('mrWidget| Please restore it or use a different %{missingBranchName} branch'), {
+ missingBranchName: this.missingBranchName,
+ });
+ },
+ message() {
+ return sprintf(s__('mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line'), {
+ missingBranchName: this.missingBranchName,
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+
+ <div class="media-body space-children">
+ <span class="bold js-branch-text">
+ <span class="capitalize">
+ {{ missingBranchName }}
+ </span> {{ s__("mrWidget|branch does not exist.") }}
+ {{ missingBranchNameMessage }}
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ :title="message"
+ :aria-label="message"
+ >
+ </i>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
deleted file mode 100644
index 07169b349be..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export default {
- name: 'MRWidgetNotAllowed',
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- Ready to be merged automatically.
- Ask someone with write access to this repository to merge this request.
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
new file mode 100644
index 00000000000..e4af50b09f8
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
@@ -0,0 +1,25 @@
+<script>
+ import StatusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetNotAllowed',
+ components: {
+ StatusIcon,
+ },
+ };
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="success"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__(`mrWidget|Ready to be merged automatically.
+Ask someone with write access to this repository to merge this request`) }}
+ </span>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 375a382615a..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
+++ /dev/null
@@ -1,42 +0,0 @@
-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 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_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
deleted file mode 100644
index 31c53b679ed..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
+++ /dev/null
@@ -1,16 +0,0 @@
-export default {
- name: 'MRWidgetPipelineBlocked',
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- Pipeline blocked. The pipeline for this merge request requires a manual action to proceed.
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
new file mode 100644
index 00000000000..6d7cc03f7ad
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -0,0 +1,24 @@
+<script>
+ import StatusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetPipelineBlocked',
+ components: {
+ StatusIcon,
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__(`mrWidget|Pipeline blocked.
+The pipeline for this merge request requires a manual action to proceed`) }}
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
index 002820123ca..4d9a2ca530f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
@@ -1,16 +1,18 @@
+import statusIcon from '../mr_widget_status_icon.vue';
+
export default {
name: 'MRWidgetPipelineBlocked',
+ components: {
+ statusIcon,
+ },
template: `
- <div class="mr-widget-body">
- <button
- class="btn btn-success btn-small"
- disabled="true"
- type="button">
- Merge
- </button>
- <span class="bold">
- The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.
- </span>
+ <div class="mr-widget-body media">
+ <status-icon status="warning" :show-disabled-button="true" />
+ <div class="media-body space-children">
+ <span class="bold">
+ The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure
+ </span>
+ </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 fcd4fdaf09f..3c781ccddc8 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
@@ -1,8 +1,9 @@
-/* global Flash */
-
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import simplePoll from '~/lib/utils/simple_poll';
+import MergeRequest from '../../../merge_request';
+import Flash from '../../../flash';
+import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
export default {
@@ -25,35 +26,57 @@ export default {
warningSvg,
};
},
+ components: {
+ statusIcon,
+ },
computed: {
+ shouldShowMergeWhenPipelineSucceedsText() {
+ return this.mr.isPipelineActive;
+ },
commitMessageLinkTitle() {
const withDesc = 'Include description in commit message';
const withoutDesc = "Don't include description in commit message";
return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
},
- mergeButtonClass() {
- const defaultClass = 'btn btn-small btn-success accept-merge-request';
- const failedClass = `${defaultClass} btn-danger`;
- const inActionClass = `${defaultClass} btn-info`;
+ status() {
const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
if (hasCI && !ciStatus) {
- return failedClass;
+ return 'failed';
} else if (!pipeline) {
- return defaultClass;
+ return 'success';
} else if (isPipelineActive) {
- return inActionClass;
+ return 'pending';
} else if (isPipelineFailed) {
+ return 'failed';
+ }
+
+ return 'success';
+ },
+ mergeButtonClass() {
+ const defaultClass = 'btn btn-sm btn-success accept-merge-request';
+ const failedClass = `${defaultClass} btn-danger`;
+ const inActionClass = `${defaultClass} btn-info`;
+
+ if (this.status === 'failed') {
return failedClass;
+ } else if (this.status === 'pending') {
+ return inActionClass;
}
return defaultClass;
},
+ iconClass() {
+ if (this.status === 'failed' || !this.commitMessage.length || !this.mr.isMergeAllowed || this.mr.preventMerge) {
+ return 'warning';
+ }
+ return 'success';
+ },
mergeButtonText() {
if (this.isMergingImmediately) {
return 'Merge in progress';
- } else if (this.mr.isPipelineActive) {
+ } else if (this.shouldShowMergeWhenPipelineSucceedsText) {
return 'Merge when pipeline succeeds';
}
@@ -65,12 +88,12 @@ export default {
isMergeButtonDisabled() {
const { commitMessage } = this;
return Boolean(!commitMessage.length
- || !this.isMergeAllowed()
+ || !this.shouldShowMergeControls()
|| this.isMakingRequest
|| this.mr.preventMerge);
},
isRemoveSourceBranchButtonDisabled() {
- return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
+ return this.isMergeButtonDisabled;
},
shouldShowSquashBeforeMerge() {
const { commitsCount, enableSquashBeforeMerge } = this.mr;
@@ -78,8 +101,8 @@ export default {
},
},
methods: {
- isMergeAllowed() {
- return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed);
+ shouldShowMergeControls() {
+ return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText;
},
updateCommitMessage() {
const cmwd = this.mr.commitMessageWithDescription;
@@ -113,16 +136,16 @@ export default {
this.isMakingRequest = true;
this.service.merge(options)
- .then(res => res.json())
- .then((res) => {
- const hasError = res.status === 'failed' || res.status === 'hook_validation_error';
+ .then(res => res.data)
+ .then((data) => {
+ const hasError = data.status === 'failed' || data.status === 'hook_validation_error';
- if (res.status === 'merge_when_pipeline_succeeds') {
+ if (data.status === 'merge_when_pipeline_succeeds') {
eventHub.$emit('MRWidgetUpdateRequested');
- } else if (res.status === 'success') {
+ } else if (data.status === 'success') {
this.initiateMergePolling();
} else if (hasError) {
- eventHub.$emit('FailedToMerge', res.merge_error);
+ eventHub.$emit('FailedToMerge', data.merge_error);
}
})
.catch(() => {
@@ -137,25 +160,24 @@ export default {
},
handleMergePolling(continuePolling, stopPolling) {
this.service.poll()
- .then(res => res.json())
- .then((res) => {
- if (res.state === 'merged') {
+ .then(res => res.data)
+ .then((data) => {
+ if (data.state === 'merged') {
// If state is merged we should update the widget and stop the polling
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('FetchActionsContent');
- if (window.mergeRequest) {
- window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged');
- window.mergeRequest.decreaseCounter();
- }
+ MergeRequest.setStatusBoxToMerged();
+ MergeRequest.hideCloseButton();
+ MergeRequest.decreaseCounter();
stopPolling();
// If user checked remove source branch and we didn't remove the branch yet
// we should start another polling for source branch remove process
- if (this.removeSourceBranch && res.source_branch_exists) {
+ if (this.removeSourceBranch && data.source_branch_exists) {
this.initiateRemoveSourceBranchPolling();
}
- } else if (res.merge_error) {
- eventHub.$emit('FailedToMerge', res.merge_error);
+ } else if (data.merge_error) {
+ eventHub.$emit('FailedToMerge', data.merge_error);
stopPolling();
} else {
// MR is not merged yet, continue polling until the state becomes 'merged'
@@ -176,11 +198,11 @@ export default {
},
handleRemoveBranchPolling(continuePolling, stopPolling) {
this.service.poll()
- .then(res => res.json())
- .then((res) => {
+ .then(res => res.data)
+ .then((data) => {
// If source branch exists then we should continue polling
// because removing a source branch is a background task and takes time
- if (res.source_branch_exists) {
+ if (data.source_branch_exists) {
continuePolling();
} else {
// Branch is removed. Update widget, stop polling and hide the spinner
@@ -196,84 +218,106 @@ export default {
},
},
template: `
- <div class="mr-widget-body">
- <span class="btn-group">
- <button
- @click="handleMergeButtonClick()"
- :disabled="isMergeButtonDisabled"
- :class="mergeButtonClass"
- type="button">
- <i
- v-if="isMakingRequest"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- {{mergeButtonText}}
- </button>
- <button
- v-if="shouldShowMergeOptionsDropdown"
- :disabled="isMergeButtonDisabled"
- type="button"
- class="btn btn-small btn-info dropdown-toggle"
- data-toggle="dropdown">
- <i
- class="fa fa-caret-down"
- aria-hidden="true" />
- <span class="sr-only">
- Select merge moment
+ <div class="mr-widget-body media">
+ <status-icon :status="iconClass" />
+ <div class="media-body">
+ <div class="mr-widget-body-controls media space-children">
+ <span class="btn-group append-bottom-5">
+ <button
+ @click="handleMergeButtonClick()"
+ :disabled="isMergeButtonDisabled"
+ :class="mergeButtonClass"
+ type="button"
+ class="qa-merge-button">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ {{mergeButtonText}}
+ </button>
+ <button
+ v-if="shouldShowMergeOptionsDropdown"
+ :disabled="isMergeButtonDisabled"
+ type="button"
+ class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
+ data-toggle="dropdown"
+ aria-label="Select merge moment">
+ <i
+ class="fa fa-chevron-down"
+ aria-hidden="true" />
+ </button>
+ <ul
+ v-if="shouldShowMergeOptionsDropdown"
+ class="dropdown-menu dropdown-menu-right"
+ role="menu">
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(true)"
+ class="merge_when_pipeline_succeeds"
+ href="#">
+ <span class="media">
+ <span
+ v-html="successSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="media-body merge-opt-title">Merge when pipeline succeeds</span>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(false, true)"
+ class="accept-merge-request"
+ href="#">
+ <span class="media">
+ <span
+ v-html="warningSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="media-body merge-opt-title">Merge immediately</span>
+ </span>
+ </a>
+ </li>
+ </ul>
</span>
- </button>
- <ul
- v-if="shouldShowMergeOptionsDropdown"
- class="dropdown-menu dropdown-menu-right"
- role="menu">
- <li>
- <a
- @click.prevent="handleMergeButtonClick(true)"
- class="merge_when_pipeline_succeeds"
- href="#">
- <span
- v-html="successSvg"
- class="merge-opt-icon"
- aria-hidden="true"></span>
- <span class="merge-opt-title">Merge when pipeline succeeds</span>
- </a>
- </li>
- <li>
- <a
- @click.prevent="handleMergeButtonClick(false, true)"
- class="accept-merge-request"
- href="#">
- <span
- v-html="warningSvg"
- class="merge-opt-icon"
- aria-hidden="true"></span>
- <span class="merge-opt-title">Merge immediately</span>
- </a>
- </li>
- </ul>
- </span>
- <template v-if="isMergeAllowed()">
- <label class="spacing">
- <input
- id="remove-source-branch-input"
- v-model="removeSourceBranch"
- :disabled="isRemoveSourceBranchButtonDisabled"
- type="checkbox"/> Remove source branch
- </label>
+ <div class="media-body-wrap space-children">
+ <template v-if="shouldShowMergeControls()">
+ <label v-if="mr.canRemoveSourceBranch">
+ <input
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ class="js-remove-source-branch-checkbox"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ type="checkbox"/> Remove source branch
+ </label>
- <!-- Placeholder for EE extension of this component -->
- <squash-before-merge
- v-if="shouldShowSquashBeforeMerge"
- :mr="mr"
- :is-merge-button-disabled="isMergeButtonDisabled" />
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ :mr="mr"
+ :is-merge-button-disabled="isMergeButtonDisabled" />
- <button
- @click="toggleCommitMessageEditor"
- :disabled="isMergeButtonDisabled"
- class="btn btn-default btn-xs"
- type="button">
- Modify commit message
- </button>
+ <span
+ v-if="mr.ffOnlyEnabled"
+ class="js-fast-forward-message">
+ Fast-forward merge without a merge commit
+ </span>
+ <button
+ v-else
+ @click="toggleCommitMessageEditor"
+ :disabled="isMergeButtonDisabled"
+ class="js-modify-commit-message-button btn btn-default btn-xs"
+ type="button">
+ Modify commit message
+ </button>
+ </template>
+ <template v-else>
+ <span class="bold js-resolve-mr-widget-items-message">
+ You can only merge once the items above are resolved
+ </span>
+ </template>
+ </div>
+ </div>
<div
v-if="showCommitMessageEditor"
class="prepend-top-default commit-message-editor">
@@ -293,7 +337,7 @@ export default {
rows="14"
name="Commit message"></textarea>
</div>
- <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p>
+ <p class="hint">Try to keep the first line under 52 characters and the others under 72</p>
<div class="hint">
<a
@click.prevent="updateCommitMessage"
@@ -302,12 +346,7 @@ export default {
</div>
</div>
</div>
- </template>
- <template v-else>
- <span class="bold">
- The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.
- </span>
- </template>
+ </div>
</div>
`,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
new file mode 100644
index 00000000000..143fd328d88
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -0,0 +1,138 @@
+<script>
+ import simplePoll from '../../../lib/utils/simple_poll';
+ import eventHub from '../../event_hub';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+ import Flash from '../../../flash';
+
+ export default {
+ name: 'MRWidgetRebase',
+ components: {
+ statusIcon,
+ loadingIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ rebasingError: null,
+ };
+ },
+ computed: {
+ status() {
+ if (this.mr.rebaseInProgress || this.isMakingRequest) {
+ return 'loading';
+ }
+ if (!this.mr.canPushToSourceBranch && !this.mr.rebaseInProgress) {
+ return 'warning';
+ }
+ return 'success';
+ },
+ showDisabledButton() {
+ return ['failed', 'loading'].includes(this.status);
+ },
+ },
+ methods: {
+ rebase() {
+ this.isMakingRequest = true;
+ this.rebasingError = null;
+
+ this.service.rebase()
+ .then(() => {
+ simplePoll(this.checkRebaseStatus);
+ })
+ .catch((error) => {
+ this.rebasingError = error.merge_error;
+ this.isMakingRequest = false;
+ Flash('Something went wrong. Please try again.');
+ });
+ },
+ checkRebaseStatus(continuePolling, stopPolling) {
+ this.service.poll()
+ .then(res => res.data)
+ .then((res) => {
+ if (res.rebase_in_progress) {
+ continuePolling();
+ } else {
+ this.isMakingRequest = false;
+
+ if (res.merge_error && res.merge_error.length) {
+ this.rebasingError = res.merge_error;
+ Flash('Something went wrong. Please try again.');
+ }
+
+ eventHub.$emit('MRWidgetUpdateRequested');
+ stopPolling();
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ Flash('Something went wrong. Please try again.');
+ stopPolling();
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ :status="status"
+ :show-disabled-button="showDisabledButton"
+ />
+
+ <div class="rebase-state-find-class-convention media media-body space-children">
+ <template v-if="mr.rebaseInProgress || isMakingRequest">
+ <span class="bold">
+ Rebase in progress
+ </span>
+ </template>
+ <template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch">
+ <span class="bold">
+ Fast-forward merge is not possible.
+ Rebase the source branch onto
+ <span class="label-branch">{{ mr.targetBranch }}</span>
+ to allow this merge request to be merged.
+ </span>
+ </template>
+ <template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest">
+ <div
+ class="accept-merge-holder clearfix
+js-toggle-container accept-action media space-children"
+ >
+ <button
+ type="button"
+ class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button"
+ :disabled="isMakingRequest"
+ @click="rebase"
+ >
+ <loading-icon v-if="isMakingRequest" />
+ Rebase
+ </button>
+ <span
+ v-if="!rebasingError"
+ class="bold"
+ >
+ Fast-forward merge is not possible.
+ Rebase the source branch onto the target branch or merge target
+ branch into source branch to allow this merge request to be merged.
+ </span>
+ <span
+ v-else
+ class="bold danger">
+ {{ rebasingError }}
+ </span>
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
deleted file mode 100644
index 79f8ef408e6..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
+++ /dev/null
@@ -1,16 +0,0 @@
-export default {
- name: 'MRWidgetSHAMismatch',
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- The source branch HEAD has recently changed. Please reload the page and review the changes before merging.
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
deleted file mode 100644
index f4ab2d9fa58..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
+++ /dev/null
@@ -1,27 +0,0 @@
-export default {
- name: 'MRWidgetUnresolvedDiscussions',
- props: {
- mr: { type: Object, required: true },
- },
- template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- There are unresolved discussions. Please resolve these discussions
- <span v-if="mr.canCreateIssue">or</span>
- <span v-else>.</span>
- </span>
- <a
- v-if="mr.createIssueToResolveDiscussionsPath"
- :href="mr.createIssueToResolveDiscussionsPath"
- class="btn btn-default btn-xs js-create-issue">
- Create an issue to resolve them later
- </a>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
index cb02ffe93bd..44e1a616a19 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
@@ -1,4 +1,6 @@
-/* global Flash */
+import $ from 'jquery';
+import statusIcon from '../mr_widget_status_icon.vue';
+import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
export default {
@@ -7,42 +9,48 @@ export default {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
+ directives: {
+ tooltip,
+ },
data() {
return {
isMakingRequest: false,
};
},
+ components: {
+ statusIcon,
+ },
methods: {
removeWIP() {
this.isMakingRequest = true;
this.service.removeWIP()
- .then(res => res.json())
- .then((res) => {
- eventHub.$emit('UpdateWidgetData', res);
- new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
+ .then(res => res.data)
+ .then((data) => {
+ eventHub.$emit('UpdateWidgetData', data);
+ new window.Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
$('.merge-request .detail-page-description .title').text(this.mr.title);
})
.catch(() => {
this.isMakingRequest = false;
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ new window.Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
},
template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge</button>
- <span class="bold">
- This merge request is currently Work In Progress and therefore unable to merge
- </span>
- <template v-if="mr.removeWIPPath">
- <i
- class="fa fa-question-circle has-tooltip"
- title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged." />
+ <div class="mr-widget-body media">
+ <status-icon status="warning" :show-disabled-button="Boolean(mr.removeWIPPath)" />
+ <div class="media-body space-children">
+ <span class="bold">
+ This is a Work in Progress
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged"
+ aria-label="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged">
+ </i>
+ </span>
<button
+ v-if="mr.removeWIPPath"
@click="removeWIP"
:disabled="isMakingRequest"
type="button"
@@ -53,7 +61,7 @@ export default {
aria-hidden="true" />
Resolve WIP status
</button>
- </template>
+ </div>
</div>
`,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
new file mode 100644
index 00000000000..3d9161f6926
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -0,0 +1,47 @@
+<script>
+import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+
+export default {
+ name: 'MRWidgetNothingToMerge',
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return { emptyStateSVG };
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body mr-widget-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>
+ <div>
+ <a
+ v-if="mr.newBlobPath"
+ :href="mr.newBlobPath"
+ class="btn btn-inverted btn-save">
+ Create file
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
new file mode 100644
index 00000000000..04100871a94
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -0,0 +1,25 @@
+<script>
+import statusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ name: 'ShaMismatch',
+ components: {
+ statusIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ The source branch HEAD has recently changed.
+ Please reload the page and review the changes before merging.
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
new file mode 100644
index 00000000000..9ade6a91747
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -0,0 +1,33 @@
+<script>
+import statusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ name: 'UnresolvedDiscussions',
+ components: {
+ statusIcon,
+ },
+ props: {
+ mr: { type: Object, required: true },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ There are unresolved discussions. Please resolve these discussions
+ </span>
+ <a
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="btn btn-default btn-xs js-create-issue">
+ Create an issue to resolve them later
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index fe5e1bbb55c..ed15fc6ab0f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -1,7 +1,7 @@
/**
* This file is the centerpiece of an attempt to reduce potential conflicts
* between the CE and EE versions of the MR widget. EE additions to the MR widget should
- * be contained in the ./vue_merge_request_widget/ee directory, and should **extend**
+ * be contained in the ee/vue_merge_request_widget directory, and should **extend**
* rather than mutate CE MR Widget code.
*
* This file should be the only source of conflicts between EE and CE. EE-only components should
@@ -11,34 +11,38 @@
export { default as Vue } from 'vue';
export { default as SmartInterval } from '~/smart_interval';
-export { default as WidgetHeader } from './components/mr_widget_header';
-export { default as WidgetMergeHelp } from './components/mr_widget_merge_help';
-export { default as WidgetPipeline } from './components/mr_widget_pipeline';
-export { default as WidgetDeployment } from './components/mr_widget_deployment';
-export { default as WidgetRelatedLinks } from './components/mr_widget_related_links';
-export { default as MergedState } from './components/states/mr_widget_merged';
-export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge';
-export { default as ClosedState } from './components/states/mr_widget_closed';
-export { default as LockedState } from './components/states/mr_widget_locked';
+export { default as WidgetHeader } from './components/mr_widget_header.vue';
+export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue';
+export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue';
+export { default as Deployment } from './components/deployment.vue';
+export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue';
+export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue';
+export { default as MergedState } from './components/states/mr_widget_merged.vue';
+export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge.vue';
+export { default as ClosedState } from './components/states/mr_widget_closed.vue';
+export { default as MergingState } from './components/states/mr_widget_merging.vue';
export { default as WipState } from './components/states/mr_widget_wip';
-export { default as ArchivedState } from './components/states/mr_widget_archived';
-export { default as ConflictsState } from './components/states/mr_widget_conflicts';
-export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge';
-export { default as MissingBranchState } from './components/states/mr_widget_missing_branch';
-export { default as NotAllowedState } from './components/states/mr_widget_not_allowed';
+export { default as ArchivedState } from './components/states/mr_widget_archived.vue';
+export { default as ConflictsState } from './components/states/mr_widget_conflicts.vue';
+export { default as NothingToMergeState } from './components/states/nothing_to_merge.vue';
+export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue';
+export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue';
export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge';
-export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch';
-export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions';
-export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked';
+export { default as ShaMismatchState } from './components/states/sha_mismatch.vue';
+export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue';
+export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue';
export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed';
-export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds';
-export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed';
-export { default as CheckingState } from './components/states/mr_widget_checking';
+export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue';
+export { default as RebaseState } from './components/states/mr_widget_rebase.vue';
+export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed.vue';
+export { default as CheckingState } from './components/states/mr_widget_checking.vue';
export { default as MRWidgetStore } from './stores/mr_widget_store';
export { default as MRWidgetService } from './services/mr_widget_service';
export { default as eventHub } from './event_hub';
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';
+export { default as SourceBranchRemovalStatus } from './components/source_branch_removal_status.vue';
+
+export { default as mrWidgetOptions } from './mr_widget_options';
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 43ef468c303..69a9132a2da 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -2,8 +2,11 @@ import {
Vue,
mrWidgetOptions,
} from './dependencies';
+import Translate from '../vue_shared/translate';
-document.addEventListener('DOMContentLoaded', () => {
+Vue.use(Translate);
+
+export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
const vm = new Vue(mrWidgetOptions);
@@ -11,4 +14,4 @@ document.addEventListener('DOMContentLoaded', () => {
window.gl.mrWidget = {
checkStatus: vm.checkStatus,
};
-});
+};
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 2339a00ddd0..0be5d9e5a55 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
@@ -1,14 +1,17 @@
-/* global Flash */
-
+import Project from '~/pages/projects/project';
+import SmartInterval from '~/smart_interval';
+import Flash from '../flash';
import {
WidgetHeader,
WidgetMergeHelp,
WidgetPipeline,
- WidgetDeployment,
+ Deployment,
+ WidgetMaintainerEdit,
WidgetRelatedLinks,
MergedState,
ClosedState,
- LockedState,
+ MergingState,
+ RebaseState,
WipState,
ArchivedState,
ConflictsState,
@@ -16,7 +19,7 @@ import {
MissingBranchState,
NotAllowedState,
ReadyToMergeState,
- SHAMismatchState,
+ ShaMismatchState,
UnresolvedDiscussionsState,
PipelineBlockedState,
PipelineFailedState,
@@ -30,13 +33,21 @@ import {
stateMaps,
SquashBeforeMerge,
notify,
+ SourceBranchRemovalStatus,
} from './dependencies';
+import { setFavicon } from '../lib/utils/common_utils';
export default {
el: '#js-vue-mr-widget',
name: 'MRWidget',
+ props: {
+ mrData: {
+ type: Object,
+ required: false,
+ },
+ },
data() {
- const store = new MRWidgetStore(gl.mrWidgetData);
+ const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData);
const service = this.createService(store);
return {
mr: store,
@@ -51,13 +62,14 @@ export default {
return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
},
shouldRenderPipelines() {
- return Object.keys(this.mr.pipeline).length || this.mr.hasCI;
+ return this.mr.hasCI;
},
shouldRenderRelatedLinks() {
- return this.mr.relatedLinks;
+ return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState;
},
- shouldRenderDeployments() {
- return this.mr.deployments.length;
+ shouldRenderSourceBranchRemovalStatus() {
+ return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch &&
+ (!this.mr.isNothingToMergeState && !this.mr.isMergedState);
},
},
methods: {
@@ -71,27 +83,26 @@ export default {
ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
statusPath: store.statusPath,
mergeActionsContentPath: store.mergeActionsContentPath,
+ rebasePath: store.rebasePath,
};
return new MRWidgetService(endpoints);
},
checkStatus(cb) {
- this.service.checkStatus()
- .then(res => res.json())
- .then((res) => {
- this.handleNotification(res);
- this.mr.setData(res);
- this.setFavicon();
+ return this.service.checkStatus()
+ .then(res => res.data)
+ .then((data) => {
+ this.handleNotification(data);
+ this.mr.setData(data);
+ this.setFaviconHelper();
if (cb) {
- cb.call(null, res);
+ cb.call(null, data);
}
})
- .catch(() => {
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
+ .catch(() => new Flash('Something went wrong. Please try again.'));
},
initPolling() {
- this.pollingInterval = new gl.SmartInterval({
+ this.pollingInterval = new SmartInterval({
callback: this.checkStatus,
startingInterval: 10000,
maxInterval: 30000,
@@ -100,7 +111,7 @@ export default {
});
},
initDeploymentsPolling() {
- this.deploymentsInterval = new gl.SmartInterval({
+ this.deploymentsInterval = new SmartInterval({
callback: this.fetchDeployments,
startingInterval: 30000,
maxInterval: 120000,
@@ -109,17 +120,17 @@ export default {
immediateExecution: true,
});
},
- setFavicon() {
+ setFaviconHelper() {
if (this.mr.ciStatusFaviconPath) {
- gl.utils.setFavicon(this.mr.ciStatusFaviconPath);
+ setFavicon(this.mr.ciStatusFaviconPath);
}
},
fetchDeployments() {
- this.service.fetchDeployments()
- .then(res => res.json())
- .then((res) => {
- if (res.length) {
- this.mr.deployments = res;
+ return this.service.fetchDeployments()
+ .then(res => res.data)
+ .then((data) => {
+ if (data.length) {
+ this.mr.deployments = data;
}
})
.catch(() => {
@@ -129,18 +140,18 @@ export default {
fetchActionsContent() {
this.service.fetchMergeActionsContent()
.then((res) => {
- if (res.body) {
+ if (res.data) {
const el = document.createElement('div');
- el.innerHTML = res.body;
+ el.innerHTML = res.data;
document.body.appendChild(el);
+ Project.initRefSwitcher();
}
})
- .catch(() => {
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
+ .catch(() => new Flash('Something went wrong. Please try again.'));
},
handleNotification(data) {
if (data.ci_status === this.mr.ciStatus) return;
+ if (!data.pipeline) return;
const label = data.pipeline.details.status.label;
const title = `Pipeline ${label}`;
@@ -187,7 +198,7 @@ export default {
});
},
handleMounted() {
- this.setFavicon();
+ this.setFaviconHelper();
this.initDeploymentsPolling();
},
},
@@ -202,11 +213,12 @@ export default {
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
'mr-widget-pipeline': WidgetPipeline,
- 'mr-widget-deployment': WidgetDeployment,
+ Deployment,
+ 'mr-widget-maintainer-edit': WidgetMaintainerEdit,
'mr-widget-related-links': WidgetRelatedLinks,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
- 'mr-widget-locked': LockedState,
+ 'mr-widget-merging': MergingState,
'mr-widget-failed-to-merge': FailedToMerge,
'mr-widget-wip': WipState,
'mr-widget-archived': ArchivedState,
@@ -215,7 +227,7 @@ export default {
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
'mr-widget-ready-to-merge': ReadyToMergeState,
- 'mr-widget-sha-mismatch': SHAMismatchState,
+ 'mr-widget-sha-mismatch': ShaMismatchState,
'mr-widget-squash-before-merge': SquashBeforeMerge,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
@@ -223,25 +235,43 @@ export default {
'mr-widget-pipeline-failed': PipelineFailedState,
'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
'mr-widget-auto-merge-failed': AutoMergeFailed,
+ 'mr-widget-rebase': RebaseState,
+ SourceBranchRemovalStatus,
},
template: `
<div class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
<mr-widget-pipeline
v-if="shouldRenderPipelines"
- :mr="mr" />
- <mr-widget-deployment
- v-if="shouldRenderDeployments"
- :mr="mr"
- :service="service" />
- <component
- :is="componentName"
- :mr="mr"
- :service="service" />
- <mr-widget-related-links
- v-if="shouldRenderRelatedLinks"
- :related-links="mr.relatedLinks" />
- <mr-widget-merge-help v-if="shouldRenderMergeHelp" />
+ :pipeline="mr.pipeline"
+ :ci-status="mr.ciStatus"
+ :has-ci="mr.hasCI"
+ />
+ <deployment
+ v-for="deployment in mr.deployments"
+ :key="deployment.id"
+ :deployment="deployment"
+ />
+ <div class="mr-widget-section">
+ <component
+ :is="componentName"
+ :mr="mr"
+ :service="service" />
+ <mr-widget-maintainer-edit
+ :maintainerEditAllowed="mr.maintainerEditAllowed" />
+ <mr-widget-related-links
+ v-if="shouldRenderRelatedLinks"
+ :state="mr.state"
+ :related-links="mr.relatedLinks" />
+ <source-branch-removal-status
+ v-if="shouldRenderSourceBranchRemovalStatus"
+ />
+ </div>
+ <div
+ class="mr-widget-footer"
+ v-if="shouldRenderMergeHelp">
+ <mr-widget-merge-help />
+ </div>
</div>
`,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index 79c3d335679..fecbfec2214 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -1,57 +1,51 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class MRWidgetService {
constructor(endpoints) {
- this.mergeResource = Vue.resource(endpoints.mergePath);
- this.mergeCheckResource = Vue.resource(endpoints.statusPath);
- this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath);
- this.removeWIPResource = Vue.resource(endpoints.removeWIPPath);
- this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath);
- this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath);
- this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`);
- this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath);
+ this.endpoints = endpoints;
}
merge(data) {
- return this.mergeResource.save(data);
+ return axios.post(this.endpoints.mergePath, data);
}
cancelAutomaticMerge() {
- return this.cancelAutoMergeResource.save();
+ return axios.post(this.endpoints.cancelAutoMergePath);
}
removeWIP() {
- return this.removeWIPResource.save();
+ return axios.post(this.endpoints.removeWIPPath);
}
removeSourceBranch() {
- return this.removeSourceBranchResource.delete();
+ return axios.delete(this.endpoints.sourceBranchPath);
}
fetchDeployments() {
- return this.deploymentsResource.get();
+ return axios.get(this.endpoints.ciEnvironmentsStatusPath);
}
poll() {
- return this.pollResource.get();
+ return axios.get(`${this.endpoints.statusPath}?serializer=basic`);
}
checkStatus() {
- return this.mergeCheckResource.get();
+ return axios.get(`${this.endpoints.statusPath}?serializer=widget`);
}
fetchMergeActionsContent() {
- return this.mergeActionsContentResource.get();
+ return axios.get(this.endpoints.mergeActionsContentPath);
+ }
+
+ rebase() {
+ return axios.post(this.endpoints.rebasePath);
}
static stopEnvironment(url) {
- return Vue.http.post(url);
+ return axios.post(url);
}
static fetchMetrics(metricsUrl) {
- return Vue.http.get(`${metricsUrl}.json`);
+ return axios.get(`${metricsUrl}.json`);
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 7c15abfff10..f7f0c1b6cb7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -1,30 +1,34 @@
+import { stateKey } from './state_maps';
+
export default function deviseState(data) {
if (data.project_archived) {
- return 'archived';
+ return stateKey.archived;
} else if (data.branch_missing) {
- return 'missingBranch';
+ return stateKey.missingBranch;
} else if (!data.commits_count) {
- return 'nothingToMerge';
+ return stateKey.nothingToMerge;
} else if (this.mergeStatus === 'unchecked') {
- return 'checking';
+ return stateKey.checking;
} else if (data.has_conflicts) {
- return 'conflicts';
+ return stateKey.conflicts;
} else if (data.work_in_progress) {
- return 'workInProgress';
+ return stateKey.workInProgress;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
- return 'pipelineFailed';
+ return stateKey.pipelineFailed;
} else if (this.hasMergeableDiscussionsState) {
- return 'unresolvedDiscussions';
+ return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
- return 'pipelineBlocked';
+ return stateKey.pipelineBlocked;
} else if (this.hasSHAChanged) {
- return 'shaMismatch';
+ return stateKey.shaMismatch;
} else if (this.mergeWhenPipelineSucceeds) {
- return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
+ return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
} else if (!this.canMerge) {
- return 'notAllowedToMerge';
+ return stateKey.notAllowedToMerge;
+ } else if (this.shouldBeRebased) {
+ return stateKey.rebase;
} else if (this.canBeMerged) {
- return 'readyToMerge';
+ return stateKey.readyToMerge;
}
return null;
}
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 69bc1436284..a47ca9fae86 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
@@ -1,8 +1,9 @@
import Timeago from 'timeago.js';
import { getStateKey } from '../dependencies';
+import { stateKey } from './state_maps';
+import { formatDate } from '../../lib/utils/datetime_utility';
export default class MergeRequestStore {
-
constructor(data) {
this.sha = data.diff_head_sha;
this.gitlabLogo = data.gitlabLogo;
@@ -24,6 +25,7 @@ export default class MergeRequestStore {
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.deployments = this.deployments || data.deployments || [];
+ this.initRebase(data);
if (data.issues_links) {
const links = data.issues_links;
@@ -37,17 +39,15 @@ export default class MergeRequestStore {
}
this.updatedAt = data.updated_at;
- this.mergedAt = MergeRequestStore.getEventDate(data.merge_event);
- this.closedAt = MergeRequestStore.getEventDate(data.closed_event);
- this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event);
- this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event);
- this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} });
+ this.metrics = MergeRequestStore.buildMetrics(data.metrics);
+ this.setToMWPSBy = MergeRequestStore.formatUserObject(data.merge_user || {});
this.mergeUserId = data.merge_user_id;
this.currentUserId = gon.current_user_id;
this.sourceBranchPath = data.source_branch_path;
this.sourceBranchLink = data.source_branch_with_namespace_link;
this.mergeError = data.merge_error;
this.targetBranchPath = data.target_branch_commits_path;
+ this.targetBranchTreePath = data.target_branch_tree_path;
this.conflictResolutionPath = data.conflict_resolution_path;
this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
this.removeWIPPath = data.remove_wip_path;
@@ -56,6 +56,8 @@ export default class MergeRequestStore {
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
this.mergePath = data.merge_path;
+ this.ffOnlyEnabled = data.ff_only_enabled;
+ this.shouldBeRebased = !!data.should_be_rebased;
this.statusPath = data.status_path;
this.emailPatchesPath = data.email_patches_path;
this.plainDiffPath = data.plain_diff_path;
@@ -64,7 +66,7 @@ export default class MergeRequestStore {
this.mergeCheckPath = data.merge_check_path;
this.mergeActionsContentPath = data.commit_change_content_path;
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
- this.isOpen = data.state === 'opened' || data.state === 'reopened' || false;
+ this.isOpen = data.state === 'opened';
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
this.canMerge = !!data.merge_path;
@@ -72,6 +74,9 @@ export default class MergeRequestStore {
this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
this.hasSHAChanged = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false;
+ this.isMergeAllowed = data.mergeable || false;
+ this.mergeOngoing = data.merge_ongoing;
+ this.maintainerEditAllowed = data.allow_maintainer_to_push;
// Cherry-pick and Revert actions related
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
@@ -83,7 +88,9 @@ export default class MergeRequestStore {
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.hasCI = data.has_ci;
this.ciStatus = data.ci_status;
- this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false;
+ this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
+ this.isPipelinePassing = this.ciStatus === 'success' || this.ciStatus === 'success_with_warnings';
+ this.isPipelineSkipped = this.ciStatus === 'skipped';
this.pipelineDetailedStatus = pipelineStatus;
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
@@ -93,6 +100,11 @@ export default class MergeRequestStore {
}
setState(data) {
+ if (this.mergeOngoing) {
+ this.state = 'merging';
+ return;
+ }
+
if (this.isOpen) {
this.state = getStateKey.call(this, data);
} else {
@@ -103,36 +115,62 @@ export default class MergeRequestStore {
case 'closed':
this.state = 'closed';
break;
- case 'locked':
- this.state = 'locked';
- break;
default:
this.state = null;
}
}
}
- static getAuthorObject(event) {
- if (!event) {
+ get isNothingToMergeState() {
+ return this.state === stateKey.nothingToMerge;
+ }
+
+ get isMergedState() {
+ return this.state === stateKey.merged;
+ }
+
+ initRebase(data) {
+ this.canPushToSourceBranch = data.can_push_to_source_branch;
+ this.rebaseInProgress = data.rebase_in_progress;
+ this.approvalsLeft = !data.approved;
+ this.rebasePath = data.rebase_path;
+ }
+
+ static buildMetrics(metrics) {
+ if (!metrics) {
return {};
}
return {
- name: event.author.name || '',
- username: event.author.username || '',
- webUrl: event.author.web_url || '',
- avatarUrl: event.author.avatar_url || '',
+ mergedBy: MergeRequestStore.formatUserObject(metrics.merged_by),
+ closedBy: MergeRequestStore.formatUserObject(metrics.closed_by),
+ mergedAt: formatDate(metrics.merged_at),
+ closedAt: formatDate(metrics.closed_at),
+ readableMergedAt: MergeRequestStore.getReadableDate(metrics.merged_at),
+ readableClosedAt: MergeRequestStore.getReadableDate(metrics.closed_at),
};
}
- static getEventDate(event) {
- const timeagoInstance = new Timeago();
+ static formatUserObject(user) {
+ if (!user) {
+ return {};
+ }
+
+ return {
+ name: user.name || '',
+ username: user.username || '',
+ webUrl: user.web_url || '',
+ avatarUrl: user.avatar_url || '',
+ };
+ }
- if (!event) {
+ static getReadableDate(date) {
+ if (!date) {
return '';
}
- return timeagoInstance.format(event.updated_at);
- }
+ const timeagoInstance = new Timeago();
+ return timeagoInstance.format(date);
+ }
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index 605dd3a1ff4..e080ce5c229 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -1,7 +1,7 @@
const stateToComponentMap = {
merged: 'mr-widget-merged',
closed: 'mr-widget-closed',
- locked: 'mr-widget-locked',
+ merging: 'mr-widget-merging',
conflicts: 'mr-widget-conflicts',
missingBranch: 'mr-widget-missing-branch',
workInProgress: 'mr-widget-wip',
@@ -16,11 +16,12 @@ const stateToComponentMap = {
mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
- shaMismatch: 'mr-widget-sha-mismatch',
+ shaMismatch: 'sha-mismatch',
+ rebase: 'mr-widget-rebase',
};
const statesToShowHelpWidget = [
- 'locked',
+ 'merging',
'conflicts',
'workInProgress',
'readyToMerge',
@@ -29,8 +30,28 @@ const statesToShowHelpWidget = [
'pipelineFailed',
'pipelineBlocked',
'autoMergeFailed',
+ 'rebase',
];
+export const stateKey = {
+ archived: 'archived',
+ missingBranch: 'missingBranch',
+ nothingToMerge: 'nothingToMerge',
+ checking: 'checking',
+ conflicts: 'conflicts',
+ workInProgress: 'workInProgress',
+ pipelineFailed: 'pipelineFailed',
+ unresolvedDiscussions: 'unresolvedDiscussions',
+ pipelineBlocked: 'pipelineBlocked',
+ shaMismatch: 'shaMismatch',
+ autoMergeFailed: 'autoMergeFailed',
+ mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds',
+ notAllowedToMerge: 'notAllowedToMerge',
+ readyToMerge: 'readyToMerge',
+ rebase: 'rebase',
+ merged: 'merged',
+};
+
export default {
stateToComponentMap,
statesToShowHelpWidget,
diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js
deleted file mode 100644
index b21f0ab49fd..00000000000
--- a/app/assets/javascripts/vue_shared/ci_action_icons.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import cancelSVG from 'icons/_icon_action_cancel.svg';
-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) {
- const icons = {
- icon_action_cancel: cancelSVG,
- icon_action_play: playSVG,
- icon_action_retry: retrySVG,
- icon_action_stop: stopSVG,
- };
-
- return icons[action] || '';
-}
diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js
deleted file mode 100644
index d9d0cad38e4..00000000000
--- a/app/assets/javascripts/vue_shared/ci_status_icons.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
-import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
-import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
-import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
-import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
-import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
-import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
-import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
-import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
-
-import CANCELED_SVG from 'icons/_icon_status_canceled.svg';
-import CREATED_SVG from 'icons/_icon_status_created.svg';
-import FAILED_SVG from 'icons/_icon_status_failed.svg';
-import MANUAL_SVG from 'icons/_icon_status_manual.svg';
-import PENDING_SVG from 'icons/_icon_status_pending.svg';
-import RUNNING_SVG from 'icons/_icon_status_running.svg';
-import SKIPPED_SVG from 'icons/_icon_status_skipped.svg';
-import SUCCESS_SVG from 'icons/_icon_status_success.svg';
-import WARNING_SVG from 'icons/_icon_status_warning.svg';
-
-export const borderlessStatusIconEntityMap = {
- icon_status_canceled: BORDERLESS_CANCELED_SVG,
- icon_status_created: BORDERLESS_CREATED_SVG,
- icon_status_failed: BORDERLESS_FAILED_SVG,
- icon_status_manual: BORDERLESS_MANUAL_SVG,
- icon_status_pending: BORDERLESS_PENDING_SVG,
- icon_status_running: BORDERLESS_RUNNING_SVG,
- icon_status_skipped: BORDERLESS_SKIPPED_SVG,
- icon_status_success: BORDERLESS_SUCCESS_SVG,
- icon_status_warning: BORDERLESS_WARNING_SVG,
-};
-
-export const statusIconEntityMap = {
- icon_status_canceled: CANCELED_SVG,
- icon_status_created: CREATED_SVG,
- icon_status_failed: FAILED_SVG,
- icon_status_manual: MANUAL_SVG,
- icon_status_pending: PENDING_SVG,
- icon_status_running: RUNNING_SVG,
- icon_status_skipped: SKIPPED_SVG,
- icon_status_success: SUCCESS_SVG,
- icon_status_warning: WARNING_SVG,
-};
diff --git a/app/assets/javascripts/vue_shared/common_vue.js b/app/assets/javascripts/vue_shared/common_vue.js
deleted file mode 100644
index eb2a6071fda..00000000000
--- a/app/assets/javascripts/vue_shared/common_vue.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import Vue from 'vue';
-import './vue_resource_interceptor';
-
-if (process.env.NODE_ENV !== 'production') {
- Vue.config.productionTip = false;
-}
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index caa28bff6db..5324d5dc797 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -1,52 +1,64 @@
<script>
-import ciIcon from './ci_icon.vue';
-/**
- * Renders CI Badge link with CI icon and status text based on
- * API response shared between all places where it is used.
- *
- * Receives status object containing:
- * status: {
- * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
- * group:"running" // used for CSS class
- * icon: "icon_status_running" // used to render the icon
- * label:"running" // used for potential tooltip
- * text:"running" // text rendered
- * }
- *
- * Used in:
- * - Pipelines table - first column
- * - Jobs table - first column
- * - Pipeline show view - header
- * - Job show view - header
- * - MR widget
- */
+ import ciIcon from './ci_icon.vue';
+ import tooltip from '../directives/tooltip';
+ /**
+ * Renders CI Badge link with CI icon and status text based on
+ * API response shared between all places where it is used.
+ *
+ * Receives status object containing:
+ * status: {
+ * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
+ * group:"running" // used for CSS class
+ * icon: "icon_status_running" // used to render the icon
+ * label:"running" // used for potential tooltip
+ * text:"running" // text rendered
+ * }
+ *
+ * Used in:
+ * - Pipelines table - first column
+ * - Jobs table - first column
+ * - Pipeline show view - header
+ * - Job show view - header
+ * - MR widget
+ */
-export default {
- props: {
- status: {
- type: Object,
- required: true,
+ export default {
+ components: {
+ ciIcon,
},
- },
-
- components: {
- ciIcon,
- },
-
- computed: {
- cssClass() {
- const className = this.status.group;
-
- return className ? `ci-status ci-${this.status.group}` : 'ci-status';
+ directives: {
+ tooltip,
},
- },
-};
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ showText: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ cssClass() {
+ const className = this.status.group;
+ return className ? `ci-status ci-${className}` : 'ci-status';
+ },
+ },
+ };
</script>
<template>
<a
:href="status.details_path"
- :class="cssClass">
+ :class="cssClass"
+ v-tooltip
+ :title="!showText ? status.text : ''"
+ >
<ci-icon :status="status" />
- {{status.text}}
+
+ <template v-if="showText">
+ {{ status.text }}
+ </template>
</a>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index ec88119e16c..8fea746f4de 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -1,5 +1,5 @@
<script>
- import { statusIconEntityMap } from '../ci_status_icons';
+ import icon from '../../vue_shared/components/icon.vue';
/**
* Renders CI icon based on API response shared between all places where it is used.
@@ -23,6 +23,9 @@
* - Jobs show view sidebar
*/
export default {
+ components: {
+ icon,
+ },
props: {
status: {
type: Object,
@@ -31,10 +34,6 @@
},
computed: {
- statusIconSvg() {
- return statusIconEntityMap[this.status.icon];
- },
-
cssClass() {
const status = this.status.group;
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
@@ -43,8 +42,7 @@
};
</script>
<template>
- <span
- :class="cssClass"
- v-html="statusIconSvg">
+ <span :class="cssClass">
+ <icon :name="status.icon" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
new file mode 100644
index 00000000000..cab126a7eca
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -0,0 +1,57 @@
+<script>
+ /**
+ * Falls back to the code used in `copy_to_clipboard.js`
+ */
+ import tooltip from '../directives/tooltip';
+
+ export default {
+ name: 'ClipboardButton',
+ directives: {
+ tooltip,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ tooltipContainer: {
+ type: [String, Boolean],
+ required: false,
+ default: false,
+ },
+ cssClass: {
+ type: String,
+ required: false,
+ default: 'btn-default',
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ type="button"
+ class="btn"
+ :class="cssClass"
+ :title="title"
+ :data-clipboard-text="text"
+ v-tooltip
+ :data-container="tooltipContainer"
+ :data-placement="tooltipPlacement"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-clipboard"
+ >
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 262584769e0..97789636787 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -1,13 +1,22 @@
<script>
import commitIconSvg from 'icons/_icon_commit.svg';
import userAvatarLink from './user_avatar/user_avatar_link.vue';
+ import tooltip from '../directives/tooltip';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ userAvatarLink,
+ icon,
+ },
props: {
/**
* Indicates the existance of a tag.
* Used to render the correct icon, if true will render `fa-tag` icon,
- * if false will render `fa-code-fork` icon.
+ * if false will render a svg sprite fork icon
*/
tag: {
type: Boolean,
@@ -62,14 +71,17 @@
required: false,
default: () => ({}),
},
+ showBranch: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
/**
* Used to verify if all the properties needed to render the commit
* ref section were provided.
*
- * TODO: Improve this! Use lodash _.has when we have it.
- *
* @returns {Boolean}
*/
hasCommitRef() {
@@ -79,8 +91,6 @@
* Used to verify if all the properties needed to render the commit
* author section were provided.
*
- * TODO: Improve this! Use lodash _.has when we have it.
- *
* @returns {Boolean}
*/
hasAuthor() {
@@ -100,51 +110,55 @@
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
- data() {
- return { commitIconSvg };
- },
- components: {
- userAvatarLink,
+ created() {
+ this.commitIconSvg = commitIconSvg;
},
};
</script>
<template>
<div class="branch-commit">
- <div v-if="hasCommitRef" class="icon-container hidden-xs">
- <i
- v-if="tag"
- class="fa fa-tag"
- aria-hidden="true">
- </i>
- <i
- v-if="!tag"
- class="fa fa-code-fork"
- aria-hidden="true">
- </i>
- </div>
-
- <a
- v-if="hasCommitRef"
- class="ref-name hidden-xs"
- :href="commitRef.ref_url">
- {{commitRef.name}}
- </a>
+ <template v-if="hasCommitRef && showBranch">
+ <div class="icon-container">
+ <i
+ v-if="tag"
+ class="fa fa-tag"
+ aria-hidden="true"
+ >
+ </i>
+ <icon
+ v-if="!tag"
+ name="fork"
+ />
+ </div>
+ <a
+ class="ref-name"
+ :href="commitRef.ref_url"
+ v-tooltip
+ data-container="body"
+ :title="commitRef.name"
+ >
+ {{ commitRef.name }}
+ </a>
+ </template>
<div
v-html="commitIconSvg"
- class="commit-icon js-commit-icon">
+ class="commit-icon js-commit-icon"
+ >
</div>
<a
class="commit-sha"
- :href="commitUrl">
- {{shortSha}}
+ :href="commitUrl"
+ >
+ {{ shortSha }}
</a>
<div class="commit-title flex-truncate-parent">
<span
v-if="title"
- class="flex-truncate-child">
+ class="flex-truncate-child"
+ >
<user-avatar-link
v-if="hasAuthor"
class="avatar-image-container"
@@ -153,9 +167,11 @@
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
/>
- <a class="commit-row-message"
- :href="commitUrl">
- {{title}}
+ <a
+ class="commit-row-message"
+ :href="commitUrl"
+ >
+ {{ title }}
</a>
</span>
<span v-else>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
new file mode 100644
index 00000000000..dcf1489b37c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
@@ -0,0 +1,173 @@
+<script>
+ /* eslint-disable vue/require-default-prop */
+ export default {
+ name: 'DeprecatedModal', // use GlModal instead
+
+ props: {
+ id: {
+ type: String,
+ required: false,
+ },
+ title: {
+ type: String,
+ required: false,
+ },
+ text: {
+ type: String,
+ required: false,
+ },
+ hideFooter: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ kind: {
+ type: String,
+ required: false,
+ default: 'primary',
+ },
+ modalDialogClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ closeKind: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
+ closeButtonLabel: {
+ type: String,
+ required: false,
+ default: 'Cancel',
+ },
+ primaryButtonLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ secondaryButtonLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ btnKindClass() {
+ return {
+ [`btn-${this.kind}`]: true,
+ };
+ },
+ btnCancelKindClass() {
+ return {
+ [`btn-${this.closeKind}`]: true,
+ };
+ },
+ },
+
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="modal-open">
+ <div
+ :id="id"
+ class="modal"
+ :class="id ? '' : 'show'"
+ role="dialog"
+ tabindex="-1"
+ >
+ <div
+ :class="modalDialogClass"
+ class="modal-dialog"
+ role="document"
+ >
+ <div class="modal-content">
+ <div class="modal-header">
+ <slot name="header">
+ <h4 class="modal-title pull-left">
+ {{ title }}
+ </h4>
+ <button
+ type="button"
+ class="close pull-right"
+ @click="emitCancel($event)"
+ data-dismiss="modal"
+ aria-label="Close"
+ >
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </slot>
+ </div>
+ <div class="modal-body">
+ <slot
+ name="body"
+ :text="text"
+ >
+ <p>{{ text }}</p>
+ </slot>
+ </div>
+ <div
+ class="modal-footer"
+ v-if="!hideFooter"
+ >
+ <button
+ type="button"
+ class="btn"
+ :class="btnCancelKindClass"
+ @click="emitCancel($event)"
+ data-dismiss="modal"
+ >
+ {{ closeButtonLabel }}
+ </button>
+
+ <slot
+ v-if="secondaryButtonLabel"
+ name="secondary-button"
+ >
+ <button
+ v-if="secondaryButtonLabel"
+ type="button"
+ class="btn"
+ data-dismiss="modal"
+ >
+ {{ secondaryButtonLabel }}
+ </button>
+ </slot>
+
+ <button
+ v-if="primaryButtonLabel"
+ type="button"
+ class="btn js-primary-button"
+ :disabled="submitDisabled"
+ :class="btnKindClass"
+ @click="emitSubmit($event)"
+ data-dismiss="modal"
+ >
+ {{ primaryButtonLabel }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="!id"
+ class="modal-backdrop fade in"
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue
new file mode 100644
index 00000000000..c943c8d98a4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/expand_button.vue
@@ -0,0 +1,46 @@
+<script>
+ import { __ } from '~/locale';
+ /**
+ * Port of detail_behavior expand button.
+ *
+ * @example
+ * <expand-button>
+ * <template slot="expanded">
+ * Text goes here.
+ * </template>
+ * </expand-button>
+ */
+ export default {
+ name: 'ExpandButton',
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+ computed: {
+ ariaLabel() {
+ return __('Click to expand text');
+ },
+ },
+ methods: {
+ onClick() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ },
+ };
+</script>
+<template>
+ <span>
+ <button
+ type="button"
+ v-show="isCollapsed"
+ class="text-expander btn-blank"
+ :aria-label="ariaLabel"
+ @click="onClick">
+ ...
+ </button>
+ <span v-if="!isCollapsed">
+ <slot name="expanded"></slot>
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
new file mode 100644
index 00000000000..ee1c3498748
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -0,0 +1,91 @@
+<script>
+ import getIconForFile from './file_icon/file_icon_map';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+
+ /* This is a re-usable vue component for rendering a svg sprite
+ icon
+
+ Sample configuration:
+
+ <file-icon
+ name="retry"
+ :size="32"
+ css-classes="top"
+ />
+
+ */
+ export default {
+ components: {
+ loadingIcon,
+ icon,
+ },
+ props: {
+ fileName: {
+ type: String,
+ required: true,
+ },
+
+ folder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ opened: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
+
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ spriteHref() {
+ const iconName = getIconForFile(this.fileName) || 'file';
+ return `${gon.sprite_file_icons}#${iconName}`;
+ },
+ folderIconName() {
+ return this.opened ? 'folder-open' : 'folder';
+ },
+ iconSizeClass() {
+ return this.size ? `s${this.size}` : '';
+ },
+ },
+ };
+</script>
+<template>
+ <span>
+ <svg
+ :class="[iconSizeClass, cssClasses]"
+ v-if="!loading && !folder"
+ >
+ <use v-bind="{ 'xlink:href':spriteHref }" />
+ </svg>
+ <icon
+ v-if="!loading && folder"
+ :name="folderIconName"
+ :size="size"
+ />
+ <loading-icon
+ v-if="loading"
+ :inline="true"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
new file mode 100644
index 00000000000..9ffbaae3ea5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -0,0 +1,589 @@
+const fileExtensionIcons = {
+ html: 'html',
+ htm: 'html',
+ html_vm: 'html',
+ asp: 'html',
+ jade: 'pug',
+ pug: 'pug',
+ md: 'markdown',
+ 'md.rendered': 'markdown',
+ markdown: 'markdown',
+ 'markdown.rendered': 'markdown',
+ rst: 'markdown',
+ blink: 'blink',
+ css: 'css',
+ scss: 'sass',
+ sass: 'sass',
+ less: 'less',
+ json: 'json',
+ yaml: 'yaml',
+ 'YAML-tmLanguage': 'yaml',
+ yml: 'yaml',
+ xml: 'xml',
+ plist: 'xml',
+ xsd: 'xml',
+ dtd: 'xml',
+ xsl: 'xml',
+ xslt: 'xml',
+ resx: 'xml',
+ iml: 'xml',
+ xquery: 'xml',
+ tmLanguage: 'xml',
+ manifest: 'xml',
+ project: 'xml',
+ png: 'image',
+ jpeg: 'image',
+ jpg: 'image',
+ gif: 'image',
+ svg: 'image',
+ ico: 'image',
+ tif: 'image',
+ tiff: 'image',
+ psd: 'image',
+ psb: 'image',
+ ami: 'image',
+ apx: 'image',
+ bmp: 'image',
+ bpg: 'image',
+ brk: 'image',
+ cur: 'image',
+ dds: 'image',
+ dng: 'image',
+ exr: 'image',
+ fpx: 'image',
+ gbr: 'image',
+ img: 'image',
+ jbig2: 'image',
+ jb2: 'image',
+ jng: 'image',
+ jxr: 'image',
+ pbm: 'image',
+ pgf: 'image',
+ pic: 'image',
+ raw: 'image',
+ webp: 'image',
+ js: 'javascript',
+ ejs: 'javascript',
+ esx: 'javascript',
+ jsx: 'react',
+ tsx: 'react',
+ ini: 'settings',
+ dlc: 'settings',
+ dll: 'settings',
+ config: 'settings',
+ conf: 'settings',
+ properties: 'settings',
+ prop: 'settings',
+ settings: 'settings',
+ option: 'settings',
+ props: 'settings',
+ toml: 'settings',
+ prefs: 'settings',
+ 'sln.dotsettings': 'settings',
+ 'sln.dotsettings.user': 'settings',
+ ts: 'typescript',
+ 'd.ts': 'typescript-def',
+ marko: 'markojs',
+ pdf: 'pdf',
+ xlsx: 'table',
+ xls: 'table',
+ csv: 'table',
+ tsv: 'table',
+ vscodeignore: 'vscode',
+ vsixmanifest: 'vscode',
+ vsix: 'vscode',
+ 'code-workplace': 'vscode',
+ suo: 'visualstudio',
+ sln: 'visualstudio',
+ csproj: 'visualstudio',
+ vb: 'visualstudio',
+ pdb: 'database',
+ sql: 'database',
+ pks: 'database',
+ pkb: 'database',
+ accdb: 'database',
+ mdb: 'database',
+ sqlite: 'database',
+ cs: 'csharp',
+ zip: 'zip',
+ tar: 'zip',
+ gz: 'zip',
+ xz: 'zip',
+ bzip2: 'zip',
+ gzip: 'zip',
+ '7z': 'zip',
+ rar: 'zip',
+ tgz: 'zip',
+ exe: 'exe',
+ msi: 'exe',
+ java: 'java',
+ jar: 'java',
+ jsp: 'java',
+ c: 'c',
+ m: 'c',
+ h: 'h',
+ cc: 'cpp',
+ cpp: 'cpp',
+ mm: 'cpp',
+ cxx: 'cpp',
+ hpp: 'hpp',
+ go: 'go',
+ py: 'python',
+ url: 'url',
+ sh: 'console',
+ ksh: 'console',
+ csh: 'console',
+ tcsh: 'console',
+ zsh: 'console',
+ bash: 'console',
+ bat: 'console',
+ cmd: 'console',
+ ps1: 'powershell',
+ psm1: 'powershell',
+ psd1: 'powershell',
+ ps1xml: 'powershell',
+ psc1: 'powershell',
+ pssc: 'powershell',
+ gradle: 'gradle',
+ doc: 'word',
+ docx: 'word',
+ rtf: 'word',
+ cer: 'certificate',
+ cert: 'certificate',
+ crt: 'certificate',
+ pub: 'key',
+ key: 'key',
+ pem: 'key',
+ asc: 'key',
+ gpg: 'key',
+ woff: 'font',
+ woff2: 'font',
+ ttf: 'font',
+ eot: 'font',
+ suit: 'font',
+ otf: 'font',
+ bmap: 'font',
+ fnt: 'font',
+ odttf: 'font',
+ ttc: 'font',
+ font: 'font',
+ fonts: 'font',
+ sui: 'font',
+ ntf: 'font',
+ mrf: 'font',
+ lib: 'lib',
+ bib: 'lib',
+ rb: 'ruby',
+ erb: 'ruby',
+ fs: 'fsharp',
+ fsx: 'fsharp',
+ fsi: 'fsharp',
+ fsproj: 'fsharp',
+ swift: 'swift',
+ ino: 'arduino',
+ dockerignore: 'docker',
+ dockerfile: 'docker',
+ tex: 'tex',
+ cls: 'tex',
+ sty: 'tex',
+ pptx: 'powerpoint',
+ ppt: 'powerpoint',
+ pptm: 'powerpoint',
+ potx: 'powerpoint',
+ pot: 'powerpoint',
+ potm: 'powerpoint',
+ ppsx: 'powerpoint',
+ ppsm: 'powerpoint',
+ pps: 'powerpoint',
+ ppam: 'powerpoint',
+ ppa: 'powerpoint',
+ webm: 'movie',
+ mkv: 'movie',
+ flv: 'movie',
+ vob: 'movie',
+ ogv: 'movie',
+ ogg: 'movie',
+ gifv: 'movie',
+ avi: 'movie',
+ mov: 'movie',
+ qt: 'movie',
+ wmv: 'movie',
+ yuv: 'movie',
+ rm: 'movie',
+ rmvb: 'movie',
+ mp4: 'movie',
+ m4v: 'movie',
+ mpg: 'movie',
+ mp2: 'movie',
+ mpeg: 'movie',
+ mpe: 'movie',
+ mpv: 'movie',
+ m2v: 'movie',
+ vdi: 'virtual',
+ vbox: 'virtual',
+ 'vbox-prev': 'virtual',
+ ics: 'email',
+ mp3: 'music',
+ flac: 'music',
+ m4a: 'music',
+ wma: 'music',
+ aiff: 'music',
+ coffee: 'coffee',
+ txt: 'document',
+ graphql: 'graphql',
+ rs: 'rust',
+ raml: 'raml',
+ xaml: 'xaml',
+ hs: 'haskell',
+ kt: 'kotlin',
+ kts: 'kotlin',
+ patch: 'git',
+ lua: 'lua',
+ clj: 'clojure',
+ cljs: 'clojure',
+ groovy: 'groovy',
+ r: 'r',
+ rmd: 'r',
+ dart: 'dart',
+ as: 'actionscript',
+ mxml: 'mxml',
+ ahk: 'autohotkey',
+ swf: 'flash',
+ swc: 'swc',
+ cmake: 'cmake',
+ asm: 'assembly',
+ a51: 'assembly',
+ inc: 'assembly',
+ nasm: 'assembly',
+ s: 'assembly',
+ ms: 'assembly',
+ agc: 'assembly',
+ ags: 'assembly',
+ aea: 'assembly',
+ argus: 'assembly',
+ mitigus: 'assembly',
+ binsource: 'assembly',
+ vue: 'vue',
+ ml: 'ocaml',
+ mli: 'ocaml',
+ cmx: 'ocaml',
+ 'js.map': 'javascript-map',
+ 'css.map': 'css-map',
+ lock: 'lock',
+ hbs: 'handlebars',
+ mustache: 'handlebars',
+ pl: 'perl',
+ pm: 'perl',
+ hx: 'haxe',
+ 'spec.ts': 'test-ts',
+ 'test.ts': 'test-ts',
+ 'ts.snap': 'test-ts',
+ 'spec.tsx': 'test-jsx',
+ 'test.tsx': 'test-jsx',
+ 'tsx.snap': 'test-jsx',
+ 'spec.jsx': 'test-jsx',
+ 'test.jsx': 'test-jsx',
+ 'jsx.snap': 'test-jsx',
+ 'spec.js': 'test-js',
+ 'test.js': 'test-js',
+ 'js.snap': 'test-js',
+ 'routing.ts': 'angular-routing',
+ 'routing.js': 'angular-routing',
+ 'module.ts': 'angular',
+ 'module.js': 'angular',
+ 'ng-template': 'angular',
+ 'component.ts': 'angular-component',
+ 'component.js': 'angular-component',
+ 'guard.ts': 'angular-guard',
+ 'guard.js': 'angular-guard',
+ 'service.ts': 'angular-service',
+ 'service.js': 'angular-service',
+ 'pipe.ts': 'angular-pipe',
+ 'pipe.js': 'angular-pipe',
+ 'filter.js': 'angular-pipe',
+ 'directive.ts': 'angular-directive',
+ 'directive.js': 'angular-directive',
+ 'resolver.ts': 'angular-resolver',
+ 'resolver.js': 'angular-resolver',
+ pp: 'puppet',
+ ex: 'elixir',
+ exs: 'elixir',
+ ls: 'livescript',
+ erl: 'erlang',
+ twig: 'twig',
+ jl: 'julia',
+ elm: 'elm',
+ pure: 'purescript',
+ tpl: 'smarty',
+ styl: 'stylus',
+ re: 'reason',
+ rei: 'reason',
+ cmj: 'bucklescript',
+ merlin: 'merlin',
+ v: 'verilog',
+ vhd: 'verilog',
+ sv: 'verilog',
+ svh: 'verilog',
+ nb: 'mathematica',
+ wl: 'wolframlanguage',
+ wls: 'wolframlanguage',
+ njk: 'nunjucks',
+ nunjucks: 'nunjucks',
+ robot: 'robot',
+ sol: 'solidity',
+ au3: 'autoit',
+ haml: 'haml',
+ yang: 'yang',
+ tf: 'terraform',
+ 'tf.json': 'terraform',
+ tfvars: 'terraform',
+ tfstate: 'terraform',
+ 'blade.php': 'laravel',
+ 'inky.php': 'laravel',
+ applescript: 'applescript',
+ cake: 'cake',
+ feature: 'cucumber',
+ nim: 'nim',
+ nimble: 'nim',
+ apib: 'apiblueprint',
+ apiblueprint: 'apiblueprint',
+ tag: 'riot',
+ vfl: 'vfl',
+ kl: 'kl',
+ pcss: 'postcss',
+ sss: 'postcss',
+ todo: 'todo',
+ cfml: 'coldfusion',
+ cfc: 'coldfusion',
+ lucee: 'coldfusion',
+ cabal: 'cabal',
+ nix: 'nix',
+ slim: 'slim',
+ http: 'http',
+ rest: 'http',
+ rql: 'restql',
+ restql: 'restql',
+ kv: 'kivy',
+ graphcool: 'graphcool',
+ sbt: 'sbt',
+ 'reducer.ts': 'ngrx-reducer',
+ 'rootReducer.ts': 'ngrx-reducer',
+ 'state.ts': 'ngrx-state',
+ 'actions.ts': 'ngrx-actions',
+ 'effects.ts': 'ngrx-effects',
+ cr: 'crystal',
+ 'drone.yml': 'drone',
+ cu: 'cuda',
+ cuh: 'cuda',
+ log: 'log',
+};
+
+const fileNameIcons = {
+ '.jscsrc': 'json',
+ '.jshintrc': 'json',
+ 'tsconfig.json': 'json',
+ 'tslint.json': 'json',
+ 'composer.lock': 'json',
+ '.jsbeautifyrc': 'json',
+ '.esformatter': 'json',
+ 'cdp.pid': 'json',
+ '.htaccess': 'xml',
+ '.jshintignore': 'settings',
+ '.buildignore': 'settings',
+ makefile: 'settings',
+ '.mrconfig': 'settings',
+ '.yardopts': 'settings',
+ 'gradle.properties': 'gradle',
+ gradlew: 'gradle',
+ 'gradle-wrapper.properties': 'gradle',
+ license: 'certificate',
+ 'license.md': 'certificate',
+ 'license.md.rendered': 'certificate',
+ 'license.txt': 'certificate',
+ licence: 'certificate',
+ 'licence.md': 'certificate',
+ 'licence.md.rendered': 'certificate',
+ 'licence.txt': 'certificate',
+ dockerfile: 'docker',
+ 'docker-compose.yml': 'docker',
+ '.mailmap': 'email',
+ '.gitignore': 'git',
+ '.gitconfig': 'git',
+ '.gitattributes': 'git',
+ '.gitmodules': 'git',
+ '.gitkeep': 'git',
+ 'git-history': 'git',
+ '.Rhistory': 'r',
+ 'cmakelists.txt': 'cmake',
+ 'cmakecache.txt': 'cmake',
+ 'angular-cli.json': 'angular',
+ '.angular-cli.json': 'angular',
+ '.vfl': 'vfl',
+ '.kl': 'kl',
+ 'postcss.config.js': 'postcss',
+ '.postcssrc.js': 'postcss',
+ 'project.graphcool': 'graphcool',
+ 'webpack.js': 'webpack',
+ 'webpack.ts': 'webpack',
+ 'webpack.base.js': 'webpack',
+ 'webpack.base.ts': 'webpack',
+ 'webpack.config.js': 'webpack',
+ 'webpack.config.ts': 'webpack',
+ 'webpack.common.js': 'webpack',
+ 'webpack.common.ts': 'webpack',
+ 'webpack.config.common.js': 'webpack',
+ 'webpack.config.common.ts': 'webpack',
+ 'webpack.config.common.babel.js': 'webpack',
+ 'webpack.config.common.babel.ts': 'webpack',
+ 'webpack.dev.js': 'webpack',
+ 'webpack.dev.ts': 'webpack',
+ 'webpack.config.dev.js': 'webpack',
+ 'webpack.config.dev.ts': 'webpack',
+ 'webpack.config.dev.babel.js': 'webpack',
+ 'webpack.config.dev.babel.ts': 'webpack',
+ 'webpack.prod.js': 'webpack',
+ 'webpack.prod.ts': 'webpack',
+ 'webpack.server.js': 'webpack',
+ 'webpack.server.ts': 'webpack',
+ 'webpack.client.js': 'webpack',
+ 'webpack.client.ts': 'webpack',
+ 'webpack.config.server.js': 'webpack',
+ 'webpack.config.server.ts': 'webpack',
+ 'webpack.config.client.js': 'webpack',
+ 'webpack.config.client.ts': 'webpack',
+ 'webpack.config.production.babel.js': 'webpack',
+ 'webpack.config.production.babel.ts': 'webpack',
+ 'webpack.config.prod.babel.js': 'webpack',
+ 'webpack.config.prod.babel.ts': 'webpack',
+ 'webpack.config.prod.js': 'webpack',
+ 'webpack.config.prod.ts': 'webpack',
+ 'webpack.config.production.js': 'webpack',
+ 'webpack.config.production.ts': 'webpack',
+ 'webpack.config.staging.js': 'webpack',
+ 'webpack.config.staging.ts': 'webpack',
+ 'webpack.config.babel.js': 'webpack',
+ 'webpack.config.babel.ts': 'webpack',
+ 'webpack.config.base.babel.js': 'webpack',
+ 'webpack.config.base.babel.ts': 'webpack',
+ 'webpack.config.base.js': 'webpack',
+ 'webpack.config.base.ts': 'webpack',
+ 'webpack.config.staging.babel.js': 'webpack',
+ 'webpack.config.staging.babel.ts': 'webpack',
+ 'webpack.config.coffee': 'webpack',
+ 'webpack.config.test.js': 'webpack',
+ 'webpack.config.test.ts': 'webpack',
+ 'webpack.config.vendor.js': 'webpack',
+ 'webpack.config.vendor.ts': 'webpack',
+ 'webpack.config.vendor.production.js': 'webpack',
+ 'webpack.config.vendor.production.ts': 'webpack',
+ 'webpack.test.js': 'webpack',
+ 'webpack.test.ts': 'webpack',
+ 'webpack.dist.js': 'webpack',
+ 'webpack.dist.ts': 'webpack',
+ 'webpackfile.js': 'webpack',
+ 'webpackfile.ts': 'webpack',
+ 'ionic.config.json': 'ionic',
+ '.io-config.json': 'ionic',
+ 'gulpfile.js': 'gulp',
+ 'gulpfile.ts': 'gulp',
+ 'gulpfile.babel.js': 'gulp',
+ 'package.json': 'nodejs',
+ 'package-lock.json': 'nodejs',
+ '.nvmrc': 'nodejs',
+ '.npmignore': 'npm',
+ '.npmrc': 'npm',
+ '.yarnrc': 'yarn',
+ 'yarn.lock': 'yarn',
+ '.yarnclean': 'yarn',
+ '.yarn-integrity': 'yarn',
+ 'yarn-error.log': 'yarn',
+ 'androidmanifest.xml': 'android',
+ '.env': 'tune',
+ '.env.example': 'tune',
+ '.babelrc': 'babel',
+ 'contributing.md': 'contributing',
+ 'contributing.md.rendered': 'contributing',
+ 'readme.md': 'readme',
+ 'readme.md.rendered': 'readme',
+ changelog: 'changelog',
+ 'changelog.md': 'changelog',
+ 'changelog.md.rendered': 'changelog',
+ CREDITS: 'credits',
+ 'credits.txt': 'credits',
+ 'credits.md': 'credits',
+ 'credits.md.rendered': 'credits',
+ '.flowconfig': 'flow',
+ 'favicon.ico': 'favicon',
+ 'karma.conf.js': 'karma',
+ 'karma.conf.ts': 'karma',
+ 'karma.conf.coffee': 'karma',
+ 'karma.config.js': 'karma',
+ 'karma.config.ts': 'karma',
+ 'karma-main.js': 'karma',
+ 'karma-main.ts': 'karma',
+ '.bithoundrc': 'bithound',
+ 'appveyor.yml': 'appveyor',
+ '.travis.yml': 'travis',
+ 'protractor.conf.js': 'protractor',
+ 'protractor.conf.ts': 'protractor',
+ 'protractor.conf.coffee': 'protractor',
+ 'protractor.config.js': 'protractor',
+ 'protractor.config.ts': 'protractor',
+ 'fuse.js': 'fusebox',
+ procfile: 'heroku',
+ '.editorconfig': 'editorconfig',
+ '.gitlab-ci.yml': 'gitlab',
+ '.bowerrc': 'bower',
+ 'bower.json': 'bower',
+ '.eslintrc.js': 'eslint',
+ '.eslintrc.yaml': 'eslint',
+ '.eslintrc.yml': 'eslint',
+ '.eslintrc.json': 'eslint',
+ '.eslintrc': 'eslint',
+ '.eslintignore': 'eslint',
+ 'code_of_conduct.md': 'conduct',
+ 'code_of_conduct.md.rendered': 'conduct',
+ '.watchmanconfig': 'watchman',
+ 'aurelia.json': 'aurelia',
+ 'mocha.opts': 'mocha',
+ jenkinsfile: 'jenkins',
+ 'firebase.json': 'firebase',
+ '.firebaserc': 'firebase',
+ 'rollup.config.js': 'rollup',
+ 'rollup.config.ts': 'rollup',
+ 'rollup-config.js': 'rollup',
+ 'rollup-config.ts': 'rollup',
+ 'rollup.config.prod.js': 'rollup',
+ 'rollup.config.prod.ts': 'rollup',
+ 'rollup.config.dev.js': 'rollup',
+ 'rollup.config.dev.ts': 'rollup',
+ 'rollup.config.prod.vendor.js': 'rollup',
+ 'rollup.config.prod.vendor.ts': 'rollup',
+ '.hhconfig': 'hack',
+ '.stylelintrc': 'stylelint',
+ 'stylelint.config.js': 'stylelint',
+ '.stylelintrc.json': 'stylelint',
+ '.stylelintrc.yaml': 'stylelint',
+ '.stylelintrc.yml': 'stylelint',
+ '.stylelintrc.js': 'stylelint',
+ '.stylelintignore': 'stylelint',
+ '.codeclimate.yml': 'code-climate',
+ '.prettierrc': 'prettier',
+ 'prettier.config.js': 'prettier',
+ '.prettierrc.js': 'prettier',
+ '.prettierrc.json': 'prettier',
+ '.prettierrc.yaml': 'prettier',
+ '.prettierrc.yml': 'prettier',
+ 'nodemon.json': 'nodemon',
+ '.sonarrc': 'sonar',
+ browserslist: 'browserlist',
+ '.browserslistrc': 'browserlist',
+ '.snyk': 'snyk',
+ '.drone.yml': 'drone',
+};
+
+export default function getIconForFile(name) {
+ return fileNameIcons[name] ||
+ fileExtensionIcons[name ? name.split('.').pop() : ''] ||
+ '';
+}
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
new file mode 100644
index 00000000000..67c9181c7b1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue
@@ -0,0 +1,106 @@
+<script>
+ const buttonVariants = [
+ 'danger',
+ 'primary',
+ 'success',
+ 'warning',
+ ];
+
+ export default {
+ name: 'GlModal',
+
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ headerTitleText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ footerPrimaryButtonVariant: {
+ type: String,
+ required: false,
+ default: 'primary',
+ validator: value => buttonVariants.indexOf(value) !== -1,
+ },
+ footerPrimaryButtonText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ :id="id"
+ class="modal fade"
+ tabindex="-1"
+ role="dialog"
+ >
+ <div
+ class="modal-dialog"
+ role="document"
+ >
+ <div class="modal-content">
+ <div class="modal-header">
+ <slot name="header">
+ <button
+ type="button"
+ class="close"
+ data-dismiss="modal"
+ :aria-label="s__('Modal|Close')"
+ @click="emitCancel($event)"
+ >
+ <span aria-hidden="true">&times;</span>
+ </button>
+ <h4 class="modal-title">
+ <slot name="title">
+ {{ headerTitleText }}
+ </slot>
+ </h4>
+ </slot>
+ </div>
+
+ <div class="modal-body">
+ <slot></slot>
+ </div>
+
+ <div class="modal-footer">
+ <slot name="footer">
+ <button
+ type="button"
+ class="btn"
+ data-dismiss="modal"
+ @click="emitCancel($event)"
+ >
+ {{ s__('Modal|Cancel') }}
+ </button>
+ <button
+ type="button"
+ class="btn"
+ :class="`btn-${footerPrimaryButtonVariant}`"
+ data-dismiss="modal"
+ @click="emitSubmit($event)"
+ >
+ {{ footerPrimaryButtonText }}
+ </button>
+ </slot>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index bdc059f4a03..a0cd0cbd200 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,75 +1,78 @@
<script>
-import ciIconBadge from './ci_badge_link.vue';
-import loadingIcon from './loading_icon.vue';
-import timeagoTooltip from './time_ago_tooltip.vue';
-import tooltip from '../directives/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,
+ import ciIconBadge from './ci_badge_link.vue';
+ import loadingIcon from './loading_icon.vue';
+ import timeagoTooltip from './time_ago_tooltip.vue';
+ import tooltip from '../directives/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 {
+ components: {
+ ciIconBadge,
+ loadingIcon,
+ timeagoTooltip,
+ userAvatarImage,
},
- time: {
- type: String,
- required: true,
+ directives: {
+ tooltip,
},
- user: {
- type: Object,
- required: false,
- 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: () => [],
+ },
+ hasSidebarButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ shouldRenderTriggeredLabel: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
- actions: {
- type: Array,
- required: false,
- default: () => [],
- },
- hasSidebarButton: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- directives: {
- tooltip,
- },
- components: {
- ciIconBadge,
- loadingIcon,
- timeagoTooltip,
- userAvatarImage,
- },
-
- computed: {
- userAvatarAltText() {
- return `${this.user.name}'s avatar`;
+ computed: {
+ userAvatarAltText() {
+ return `${this.user.name}'s avatar`;
+ },
},
- },
- methods: {
- onClickAction(action) {
- this.$emit('actionClicked', action);
+ methods: {
+ onClickAction(action) {
+ this.$emit('actionClicked', action);
+ },
},
- },
-};
+ };
</script>
<template>
@@ -79,10 +82,15 @@ export default {
<ci-icon-badge :status="status" />
<strong>
- {{itemName}} #{{itemId}}
+ {{ itemName }} #{{ itemId }}
</strong>
- triggered
+ <template v-if="shouldRenderTriggeredLabel">
+ triggered
+ </template>
+ <template v-else>
+ created
+ </template>
<timeago-tooltip :time="time" />
@@ -93,65 +101,78 @@ export default {
v-tooltip
:href="user.path"
:title="user.email"
- class="js-user-link commit-committer-link">
+ class="js-user-link commit-committer-link"
+ >
<user-avatar-image
:img-src="user.avatar_url"
:img-alt="userAvatarAltText"
:tooltip-text="user.name"
:img-size="24"
- />
+ />
- {{user.name}}
+ {{ user.name }}
</a>
</template>
</section>
<section
class="header-action-buttons"
- v-if="actions.length">
+ v-if="actions.length"
+ >
<template
- v-for="action in actions">
+ v-for="(action, i) in actions"
+ >
<a
v-if="action.type === 'link'"
:href="action.path"
- :class="action.cssClass">
- {{action.label}}
+ :class="action.cssClass"
+ :key="i"
+ >
+ {{ action.label }}
</a>
<a
- v-if="action.type === 'ujs-link'"
+ v-else-if="action.type === 'ujs-link'"
:href="action.path"
data-method="post"
rel="nofollow"
- :class="action.cssClass">
- {{action.label}}
+ :class="action.cssClass"
+ :key="i"
+ >
+ {{ action.label }}
</a>
<button
- v-else="action.type === 'button'"
+ v-else-if="action.type === 'button'"
@click="onClickAction(action)"
:disabled="action.isLoading"
:class="action.cssClass"
- type="button">
- {{action.label}}
+ type="button"
+ :key="i"
+ >
+ {{ action.label }}
<i
v-show="action.isLoading"
class="fa fa-spin fa-spinner"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
</template>
<button
v-if="hasSidebarButton"
type="button"
- class="btn btn-default visible-xs-block visible-sm-block sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
+ class="btn btn-default visible-xs-block
+visible-sm-block sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
aria-label="Toggle Sidebar"
- id="toggleSidebar">
+ id="toggleSidebar"
+ >
<i
class="fa fa-angle-double-left"
aria-hidden="true"
- aria-labelledby="toggleSidebar">
+ aria-labelledby="toggleSidebar"
+ >
</i>
</button>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
new file mode 100644
index 00000000000..6a2e05000e1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -0,0 +1,85 @@
+<script>
+
+ /* This is a re-usable vue component for rendering a svg sprite
+ icon
+
+ Sample configuration:
+
+ <icon
+ name="retry"
+ :size="32"
+ css-classes="top"
+ />
+
+ */
+ // only allow classes in images.scss e.g. s12
+ const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
+
+ export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ validator(value) {
+ return validSizes.includes(value);
+ },
+ },
+
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ width: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+
+ height: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+
+ y: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+
+ x: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+
+ computed: {
+ spriteHref() {
+ return `${gon.sprite_icons}#${this.name}`;
+ },
+ iconSizeClass() {
+ return this.size ? `s${this.size}` : '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <svg
+ :class="[iconSizeClass, cssClasses]"
+ :width="width"
+ :height="height"
+ :x="x"
+ :y="y">
+ <use v-bind="{ 'xlink:href':spriteHref }" />
+ </svg>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
new file mode 100644
index 00000000000..0a30f467b08
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -0,0 +1,51 @@
+<script>
+export default {
+ props: {
+ entityId: {
+ type: Number,
+ required: true,
+ },
+ entityName: {
+ type: String,
+ required: true,
+ },
+ sizeClass: {
+ type: String,
+ required: false,
+ default: 's40',
+ },
+ },
+ computed: {
+ /**
+ * This method is based on app/helpers/application_helper.rb#project_identicon
+ */
+ identiconStyles() {
+ const allowedColors = [
+ '#FFEBEE',
+ '#F3E5F5',
+ '#E8EAF6',
+ '#E3F2FD',
+ '#E0F2F1',
+ '#FBE9E7',
+ '#EEEEEE',
+ ];
+
+ const backgroundColor = allowedColors[this.entityId % 7];
+
+ return `background-color: ${backgroundColor}; color: #555;`;
+ },
+ identiconTitle() {
+ return this.entityName.charAt(0).toUpperCase();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="avatar identicon"
+ :class="sizeClass"
+ :style="identiconStyles">
+ {{ identiconTitle }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
new file mode 100644
index 00000000000..3d39b3ab173
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -0,0 +1,59 @@
+<script>
+ import icon from '../../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ isLocked: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ isConfidential: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ warningIcon() {
+ if (this.isConfidential) return 'eye-slash';
+ if (this.isLocked) return 'lock';
+
+ return '';
+ },
+ isLockedAndConfidential() {
+ return this.isConfidential && this.isLocked;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="issuable-note-warning">
+ <icon
+ :name="warningIcon"
+ :size="16"
+ class="icon inline"
+ aria-hidden="true"
+ v-if="!isLockedAndConfidential"
+ />
+
+ <span v-if="isLockedAndConfidential">
+ {{ __('This issue is confidential and locked.') }}
+ {{ __(`People without permission will never
+get a notification and won't be able to comment.`) }}
+ </span>
+
+ <span v-else-if="isConfidential">
+ {{ __('This is a confidential issue.') }}
+ {{ __('Your comment will not be visible to the public.') }}
+ </span>
+
+ <span v-else-if="isLocked">
+ {{ __('This issue is locked.') }}
+ {{ __('Only project members can comment.') }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
new file mode 100644
index 00000000000..e832d94d32f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/loading_button.vue
@@ -0,0 +1,81 @@
+<script>
+ /* eslint-disable vue/require-default-prop */
+ /* This is a re-usable vue component for rendering a button
+ that will probably be sending off ajax requests and need
+ to show the loading status by setting the `loading` option.
+ This can also be used for initial page load when you don't
+ know the action of the button yet by setting
+ `loading: true, label: undefined`.
+
+ Sample configuration:
+
+ <loading-button
+ :loading="true"
+ :label="Hello"
+ @click="..."
+ />
+
+ */
+
+ import loadingIcon from './loading_icon.vue';
+
+ export default {
+ components: {
+ loadingIcon,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ },
+ containerClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: 'btn btn-align-content',
+ },
+ },
+ methods: {
+ onClick(e) {
+ this.$emit('click', e);
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ @click="onClick"
+ type="button"
+ :class="containerClass"
+ :disabled="loading || disabled"
+ >
+ <transition name="fade">
+ <loading-icon
+ v-if="loading"
+ :inline="true"
+ class="js-loading-button-icon"
+ :class="{
+ 'append-right-5': label
+ }"
+ />
+ </transition>
+ <transition name="fade">
+ <span
+ v-if="label"
+ class="js-loading-button-label"
+ >
+ {{ label }}
+ </span>
+ </transition>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue
index 41b1d0165b0..12a75e016d7 100644
--- a/app/assets/javascripts/vue_shared/components/loading_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue
@@ -12,9 +12,18 @@
required: false,
default: '1',
},
+
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
+ rootElementType() {
+ return this.inline ? 'span' : 'div';
+ },
cssClass() {
return `fa-${this.size}x`;
},
@@ -22,12 +31,15 @@
};
</script>
<template>
- <div class="text-center">
+ <component
+ :is="rootElementType"
+ class="loading-container text-center">
<i
class="fa fa-spin fa-spinner"
:class="cssClass"
aria-hidden="true"
- :aria-label="label">
+ :aria-label="label"
+ >
</i>
- </div>
+ </component>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index e6977681e96..12c7d125062 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,107 +1,191 @@
<script>
- /* global Flash */
+ import $ from 'jquery';
+ import Flash from '../../../flash';
+ import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
+ import icon from '../icon.vue';
export default {
+ components: {
+ markdownHeader,
+ markdownToolbar,
+ icon,
+ },
props: {
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: false,
default: '',
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ addSpacingClasses: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
markdownPreview: '',
+ referencedCommands: '',
+ referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
},
- components: {
- markdownHeader,
- markdownToolbar,
+ computed: {
+ shouldShowReferencedUsers() {
+ const referencedUsersThreshold = 10;
+ return this.referencedUsers.length >= referencedUsersThreshold;
+ },
+ },
+ mounted() {
+ /*
+ GLForm class handles all the toolbar buttons
+ */
+ return new GLForm($(this.$refs['gl-form']), this.enableAutocomplete);
+ },
+ beforeDestroy() {
+ const glForm = $(this.$refs['gl-form']).data('glForm');
+ if (glForm) {
+ glForm.destroy();
+ }
},
methods: {
- toggleMarkdownPreview() {
- this.previewMarkdown = !this.previewMarkdown;
+ showPreviewTab() {
+ if (this.previewMarkdown) return;
- if (!this.previewMarkdown) {
- this.markdownPreview = '';
- } else {
+ this.previewMarkdown = true;
+
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ const text = this.$slots.textarea[0].elm.value;
+
+ if (text) {
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.$http.post(this.markdownPreviewPath, { text })
+ .then(resp => resp.json())
+ .then(data => this.renderMarkdown(data))
+ .catch(() => new Flash('Error loading markdown preview'));
+ } else {
+ this.renderMarkdown();
+ }
+ },
+
+ showWriteTab() {
+ this.markdownPreview = '';
+ this.previewMarkdown = false;
+ },
- this.markdownPreviewLoading = false;
- this.markdownPreview = data.body;
+ renderMarkdown(data = {}) {
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body || 'Nothing to preview.';
- this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
- });
- })
- .catch(() => new Flash('Error loading markdown preview'));
+ if (data.references) {
+ this.referencedCommands = data.references.commands;
+ this.referencedUsers = data.references.users;
}
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
},
},
- 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"
+ class="md-area js-vue-markdown-field"
+ :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
ref="gl-form">
<markdown-header
:preview-markdown="previewMarkdown"
- @toggle-markdown="toggleMarkdownPreview" />
+ @preview-markdown="showPreviewTab"
+ @write-markdown="showWriteTab"
+ />
<div
class="md-write-holder"
- v-show="!previewMarkdown">
+ 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>
+ aria-label="Enter zen mode"
+ >
+ <icon
+ name="screen-normal"
+ :size="32"
+ />
</a>
<markdown-toolbar
- :markdown-docs="markdownDocs" />
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :can-attach-file="canAttachFile"
+ />
</div>
</div>
<div
class="md md-preview-holder md-preview"
- v-show="previewMarkdown">
+ v-show="previewMarkdown"
+ >
<div
ref="markdown-preview"
- v-html="markdownPreview">
+ v-html="markdownPreview"
+ >
</div>
<span v-if="markdownPreviewLoading">
Loading...
</span>
</div>
+ <template v-if="previewMarkdown && !markdownPreviewLoading">
+ <div
+ v-if="referencedCommands"
+ v-html="referencedCommands"
+ class="referenced-commands"
+ >
+ </div>
+ <div
+ v-if="shouldShowReferencedUsers"
+ class="referenced-users"
+ >
+ <span>
+ <i
+ class="fa fa-exclamation-triangle"
+ aria-hidden="true"
+ >
+ </i>
+ You are about to add
+ <strong>
+ <span class="js-referenced-users-count">
+ {{ referencedUsers.length }}
+ </span>
+ </strong> people to the discussion. Proceed with caution.
+ </span>
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 5bf2a90cc3b..d91fe3cf0c5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,38 +1,49 @@
<script>
+ import $ from 'jquery';
import tooltip from '../../directives/tooltip';
import toolbarButton from './toolbar_button.vue';
+ import icon from '../icon.vue';
export default {
- props: {
- previewMarkdown: {
- type: Boolean,
- required: true,
- },
- },
directives: {
tooltip,
},
components: {
toolbarButton,
+ icon,
},
- 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');
+ props: {
+ previewMarkdown: {
+ type: Boolean,
+ required: true,
},
},
mounted() {
- $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
- $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ $(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
+ $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab);
},
beforeDestroy() {
- $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
- $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ $(document).off('markdown-preview:show.vue', this.previewMarkdownTab);
+ $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
+ },
+ methods: {
+ isMarkdownForm(form) {
+ return form && !form.find('.js-vue-markdown-field').length;
+ },
+
+ previewMarkdownTab(event, form) {
+ if (event.target.blur) event.target.blur();
+ if (this.isMarkdownForm(form)) return;
+
+ this.$emit('preview-markdown');
+ },
+
+ writeMarkdownTab(event, form) {
+ if (event.target.blur) event.target.blur();
+ if (this.isMarkdownForm(form)) return;
+
+ this.$emit('write-markdown');
+ },
},
};
</script>
@@ -40,73 +51,89 @@
<template>
<div class="md-header">
<ul class="nav-links clearfix">
- <li :class="{ active: !previewMarkdown }">
+ <li
+ class="md-header-tab"
+ :class="{ active: !previewMarkdown }"
+ >
<a
+ class="js-write-link"
href="#md-write-holder"
tabindex="-1"
- @click.prevent="toggleMarkdownPreview($event)">
+ @click.prevent="writeMarkdownTab($event)"
+ >
Write
</a>
</li>
- <li :class="{ active: previewMarkdown }">
+ <li
+ class="md-header-tab"
+ :class="{ active: previewMarkdown }"
+ >
<a
+ class="js-preview-link"
href="#md-preview-holder"
tabindex="-1"
- @click.prevent="toggleMarkdownPreview($event)">
+ @click.prevent="previewMarkdownTab($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
- v-tooltip
- aria-label="Go full screen"
- class="toolbar-btn js-zen-enter"
- data-container="body"
- tabindex="-1"
- title="Go full screen"
- type="button">
- <i
- aria-hidden="true"
- class="fa fa-arrows-alt fa-fw">
- </i>
- </button>
- </div>
+ <li
+ class="md-header-toolbar"
+ :class="{ active: !previewMarkdown }"
+ >
+ <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"
+ />
+ <toolbar-button
+ tag="`"
+ tag-block="```"
+ button-title="Insert code"
+ icon="code"
+ />
+ <toolbar-button
+ tag="* "
+ :prepend="true"
+ button-title="Add a bullet list"
+ icon="list-bulleted"
+ />
+ <toolbar-button
+ tag="1. "
+ :prepend="true"
+ button-title="Add a numbered list"
+ icon="list-numbered"
+ />
+ <toolbar-button
+ tag="* [ ] "
+ :prepend="true"
+ button-title="Add a task list"
+ icon="task-done"
+ />
+ <button
+ v-tooltip
+ aria-label="Go full screen"
+ class="toolbar-btn toolbar-fullscreen-btn js-zen-enter"
+ data-container="body"
+ tabindex="-1"
+ title="Go full screen"
+ type="button"
+ >
+ <icon
+ name="screen-full"
+ />
+ </button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 93252293ba6..c0ee88bbf72 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,10 +1,25 @@
<script>
export default {
props: {
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ hasQuickActionsDocsPath() {
+ return this.quickActionsDocsPath !== '';
+ },
},
};
</script>
@@ -12,22 +27,95 @@
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
- <a
- :href="markdownDocs"
- target="_blank"
- tabindex="-1">
- Markdown is supported
- </a>
+ <template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
+ <a
+ :href="markdownDocsPath"
+ target="_blank"
+ tabindex="-1"
+ >
+ Markdown is supported
+ </a>
+ </template>
+ <template v-if="hasQuickActionsDocsPath && markdownDocsPath">
+ <a
+ :href="markdownDocsPath"
+ target="_blank"
+ tabindex="-1"
+ >
+ Markdown
+ </a>
+ and
+ <a
+ :href="quickActionsDocsPath"
+ target="_blank"
+ tabindex="-1"
+ >
+ quick actions
+ </a>
+ are supported
+ </template>
</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>
+ <span
+ v-if="canAttachFile"
+ class="uploading-container"
+ >
+ <span class="uploading-progress-container hide">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"
+ >
+ </i>
+ <span class="attaching-file-message"></span>
+ <span class="uploading-progress">0%</span>
+ <span class="uploading-spinner">
+ <i
+ class="fa fa-spinner fa-spin toolbar-button-icon"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </span>
+ <span class="uploading-error-container hide">
+ <span class="uploading-error-icon">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ <span class="uploading-error-message"></span>
+ <button
+ class="retry-uploading-link"
+ type="button"
+ >
+ Try again
+ </button>
+ or
+ <button
+ class="attach-new-file markdown-selector"
+ type="button"
+ >
+ attach a new file
+ </button>
+ </span>
+ <button
+ class="markdown-selector button-attach-file"
+ tabindex="-1"
+ type="button"
+ >
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"
+ >
+ </i>
+ Attach a file
+ </button>
+ <button
+ class="btn btn-default btn-xs hide button-cancel-uploading-files"
+ type="button"
+ >
+ Cancel
+ </button>
+ </span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index f7da7ebfcfe..2d2d69ebeb2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -1,7 +1,14 @@
<script>
import tooltip from '../../directives/tooltip';
+ import icon from '../icon.vue';
export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
props: {
buttonTitle: {
type: String,
@@ -26,14 +33,6 @@
default: false,
},
},
- directives: {
- tooltip,
- },
- computed: {
- iconClass() {
- return `fa-${this.icon}`;
- },
- },
};
</script>
@@ -41,18 +40,17 @@
<button
v-tooltip
type="button"
- class="toolbar-btn js-md hidden-xs"
+ class="toolbar-btn js-md"
tabindex="-1"
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>
+ :aria-label="buttonTitle"
+ >
+ <icon
+ :name="icon"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js
deleted file mode 100644
index 643b77e04c7..00000000000
--- a/app/assets/javascripts/vue_shared/components/memory_graph.js
+++ /dev/null
@@ -1,115 +0,0 @@
-export default {
- name: 'MemoryGraph',
- props: {
- metrics: { type: Array, required: true },
- deploymentTime: { type: Number, required: true },
- width: { type: String, required: true },
- height: { type: String, required: true },
- },
- data() {
- return {
- pathD: '',
- pathViewBox: '',
- dotX: '',
- dotY: '',
- };
- },
- computed: {
- getFormattedMedian() {
- const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000);
- return `Deployed ${deployedSince}`;
- },
- },
- methods: {
- /**
- * Returns metric value index in metrics array
- * with timestamp closest to matching median
- */
- getMedianMetricIndex(median, metrics) {
- let matchIndex = 0;
- let timestampDiff = 0;
- let smallestDiff = 0;
-
- const metricTimestamps = metrics.map(v => v[0]);
-
- // Find metric timestamp which is closest to deploymentTime
- timestampDiff = Math.abs(metricTimestamps[0] - median);
- metricTimestamps.forEach((timestamp, index) => {
- if (index === 0) { // Skip first element
- return;
- }
-
- smallestDiff = Math.abs(timestamp - median);
- if (smallestDiff < timestampDiff) {
- matchIndex = index;
- timestampDiff = smallestDiff;
- }
- });
-
- return matchIndex;
- },
-
- /**
- * Get Graph Plotting values to render Line and Dot
- */
- getGraphPlotValues(median, metrics) {
- const renderData = metrics.map(v => v[1]);
- const medianMetricIndex = this.getMedianMetricIndex(median, metrics);
- let cx = 0;
- let cy = 0;
-
- // Find Maximum and Minimum values from `renderData` array
- const maxMemory = Math.max.apply(null, renderData);
- const minMemory = Math.min.apply(null, renderData);
-
- // Find difference between extreme ends
- const diff = maxMemory - minMemory;
- const lineWidth = renderData.length;
-
- // Iterate over metrics values and perform following
- // 1. Find x & y co-ords for deploymentTime's memory value
- // 2. Return line path against maxMemory
- const linePath = renderData.map((y, x) => {
- if (medianMetricIndex === x) {
- cx = x;
- cy = maxMemory - y;
- }
- return `${x} ${maxMemory - y}`;
- });
-
- return {
- pathD: linePath,
- pathViewBox: {
- lineWidth,
- diff,
- },
- dotX: cx,
- dotY: cy,
- };
- },
-
- /**
- * Render Graph based on provided median and metrics values
- */
- renderGraph(median, metrics) {
- const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics);
-
- // Set props and update graph on UI.
- this.pathD = `M ${pathD}`;
- this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
- this.dotX = dotX;
- this.dotY = dotY;
- },
- },
- mounted() {
- this.renderGraph(this.deploymentTime, this.metrics);
- },
- template: `
- <div class="memory-graph-container">
- <svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg">
- <path :d="pathD" :viewBox="pathViewBox" />
- <circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" />
- </svg>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue
new file mode 100644
index 00000000000..b07f6b07afe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue
@@ -0,0 +1,133 @@
+<script>
+import { getTimeago } from '../../lib/utils/datetime_utility';
+
+export default {
+ name: 'MemoryGraph',
+ props: {
+ metrics: { type: Array, required: true },
+ deploymentTime: { type: Number, required: true },
+ width: { type: String, required: true },
+ height: { type: String, required: true },
+ },
+ data() {
+ return {
+ pathD: '',
+ pathViewBox: '',
+ dotX: '',
+ dotY: '',
+ };
+ },
+ computed: {
+ getFormattedMedian() {
+ const deployedSince = getTimeago().format(this.deploymentTime * 1000);
+ return `Deployed ${deployedSince}`;
+ },
+ },
+ mounted() {
+ this.renderGraph(this.deploymentTime, this.metrics);
+ },
+ methods: {
+ /**
+ * Returns metric value index in metrics array
+ * with timestamp closest to matching median
+ */
+ getMedianMetricIndex(median, metrics) {
+ let matchIndex = 0;
+ let timestampDiff = 0;
+ let smallestDiff = 0;
+
+ const metricTimestamps = metrics.map(v => v[0]);
+
+ // Find metric timestamp which is closest to deploymentTime
+ timestampDiff = Math.abs(metricTimestamps[0] - median);
+ metricTimestamps.forEach((timestamp, index) => {
+ if (index === 0) { // Skip first element
+ return;
+ }
+
+ smallestDiff = Math.abs(timestamp - median);
+ if (smallestDiff < timestampDiff) {
+ matchIndex = index;
+ timestampDiff = smallestDiff;
+ }
+ });
+
+ return matchIndex;
+ },
+
+ /**
+ * Get Graph Plotting values to render Line and Dot
+ */
+ getGraphPlotValues(median, metrics) {
+ const renderData = metrics.map(v => v[1]);
+ const medianMetricIndex = this.getMedianMetricIndex(median, metrics);
+ let cx = 0;
+ let cy = 0;
+
+ // Find Maximum and Minimum values from `renderData` array
+ const maxMemory = Math.max.apply(null, renderData);
+ const minMemory = Math.min.apply(null, renderData);
+
+ // Find difference between extreme ends
+ const diff = maxMemory - minMemory;
+ const lineWidth = renderData.length;
+
+ // Iterate over metrics values and perform following
+ // 1. Find x & y co-ords for deploymentTime's memory value
+ // 2. Return line path against maxMemory
+ const linePath = renderData.map((y, x) => {
+ if (medianMetricIndex === x) {
+ cx = x;
+ cy = maxMemory - y;
+ }
+ return `${x} ${maxMemory - y}`;
+ });
+
+ return {
+ pathD: linePath,
+ pathViewBox: {
+ lineWidth,
+ diff,
+ },
+ dotX: cx,
+ dotY: cy,
+ };
+ },
+
+ /**
+ * Render Graph based on provided median and metrics values
+ */
+ renderGraph(median, metrics) {
+ const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics);
+
+ // Set props and update graph on UI.
+ this.pathD = `M ${pathD}`;
+ this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
+ this.dotX = dotX;
+ this.dotY = dotY;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="memory-graph-container">
+ <svg
+ class="has-tooltip"
+ :title="getFormattedMedian"
+ :width="width"
+ :height="height"
+ xmlns="http://www.w3.org/2000/svg">
+ <path
+ :d="pathD"
+ :viewBox="pathViewBox"
+ />
+ <circle
+ r="1.5"
+ :cx="dotX"
+ :cy="dotY"
+ tranform="translate(0 -1)"
+ />
+ </svg>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
new file mode 100644
index 00000000000..b33a0101dbf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -0,0 +1,77 @@
+<script>
+ import $ from 'jquery';
+
+ /**
+ * Given an array of tabs, renders non linked bootstrap tabs.
+ * When a tab is clicked it will trigger an event and provide the clicked scope.
+ *
+ * This component is used in apps that handle the API call.
+ * If you only need to change the URL this component should not be used.
+ *
+ * @example
+ * <navigation-tabs
+ * :tabs="[
+ * {
+ * name: String,
+ * scope: String,
+ * count: Number || Undefined,
+ * isActive: Boolean,
+ * },
+ * ]"
+ * @onChangeTab="onChangeTab"
+ * />
+ */
+ export default {
+ name: 'NavigationTabs',
+ props: {
+ tabs: {
+ type: Array,
+ required: true,
+ },
+ scope: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ mounted() {
+ $(document).trigger('init.scrolling-tabs');
+ },
+ methods: {
+ shouldRenderBadge(count) {
+ // 0 is valid in a badge, but evaluates to false, we need to check for undefined
+ return count !== undefined;
+ },
+
+ onTabClick(tab) {
+ this.$emit('onChangeTab', tab.scope);
+ },
+ },
+ };
+</script>
+<template>
+ <ul class="nav-links scrolling-tabs separator">
+ <li
+ v-for="(tab, i) in tabs"
+ :key="i"
+ :class="{
+ active: tab.isActive,
+ }"
+ >
+ <a
+ role="button"
+ @click="onTabClick(tab)"
+ :class="`js-${scope}-tab-${tab.scope}`"
+ >
+ {{ tab.name }}
+
+ <span
+ v-if="shouldRenderBadge(tab.count)"
+ class="badge"
+ >
+ {{ tab.count }}
+ </span>
+ </a>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
new file mode 100644
index 00000000000..50b1508691b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -0,0 +1,70 @@
+<script>
+ /**
+ * Common component to render a placeholder note and user information.
+ *
+ * This component needs to be used with a vuex store.
+ * That vuex store needs to have a `getUserData` getter that contains
+ * {
+ * path: String,
+ * avatar_url: String,
+ * name: String,
+ * username: String,
+ * }
+ *
+ * @example
+ * <placeholder-note
+ * :note="{body: 'This is a note'}"
+ * />
+ */
+ import { mapGetters } from 'vuex';
+ import userAvatarLink from '../user_avatar/user_avatar_link.vue';
+
+ export default {
+ name: 'PlaceholderNote',
+ components: {
+ userAvatarLink,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserData',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <li class="note being-posted fade-in-half timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <user-avatar-link
+ :link-href="getUserData.path"
+ :img-src="getUserData.avatar_url"
+ :img-size="40"
+ />
+ </div>
+ <div
+ :class="{ discussion: !note.individual_note }"
+ class="timeline-content">
+ <div class="note-header">
+ <div class="note-header-info">
+ <a :href="getUserData.path">
+ <span class="hidden-xs">{{ getUserData.name }}</span>
+ <span class="note-headline-light">@{{ getUserData.username }}</span>
+ </a>
+ </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>{{ note.body }}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
new file mode 100644
index 00000000000..95e2b38e292
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
@@ -0,0 +1,29 @@
+<script>
+ /**
+ * Common component to render a placeholder system note.
+ *
+ * @example
+ * <placeholder-system-note
+ * :note="{ body: 'Commands are being applied'}"
+ * />
+ */
+ export default {
+ name: 'PlaceholderSystemNote',
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <li class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <em>{{ note.body }}</em>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
new file mode 100644
index 00000000000..80e3db52cb0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -0,0 +1,24 @@
+<template>
+ <li class="timeline-entry note">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ </div>
+ <div class="timeline-content">
+ <div class="note-header"></div>
+ <div class="note-body">
+ <skeleton-loading-container />
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
+
+<script>
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+ export default {
+ components: {
+ skeletonLoadingContainer,
+ },
+ };
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
new file mode 100644
index 00000000000..aac10f84087
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -0,0 +1,73 @@
+<script>
+ /**
+ * Common component to render a system note, icon and user information.
+ *
+ * This component needs to be used with a vuex store.
+ * That vuex store needs to have a `targetNoteHash` getter
+ *
+ * @example
+ * <system-note
+ * :note="{
+ * id: String,
+ * author: Object,
+ * createdAt: String,
+ * note_html: String,
+ * system_note_icon_name: String
+ * }"
+ * />
+ */
+ import { mapGetters } from 'vuex';
+ import noteHeader from '~/notes/components/note_header.vue';
+ import { spriteIcon } from '../../../lib/utils/common_utils';
+
+ export default {
+ name: 'SystemNote',
+ components: {
+ noteHeader,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters([
+ 'targetNoteHash',
+ ]),
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ isTargetNote() {
+ return this.targetNoteHash === this.noteAnchorId;
+ },
+ iconHtml() {
+ return spriteIcon(this.note.system_note_icon_name);
+ },
+ },
+ };
+</script>
+
+<template>
+ <li
+ :id="noteAnchorId"
+ :class="{ target: isTargetNote }"
+ class="note system-note timeline-entry">
+ <div class="timeline-entry-inner">
+ <div
+ class="timeline-icon"
+ v-html="iconHtml">
+ </div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header
+ :author="note.author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ :action-text-html="note.note_html"
+ />
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/panel_resizer.vue b/app/assets/javascripts/vue_shared/components/panel_resizer.vue
new file mode 100644
index 00000000000..abbe9a22717
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/panel_resizer.vue
@@ -0,0 +1,91 @@
+<script>
+ export default {
+ props: {
+ startSize: {
+ type: Number,
+ required: true,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ minSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ maxSize: {
+ type: Number,
+ required: false,
+ default: Number.MAX_VALUE,
+ },
+ enabled: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ size: this.startSize,
+ };
+ },
+ computed: {
+ className() {
+ return `drag${this.side}`;
+ },
+ cursorStyle() {
+ if (this.enabled) {
+ return { cursor: 'ew-resize' };
+ }
+ return {};
+ },
+ },
+ methods: {
+ resetSize(e) {
+ e.preventDefault();
+ this.size = this.startSize;
+ this.$emit('update:size', this.size);
+ },
+ startDrag(e) {
+ if (this.enabled) {
+ e.preventDefault();
+ this.startPos = e.clientX;
+ this.currentStartSize = this.size;
+ document.addEventListener('mousemove', this.drag);
+ document.addEventListener('mouseup', this.endDrag, { once: true });
+ this.$emit('resize-start', this.size);
+ }
+ },
+ drag(e) {
+ e.preventDefault();
+ let moved = e.clientX - this.startPos;
+ if (this.side === 'left') moved = -moved;
+ let newSize = this.currentStartSize + moved;
+ if (newSize < this.minSize) {
+ newSize = this.minSize;
+ } else if (newSize > this.maxSize) {
+ newSize = this.maxSize;
+ }
+ this.size = newSize;
+
+ this.$emit('update:size', newSize);
+ },
+ endDrag(e) {
+ e.preventDefault();
+ document.removeEventListener('mousemove', this.drag);
+ this.$emit('resize-end', this.size);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dragHandle"
+ :class="className"
+ :style="cursorStyle"
+ @mousedown="startDrag"
+ @dblclick="resetSize"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
new file mode 100644
index 00000000000..bfeece12077
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/pikaday.vue
@@ -0,0 +1,82 @@
+<script>
+ import Pikaday from 'pikaday';
+ import { parsePikadayDate, pikadayToString } from '../../lib/utils/datefix';
+
+ export default {
+ name: 'DatePicker',
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: 'Date picker',
+ },
+ selectedDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ minDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ maxDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ },
+ mounted() {
+ this.calendar = new Pikaday({
+ field: this.$el.querySelector('.dropdown-menu-toggle'),
+ theme: 'gitlab-theme animate-picker',
+ format: 'yyyy-mm-dd',
+ container: this.$el,
+ defaultDate: this.selectedDate,
+ setDefaultDate: !!this.selectedDate,
+ minDate: this.minDate,
+ maxDate: this.maxDate,
+ parse: dateString => parsePikadayDate(dateString),
+ toString: date => pikadayToString(date),
+ onSelect: this.selected.bind(this),
+ onClose: this.toggled.bind(this),
+ });
+
+ this.$el.append(this.calendar.el);
+ this.calendar.show();
+ },
+ beforeDestroy() {
+ this.calendar.destroy();
+ },
+ methods: {
+ selected(dateText) {
+ this.$emit('newDateSelected', this.calendar.toString(dateText));
+ },
+ toggled() {
+ this.$emit('hidePicker');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="pikaday-container">
+ <div class="dropdown open">
+ <button
+ type="button"
+ class="dropdown-menu-toggle"
+ data-toggle="dropdown"
+ @click="toggled"
+ >
+ <span class="dropdown-toggle-text">
+ {{ label }}
+ </span>
+ <i
+ class="fa fa-chevron-down"
+ aria-hidden="true"
+ >
+ </i>
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
new file mode 100644
index 00000000000..279cc1de5bb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
@@ -0,0 +1,103 @@
+<script>
+
+ /* This is a re-usable vue component for rendering a project avatar that
+ does not need to link to the project's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <project-avatar-image
+ :lazy="true"
+ :img-src="projectAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+ import defaultAvatarUrl from 'images/no_avatar.png';
+ import { placeholderImage } from '../../../lazy_loader';
+ import tooltip from '../../directives/tooltip';
+
+ export default {
+ name: 'ProjectAvatarImage',
+ directives: {
+ tooltip,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: 'project avatar',
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside project avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ tooltipContainer() {
+ return this.tooltipText ? 'body' : null;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <img
+ v-tooltip
+ class="avatar"
+ :class="{
+ lazy: lazy,
+ [avatarSizeClass]: true,
+ [cssClasses]: true
+ }"
+ :src="resultantSrcAttribute"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-src="sanitizedSource"
+ :data-container="tooltipContainer"
+ :data-placement="tooltipPlacement"
+ :title="tooltipText"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
new file mode 100644
index 00000000000..21ffdc1dc86
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -0,0 +1,86 @@
+<script>
+ import DeprecatedModal from './deprecated_modal.vue';
+
+ export default {
+ name: 'RecaptchaModal',
+
+ components: {
+ DeprecatedModal,
+ },
+
+ props: {
+ html: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ script: {},
+ scriptSrc: 'https://www.google.com/recaptcha/api.js',
+ };
+ },
+
+ watch: {
+ html() {
+ this.appendRecaptchaScript();
+ },
+ },
+
+ mounted() {
+ window.recaptchaDialogCallback = this.submit.bind(this);
+ },
+
+ methods: {
+ appendRecaptchaScript() {
+ this.removeRecaptchaScript();
+
+ const script = document.createElement('script');
+ script.src = this.scriptSrc;
+ script.classList.add('js-recaptcha-script');
+ script.async = true;
+ script.defer = true;
+
+ this.script = script;
+
+ document.body.appendChild(script);
+ },
+
+ removeRecaptchaScript() {
+ if (this.script instanceof Element) this.script.remove();
+ },
+
+ close() {
+ this.removeRecaptchaScript();
+ this.$emit('close');
+ },
+
+ submit() {
+ this.$el.querySelector('form').submit();
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ kind="warning"
+ class="recaptcha-modal js-recaptcha-modal"
+ :hide-footer="true"
+ :title="__('Please solve the reCAPTCHA')"
+ @cancel="close"
+ >
+ <div slot="body">
+ <p>
+ {{ __('We want to be sure it is you, please confirm you are not a robot.') }}
+ </p>
+ <div
+ ref="recaptcha"
+ v-html="html"
+ >
+ </div>
+ </div>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
new file mode 100644
index 00000000000..7f1eb6bcec4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
@@ -0,0 +1,46 @@
+<script>
+ export default {
+ name: 'CollapsedCalendarIcon',
+ props: {
+ containerClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ text: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ methods: {
+ click() {
+ this.$emit('click');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ :class="containerClass"
+ @click="click"
+ >
+ <i
+ v-if="showIcon"
+ class="fa fa-calendar"
+ aria-hidden="true"
+ >
+ </i>
+ <slot>
+ <span>
+ {{ text }}
+ </span>
+ </slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
new file mode 100644
index 00000000000..dac438a702d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
@@ -0,0 +1,111 @@
+<script>
+ import { dateInWords } from '../../../lib/utils/datetime_utility';
+ import toggleSidebar from './toggle_sidebar.vue';
+ import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
+
+ export default {
+ name: 'SidebarCollapsedGroupedDatePicker',
+ components: {
+ toggleSidebar,
+ collapsedCalendarIcon,
+ },
+ props: {
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showToggleSidebar: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ minDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ maxDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ disableClickableIcons: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ hasMinAndMaxDates() {
+ return this.minDate && this.maxDate;
+ },
+ hasNoMinAndMaxDates() {
+ return !this.minDate && !this.maxDate;
+ },
+ showMinDateBlock() {
+ return this.minDate || this.hasNoMinAndMaxDates;
+ },
+ showFromText() {
+ return !this.maxDate && this.minDate;
+ },
+ iconClass() {
+ const disabledClass = this.disableClickableIcons ? 'disabled' : '';
+ return `block sidebar-collapsed-icon calendar-icon ${disabledClass}`;
+ },
+ },
+ methods: {
+ toggleSidebar() {
+ this.$emit('toggleCollapse');
+ },
+ dateText(dateType = 'min') {
+ const date = this[`${dateType}Date`];
+ const dateWords = dateInWords(date, true);
+ const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords;
+
+ return date ? parsedDateWords : 'None';
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="block sidebar-grouped-item">
+ <div
+ v-if="showToggleSidebar"
+ class="issuable-sidebar-header"
+ >
+ <toggle-sidebar
+ :collapsed="collapsed"
+ @toggle="toggleSidebar"
+ />
+ </div>
+ <collapsed-calendar-icon
+ v-if="showMinDateBlock"
+ :container-class="iconClass"
+ @click="toggleSidebar"
+ >
+ <span class="sidebar-collapsed-value">
+ <span v-if="showFromText">From</span>
+ <span>{{ dateText('min') }}</span>
+ </span>
+ </collapsed-calendar-icon>
+ <div
+ v-if="hasMinAndMaxDates"
+ class="text-center sidebar-collapsed-divider"
+ >
+ -
+ </div>
+ <collapsed-calendar-icon
+ v-if="maxDate"
+ :container-class="iconClass"
+ :show-icon="!minDate"
+ @click="toggleSidebar"
+ >
+ <span class="sidebar-collapsed-value">
+ <span v-if="!minDate">Until</span>
+ <span>{{ dateText('max') }}</span>
+ </span>
+ </collapsed-calendar-icon>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
new file mode 100644
index 00000000000..3fcacd156c5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
@@ -0,0 +1,174 @@
+<script>
+ import datePicker from '../pikaday.vue';
+ import loadingIcon from '../loading_icon.vue';
+ import toggleSidebar from './toggle_sidebar.vue';
+ import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
+ import { dateInWords } from '../../../lib/utils/datetime_utility';
+
+ export default {
+ name: 'SidebarDatePicker',
+ components: {
+ datePicker,
+ toggleSidebar,
+ loadingIcon,
+ collapsedCalendarIcon,
+ },
+ props: {
+ blockClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showToggleSidebar: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: 'Date picker',
+ },
+ selectedDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ minDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ maxDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ editing: false,
+ };
+ },
+ computed: {
+ selectedAndEditable() {
+ return this.selectedDate && this.editable;
+ },
+ selectedDateWords() {
+ return dateInWords(this.selectedDate, true);
+ },
+ collapsedText() {
+ return this.selectedDateWords ? this.selectedDateWords : 'None';
+ },
+ },
+ methods: {
+ stopEditing() {
+ this.editing = false;
+ },
+ toggleDatePicker() {
+ this.editing = !this.editing;
+ },
+ newDateSelected(date = null) {
+ this.date = date;
+ this.editing = false;
+ this.$emit('saveDate', date);
+ },
+ toggleSidebar() {
+ this.$emit('toggleCollapse');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="block"
+ :class="blockClass"
+ >
+ <div class="issuable-sidebar-header">
+ <toggle-sidebar
+ :collapsed="collapsed"
+ @toggle="toggleSidebar"
+ />
+ </div>
+ <collapsed-calendar-icon
+ class="sidebar-collapsed-icon"
+ :text="collapsedText"
+ />
+ <div class="title">
+ {{ label }}
+ <loading-icon
+ v-if="isLoading"
+ :inline="true"
+ />
+ <div class="pull-right">
+ <button
+ v-if="editable && !editing"
+ type="button"
+ class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
+ @click="toggleDatePicker"
+ >
+ Edit
+ </button>
+ <toggle-sidebar
+ v-if="showToggleSidebar"
+ :collapsed="collapsed"
+ @toggle="toggleSidebar"
+ />
+ </div>
+ </div>
+ <div class="value">
+ <date-picker
+ v-if="editing"
+ :selected-date="selectedDate"
+ :min-date="minDate"
+ :max-date="maxDate"
+ :label="label"
+ @newDateSelected="newDateSelected"
+ @hidePicker="stopEditing"
+ />
+ <span
+ v-else
+ class="value-content"
+ >
+ <template v-if="selectedDate">
+ <strong>{{ selectedDateWords }}</strong>
+ <span
+ v-if="selectedAndEditable"
+ class="no-value"
+ >
+ -
+ <button
+ type="button"
+ class="btn-blank btn-link btn-secondary-hover-link"
+ @click="newDateSelected(null)"
+ >
+ remove
+ </button>
+ </span>
+ </template>
+ <span
+ v-else
+ class="no-value"
+ >
+ None
+ </span>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
new file mode 100644
index 00000000000..5ede53d8d01
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -0,0 +1,173 @@
+<script>
+import { __ } from '~/locale';
+import LabelsSelect from '~/labels_select';
+import LoadingIcon from '../../loading_icon.vue';
+
+import DropdownTitle from './dropdown_title.vue';
+import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
+import DropdownButton from './dropdown_button.vue';
+import DropdownHiddenInput from './dropdown_hidden_input.vue';
+import DropdownHeader from './dropdown_header.vue';
+import DropdownSearchInput from './dropdown_search_input.vue';
+import DropdownFooter from './dropdown_footer.vue';
+import DropdownCreateLabel from './dropdown_create_label.vue';
+
+export default {
+ components: {
+ LoadingIcon,
+ DropdownTitle,
+ DropdownValue,
+ DropdownValueCollapsed,
+ DropdownButton,
+ DropdownHiddenInput,
+ DropdownHeader,
+ DropdownSearchInput,
+ DropdownFooter,
+ DropdownCreateLabel,
+ },
+ props: {
+ showCreate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isProject: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ abilityName: {
+ type: String,
+ required: true,
+ },
+ context: {
+ type: Object,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsPath: {
+ type: String,
+ required: true,
+ },
+ labelsWebUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelFilterBasePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ hiddenInputName() {
+ return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]';
+ },
+ createLabelTitle() {
+ if (this.isProject) {
+ return __('Create project label');
+ }
+
+ return __('Create group label');
+ },
+ manageLabelsTitle() {
+ if (this.isProject) {
+ return __('Manage project labels');
+ }
+
+ return __('Manage group labels');
+ },
+ },
+ mounted() {
+ this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
+ handleClick: this.handleClick,
+ });
+ },
+ methods: {
+ handleClick(label) {
+ this.$emit('onLabelClick', label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block labels js-labels-block">
+ <dropdown-value-collapsed
+ v-if="showCreate"
+ :labels="context.labels"
+ />
+ <dropdown-title
+ :can-edit="canEdit"
+ />
+ <dropdown-value
+ :labels="context.labels"
+ :label-filter-base-path="labelFilterBasePath"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <div
+ v-if="canEdit"
+ class="selectbox js-selectbox"
+ style="display: none;"
+ >
+ <dropdown-hidden-input
+ v-for="label in context.labels"
+ :key="label.id"
+ :name="hiddenInputName"
+ :label="label"
+ />
+ <div class="dropdown">
+ <dropdown-button
+ :ability-name="abilityName"
+ :field-name="hiddenInputName"
+ :update-path="updatePath"
+ :labels-path="labelsPath"
+ :namespace="namespace"
+ :labels="context.labels"
+ :show-extra-options="!showCreate"
+ />
+ <div
+ class="dropdown-menu dropdown-select dropdown-menu-paging
+dropdown-menu-labels dropdown-menu-selectable"
+ >
+ <div class="dropdown-page-one">
+ <dropdown-header v-if="showCreate" />
+ <dropdown-search-input/>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-loading">
+ <loading-icon />
+ </div>
+ <dropdown-footer
+ v-if="showCreate"
+ :labels-web-url="labelsWebUrl"
+ :create-label-title="createLabelTitle"
+ :manage-labels-title="manageLabelsTitle"
+ />
+ </div>
+ <dropdown-create-label
+ v-if="showCreate"
+ :is-project="isProject"
+ :header-title="createLabelTitle"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
new file mode 100644
index 00000000000..47497c1de98
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -0,0 +1,78 @@
+<script>
+import { __, s__, sprintf } from '~/locale';
+
+export default {
+ props: {
+ abilityName: {
+ type: String,
+ required: true,
+ },
+ fieldName: {
+ type: String,
+ required: true,
+ },
+ updatePath: {
+ type: String,
+ required: true,
+ },
+ labelsPath: {
+ type: String,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ labels: {
+ type: Array,
+ required: true,
+ },
+ showExtraOptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ dropdownToggleText() {
+ if (this.labels.length === 0) {
+ return __('Label');
+ }
+
+ if (this.labels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: this.labels[0].title,
+ remainingLabelCount: this.labels.length - 1,
+ });
+ }
+
+ return this.labels[0].title;
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ type="button"
+ ref="dropdownButton"
+ class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
+ data-toggle="dropdown"
+ :class="{ 'js-extra-options': showExtraOptions }"
+ :data-ability-name="abilityName"
+ :data-field-name="fieldName"
+ :data-issue-update="updatePath"
+ :data-labels="labelsPath"
+ :data-namespace-path="namespace"
+ :data-show-any="showExtraOptions"
+ >
+ <span class="dropdown-toggle-text">
+ {{ dropdownToggleText }}
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
new file mode 100644
index 00000000000..34a07f33a23
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
@@ -0,0 +1,93 @@
+<script>
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ headerTitle: {
+ type: String,
+ required: false,
+ default: () => __('Create new label'),
+ },
+ },
+ created() {
+ this.suggestedColors = gon.suggested_label_colors;
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown-page-two dropdown-new-label">
+ <div class="dropdown-title">
+ <button
+ type="button"
+ class="dropdown-title-button dropdown-menu-back"
+ :aria-label="__('Go back')"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-arrow-left"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+ {{ headerTitle }}
+ <button
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ :aria-label="__('Close')"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-content">
+ <div class="dropdown-labels-error js-label-error"></div>
+ <input
+ id="new_label_name"
+ type="text"
+ class="default-dropdown-input"
+ :placeholder="__('Name new label')"
+ />
+ <div class="suggest-colors suggest-colors-dropdown">
+ <a
+ v-for="(color, index) in suggestedColors"
+ href="#"
+ :key="index"
+ :data-color="color"
+ :style="{
+ backgroundColor: color,
+ }"
+ >
+ &nbsp;
+ </a>
+ </div>
+ <div class="dropdown-label-color-input">
+ <div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div>
+ <input
+ id="new_label_color"
+ type="text"
+ class="default-dropdown-input"
+ :placeholder="__('Assign custom color like #FF0000')"
+ />
+ </div>
+ <div class="clearfix">
+ <button
+ type="button"
+ class="btn btn-primary pull-left js-new-label-btn disabled"
+ >
+ {{ __('Create') }}
+ </button>
+ <button
+ type="button"
+ class="btn btn-default pull-right js-cancel-label-btn"
+ >
+ {{ __('Cancel') }}
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
new file mode 100644
index 00000000000..5f61e9fbe80
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
@@ -0,0 +1,46 @@
+<script>
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ labelsWebUrl: {
+ type: String,
+ required: true,
+ },
+ createLabelTitle: {
+ type: String,
+ required: false,
+ default: () => __('Create new label'),
+ },
+ manageLabelsTitle: {
+ type: String,
+ required: false,
+ default: () => __('Manage labels'),
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a
+ href="#"
+ class="dropdown-toggle-page"
+ >
+ {{ createLabelTitle }}
+ </a>
+ </li>
+ <li>
+ <a
+ data-is-link="true"
+ class="dropdown-external-link"
+ :href="labelsWebUrl"
+ >
+ {{ manageLabelsTitle }}
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
new file mode 100644
index 00000000000..7664acdf19c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
@@ -0,0 +1,21 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="dropdown-title">
+ <span>{{ __('Assign labels') }}</span>
+ <button
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ :aria-label="__('Close')"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue
new file mode 100644
index 00000000000..1832c3c1757
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue
@@ -0,0 +1,22 @@
+<script>
+export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <input
+ type="hidden"
+ :name="name"
+ :value="label.id"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
new file mode 100644
index 00000000000..ae633460c95
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
@@ -0,0 +1,27 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="dropdown-input">
+ <input
+ autocomplete="off"
+ class="dropdown-input-field"
+ type="search"
+ :placeholder="__('Search')"
+ />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search"
+ data-hidden="true"
+ >
+ </i>
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
+ data-hidden="true"
+ role="button"
+ >
+ </i>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
new file mode 100644
index 00000000000..7da82e90e29
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
@@ -0,0 +1,30 @@
+<script>
+export default {
+ props: {
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="title hide-collapsed append-bottom-10">
+ {{ __('Labels') }}
+ <template v-if="canEdit">
+ <i
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ data-hidden="true"
+ >
+ </i>
+ <button
+ type="button"
+ class="edit-link btn btn-blank pull-right js-sidebar-dropdown-toggle"
+ >
+ {{ __('Edit') }}
+ </button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
new file mode 100644
index 00000000000..69d588eb25d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -0,0 +1,63 @@
+<script>
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ labelFilterBasePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isEmpty() {
+ return this.labels.length === 0;
+ },
+ },
+ methods: {
+ labelFilterUrl(label) {
+ return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
+ },
+ labelStyle(label) {
+ return {
+ color: label.textColor,
+ backgroundColor: label.color,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="hide-collapsed value issuable-show-labels js-value">
+ <span
+ v-if="isEmpty"
+ class="text-secondary"
+ >
+ <slot>{{ __('None') }}</slot>
+ </span>
+ <a
+ v-else
+ v-for="label in labels"
+ :key="label.id"
+ :href="labelFilterUrl(label)"
+ >
+ <span
+ v-tooltip
+ class="label color-label"
+ data-placement="bottom"
+ data-container="body"
+ :style="labelStyle(label)"
+ :title="label.description"
+ >
+ {{ label.title }}
+ </span>
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
new file mode 100644
index 00000000000..5cf728fe050
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
@@ -0,0 +1,48 @@
+<script>
+import { s__, sprintf } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ labelsList() {
+ const labelsString = this.labels.slice(0, 5).map(label => label.title).join(', ');
+
+ if (this.labels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.labels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-tooltip
+ class="sidebar-collapsed-icon"
+ data-placement="left"
+ data-container="body"
+ :title="labelsList"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-tags"
+ >
+ </i>
+ <span>{{ labels.length }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
new file mode 100644
index 00000000000..8211d425b1f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -0,0 +1,34 @@
+<script>
+ export default {
+ name: 'ToggleSidebar',
+ props: {
+ collapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ toggle() {
+ this.$emit('toggle');
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ type="button"
+ class="btn btn-blank gutter-toggle btn-sidebar-action"
+ @click="toggle"
+ >
+ <i
+ aria-label="toggle collapse"
+ class="fa"
+ :class="{
+ 'fa-angle-double-right': !collapsed,
+ 'fa-angle-double-left': collapsed
+ }"
+ >
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue
new file mode 100644
index 00000000000..b06493e6c66
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue
@@ -0,0 +1,37 @@
+<script>
+ export default {
+ props: {
+ small: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ lines: {
+ type: Number,
+ required: false,
+ default: 6,
+ },
+ },
+ computed: {
+ lineClasses() {
+ return new Array(this.lines).fill().map((_, i) => `skeleton-line-${i + 1}`);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="animation-container"
+ :class="{
+ 'animation-container-small': small,
+ }"
+ >
+ <div
+ v-for="(css, index) in lineClasses"
+ :key="index"
+ :class="css"
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
new file mode 100644
index 00000000000..86f06c8d266
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -0,0 +1,127 @@
+<script>
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ successLabel: {
+ type: String,
+ required: true,
+ },
+ failureLabel: {
+ type: String,
+ required: true,
+ },
+ neutralLabel: {
+ type: String,
+ required: true,
+ },
+ successCount: {
+ type: Number,
+ required: true,
+ },
+ failureCount: {
+ type: Number,
+ required: true,
+ },
+ totalCount: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ neutralCount() {
+ return this.totalCount - this.successCount - this.failureCount;
+ },
+ successPercent() {
+ return this.getPercent(this.successCount);
+ },
+ successBarStyle() {
+ return this.barStyle(this.successPercent);
+ },
+ successTooltip() {
+ return this.getTooltip(this.successLabel, this.successCount);
+ },
+ failurePercent() {
+ return this.getPercent(this.failureCount);
+ },
+ failureBarStyle() {
+ return this.barStyle(this.failurePercent);
+ },
+ failureTooltip() {
+ return this.getTooltip(this.failureLabel, this.failureCount);
+ },
+ neutralPercent() {
+ return this.getPercent(this.neutralCount);
+ },
+ neutralBarStyle() {
+ return this.barStyle(this.neutralPercent);
+ },
+ neutralTooltip() {
+ return this.getTooltip(this.neutralLabel, this.neutralCount);
+ },
+ },
+ methods: {
+ getPercent(count) {
+ return Math.ceil((count / this.totalCount) * 100);
+ },
+ barStyle(percent) {
+ return `width: ${percent}%;`;
+ },
+ getTooltip(label, count) {
+ return `${label}: ${count}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="stacked-progress-bar"
+ :class="cssClass"
+ >
+ <span
+ v-if="!totalCount"
+ class="status-unavailable"
+ >
+ {{ __("Not available") }}
+ </span>
+ <span
+ v-tooltip
+ v-if="successPercent"
+ class="status-green"
+ data-placement="bottom"
+ :title="successTooltip"
+ :style="successBarStyle"
+ >
+ {{ successPercent }}%
+ </span>
+ <span
+ v-tooltip
+ v-if="neutralPercent"
+ class="status-neutral"
+ data-placement="bottom"
+ :title="neutralTooltip"
+ :style="neutralBarStyle"
+ >
+ {{ neutralPercent }}%
+ </span>
+ <span
+ v-tooltip
+ v-if="failurePercent"
+ class="status-red"
+ data-placement="bottom"
+ :title="failureTooltip"
+ :style="failureBarStyle"
+ >
+ {{ failurePercent }}%
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue
index 5e7df22dd83..22fc5757447 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -1,136 +1,155 @@
<script>
-const PAGINATION_UI_BUTTON_LIMIT = 4;
-const UI_LIMIT = 6;
-const SPREAD = '...';
-const PREV = 'Prev';
-const NEXT = 'Next';
-const FIRST = '« First';
-const LAST = 'Last »';
-
-export default {
- props: {
- /**
- This function will take the information given by the pagination component
-
- Here is an example `change` method:
-
- change(pagenum) {
- gl.utils.visitUrl(`?page=${pagenum}`);
+ import { s__ } from '../../locale';
+
+ const PAGINATION_UI_BUTTON_LIMIT = 4;
+ const UI_LIMIT = 6;
+ const SPREAD = '...';
+ const PREV = s__('Pagination|Prev');
+ const NEXT = s__('Pagination|Next');
+ const FIRST = s__('Pagination|« First');
+ const LAST = s__('Pagination|Last »');
+
+ export default {
+ props: {
+ /**
+ This function will take the information given by the pagination component
+
+ Here is an example `change` method:
+
+ change(pagenum) {
+ gl.utils.visitUrl(`?page=${pagenum}`);
+ },
+ */
+ change: {
+ type: Function,
+ required: true,
},
- */
- change: {
- type: Function,
- required: true,
- },
- /**
- pageInfo will come from the headers of the API call
- in the `.then` clause of the VueResource API call
- there should be a function that contructs the pageInfo for this component
-
- This is an example:
-
- const pageInfo = headers => ({
- perPage: +headers['X-Per-Page'],
- page: +headers['X-Page'],
- total: +headers['X-Total'],
- totalPages: +headers['X-Total-Pages'],
- nextPage: +headers['X-Next-Page'],
- previousPage: +headers['X-Prev-Page'],
- });
- */
- pageInfo: {
- type: Object,
- required: true,
- },
- },
- methods: {
- changePage(e) {
- const text = e.target.innerText;
- const { totalPages, nextPage, previousPage } = this.pageInfo;
-
- switch (text) {
- case SPREAD:
- break;
- case LAST:
- this.change(totalPages);
- break;
- case NEXT:
- this.change(nextPage);
- break;
- case PREV:
- this.change(previousPage);
- break;
- case FIRST:
- this.change(1);
- break;
- default:
- this.change(+text);
- break;
- }
- },
- },
- computed: {
- prev() {
- return this.pageInfo.previousPage;
+ /**
+ pageInfo will come from the headers of the API call
+ in the `.then` clause of the VueResource API call
+ there should be a function that contructs the pageInfo for this component
+
+ This is an example:
+
+ const pageInfo = headers => ({
+ perPage: +headers['X-Per-Page'],
+ page: +headers['X-Page'],
+ total: +headers['X-Total'],
+ totalPages: +headers['X-Total-Pages'],
+ nextPage: +headers['X-Next-Page'],
+ previousPage: +headers['X-Prev-Page'],
+ });
+ */
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
},
- next() {
- return this.pageInfo.nextPage;
+ computed: {
+ prev() {
+ return this.pageInfo.previousPage;
+ },
+ next() {
+ return this.pageInfo.nextPage;
+ },
+ getItems() {
+ const total = this.pageInfo.totalPages;
+ const page = this.pageInfo.page;
+ const items = [];
+
+ if (page > 1) {
+ items.push({ title: FIRST, first: true });
+ }
+
+ if (page > 1) {
+ items.push({ title: PREV, prev: true });
+ } else {
+ items.push({ title: PREV, disabled: true, prev: true });
+ }
+
+ if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
+
+ const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
+ const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
+
+ for (let i = start; i <= end; i += 1) {
+ const isActive = i === page;
+ items.push({ title: i, active: isActive, page: true });
+ }
+
+ if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
+ items.push({ title: SPREAD, separator: true, page: true });
+ }
+
+ if (page === total) {
+ items.push({ title: NEXT, disabled: true, next: true });
+ } else if (total - page >= 1) {
+ items.push({ title: NEXT, next: true });
+ }
+
+ if (total - page >= 1) {
+ items.push({ title: LAST, last: true });
+ }
+
+ return items;
+ },
+ showPagination() {
+ return this.pageInfo.totalPages > 1;
+ },
},
- getItems() {
- const total = this.pageInfo.totalPages;
- const page = this.pageInfo.page;
- const items = [];
-
- if (page > 1) items.push({ title: FIRST });
-
- if (page > 1) {
- items.push({ title: PREV, prev: true });
- } else {
- items.push({ title: PREV, disabled: true, prev: true });
- }
-
- if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
-
- const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
- const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
-
- for (let i = start; i <= end; i += 1) {
- const isActive = i === page;
- items.push({ title: i, active: isActive, page: true });
- }
-
- if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
- items.push({ title: SPREAD, separator: true, page: true });
- }
-
- if (page === total) {
- items.push({ title: NEXT, disabled: true, next: true });
- } else if (total - page >= 1) {
- items.push({ title: NEXT, next: true });
- }
-
- if (total - page >= 1) items.push({ title: LAST, last: true });
-
- return items;
+ methods: {
+ changePage(text, isDisabled) {
+ if (isDisabled) return;
+
+ const { totalPages, nextPage, previousPage } = this.pageInfo;
+
+ switch (text) {
+ case SPREAD:
+ break;
+ case LAST:
+ this.change(totalPages);
+ break;
+ case NEXT:
+ this.change(nextPage);
+ break;
+ case PREV:
+ this.change(previousPage);
+ break;
+ case FIRST:
+ this.change(1);
+ break;
+ default:
+ this.change(+text);
+ break;
+ }
+ },
},
- },
-};
+ };
</script>
<template>
- <div class="gl-pagination">
+ <div
+ v-if="showPagination"
+ class="gl-pagination"
+ >
<ul class="pagination clearfix">
<li
- v-for="item in getItems"
+ v-for="(item, index) in getItems"
+ :key="index"
:class="{
page: item.page,
- prev: item.prev,
- next: item.next,
+ 'js-previous-button': item.prev,
+ 'js-next-button': item.next,
+ 'js-last-button': item.last,
+ 'js-first-button': item.first,
separator: item.separator,
active: item.active,
disabled: item.disabled
- }">
- <a @click="changePage($event)">{{item.title}}</a>
+ }"
+ >
+ <a @click.prevent="changePage(item.title, item.disabled)">
+ {{ item.title }}
+ </a>
</li>
</ul>
</div>
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 3ff7f6e2c4e..bec4e7c99b6 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -8,6 +8,12 @@ import '../../lib/utils/datetime_utility';
*/
export default {
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
props: {
time: {
type: String,
@@ -26,14 +32,6 @@ export default {
default: '',
},
},
-
- mixins: [
- timeagoMixin,
- ],
-
- directives: {
- tooltip,
- },
};
</script>
<template>
@@ -43,6 +41,6 @@ export default {
:title="tooltipTitle(time)"
:data-placement="tooltipPlacement"
data-container="body">
- {{timeFormated(time)}}
+ {{ timeFormated(time) }}
</time>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
new file mode 100644
index 00000000000..09031d3ffa1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -0,0 +1,89 @@
+<script>
+ import { s__ } from '../../locale';
+ import icon from './icon.vue';
+ import loadingIcon from './loading_icon.vue';
+
+ const ICON_ON = 'status_success_borderless';
+ const ICON_OFF = 'status_failed_borderless';
+ const LABEL_ON = s__('ToggleButton|Toggle Status: ON');
+ const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF');
+
+ export default {
+ components: {
+ icon,
+ loadingIcon,
+ },
+
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ value: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ disabledInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ toggleIcon() {
+ return this.value ? ICON_ON : ICON_OFF;
+ },
+ ariaLabel() {
+ return this.value ? LABEL_ON : LABEL_OFF;
+ },
+ },
+
+ methods: {
+ toggleFeature() {
+ if (!this.disabledInput) this.$emit('change', !this.value);
+ },
+ },
+ };
+</script>
+
+<template>
+ <label class="toggle-wrapper">
+ <input
+ v-if="name"
+ type="hidden"
+ :name="name"
+ :value="value"
+ />
+ <button
+ type="button"
+ class="project-feature-toggle"
+ :aria-label="ariaLabel"
+ :class="{
+ 'is-checked': value,
+ 'is-disabled': disabledInput,
+ 'is-loading': isLoading
+ }"
+ @click="toggleFeature"
+ >
+ <loadingIcon class="loading-icon" />
+ <span class="toggle-icon">
+ <icon
+ css-classes="toggle-icon-svg"
+ :name="toggleIcon"/>
+ </span>
+ </button>
+ </label>
+</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
index dd9a2ebb184..cc9cc46bb4c 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -7,6 +7,7 @@
Sample configuration:
<user-avatar-image
+ :lazy="true"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
@@ -16,11 +17,20 @@
*/
import defaultAvatarUrl from 'images/no_avatar.png';
+import { placeholderImage } from '../../../lazy_loader';
import tooltip from '../../directives/tooltip';
export default {
name: 'UserAvatarImage',
+ directives: {
+ tooltip,
+ },
props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
imgSrc: {
type: String,
required: false,
@@ -52,22 +62,22 @@ export default {
default: 'top',
},
},
- directives: {
- tooltip,
- },
computed: {
+ // 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
+ sanitizedSource() {
+ return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
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>
@@ -76,11 +86,16 @@ export default {
<img
v-tooltip
class="avatar"
- :class="[avatarSizeClass, cssClasses]"
- :src="imageSource"
+ :class="{
+ lazy: lazy,
+ [avatarSizeClass]: true,
+ [cssClasses]: true
+ }"
+ :src="resultantSrcAttribute"
:width="size"
:height="size"
:alt="imgAlt"
+ :data-src="sanitizedSource"
:data-container="tooltipContainer"
:data-placement="tooltipPlacement"
:title="tooltipText"
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
index 95898d54cf7..6955d164def 100644
--- 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
@@ -12,18 +12,23 @@
:img-alt="tooltipText"
:img-size="20"
:tooltip-text="tooltipText"
- tooltip-placement="top"
+ :tooltip-placement="top"
+ :username="username"
/>
*/
import userAvatarImage from './user_avatar_image.vue';
+import tooltip from '../../directives/tooltip';
export default {
name: 'UserAvatarLink',
components: {
userAvatarImage,
},
+ directives: {
+ tooltip,
+ },
props: {
linkHref: {
type: String,
@@ -60,6 +65,19 @@ export default {
required: false,
default: 'top',
},
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
},
};
</script>
@@ -73,8 +91,13 @@ export default {
:img-alt="imgAlt"
:css-classes="imgCssClasses"
:size="imgSize"
- :tooltip-text="tooltipText"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ /><span
+ v-if="shouldShowUsername"
+ v-tooltip
+ :title="tooltipText"
:tooltip-placement="tooltipPlacement"
- />
+ >{{ username }}</span>
</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
index d2ff2ac006e..ef3b16edf5f 100644
--- 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
@@ -39,7 +39,7 @@ export default {
:class="avatarSizeClass"
:height="size"
:width="size"
- v-html="svg">
- </svg>
+ v-html="svg"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/directives/popover.js b/app/assets/javascripts/vue_shared/directives/popover.js
new file mode 100644
index 00000000000..eb35294906b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/popover.js
@@ -0,0 +1,22 @@
+import $ from 'jquery';
+
+/**
+ * Helper to user bootstrap popover in vue.js.
+ * Follow docs for html attributes: https://getbootstrap.com/docs/3.3/javascript/#static-popover
+ *
+ * @example
+ * import popover from 'vue_shared/directives/popover.js';
+ * {
+ * directives: [popover]
+ * }
+ * <a v-popover="{options}">popover</a>
+ */
+export default {
+ bind(el, binding) {
+ $(el).popover(binding.value);
+ },
+
+ unbind(el) {
+ $(el).popover('destroy');
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
index dc896cf5c7d..b7f7e9fec15 100644
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default {
bind(el) {
$(el).tooltip();
diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
new file mode 100644
index 00000000000..f94cc670edf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
@@ -0,0 +1,42 @@
+/**
+ * API callbacks for pagination and tabs
+ * shared between Pipelines and Environments table.
+ *
+ * Components need to have `scope`, `page` and `requestData`
+ */
+import {
+ historyPushState,
+ buildUrlWithCurrentLocation,
+} from '../../lib/utils/common_utils';
+
+export default {
+ methods: {
+ onChangeTab(scope) {
+ this.updateContent({ scope, page: '1' });
+ },
+
+ onChangePage(page) {
+ /* URLS parameters are strings, we need to parse to match types */
+ this.updateContent({ scope: this.scope, page: Number(page).toString() });
+ },
+
+ updateInternalState(parameters) {
+ // stop polling
+ this.poll.stop();
+
+ const queryString = Object.keys(parameters).map((parameter) => {
+ const value = parameters[parameter];
+ // update internal state for UI
+ this[parameter] = value;
+ return `${parameter}=${encodeURIComponent(value)}`;
+ }).join('&');
+
+ // update polling parameters
+ this.requestData = parameters;
+
+ historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
+
+ this.isLoading = true;
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js
new file mode 100644
index 00000000000..fab0919d96e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/issuable.js
@@ -0,0 +1,14 @@
+export default {
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+
+ computed: {
+ issuableDisplayName() {
+ return this.issuableType.replace(/_/g, ' ');
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js
new file mode 100644
index 00000000000..ff1f565e79a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js
@@ -0,0 +1,36 @@
+import recaptchaModal from '../components/recaptcha_modal.vue';
+
+export default {
+ data() {
+ return {
+ showRecaptcha: false,
+ recaptchaHTML: '',
+ };
+ },
+
+ components: {
+ recaptchaModal,
+ },
+
+ methods: {
+ openRecaptcha() {
+ this.showRecaptcha = true;
+ },
+
+ closeRecaptcha() {
+ this.showRecaptcha = false;
+ },
+
+ checkForSpam(data) {
+ if (!data.recaptcha_html) return data;
+
+ this.recaptchaHTML = data.recaptcha_html;
+
+ const spamError = new Error(data.error_message);
+ spamError.name = 'SpamError';
+ spamError.message = 'SpamError';
+
+ throw spamError;
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index 20f63ab663c..4e3b9d7b767 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -1,4 +1,4 @@
-import '../../lib/utils/datetime_utility';
+import { formatDate, getTimeago } from '../../lib/utils/datetime_utility';
/**
* Mixin with time ago methods used in some vue components
@@ -6,13 +6,13 @@ import '../../lib/utils/datetime_utility';
export default {
methods: {
timeFormated(time) {
- const timeago = gl.utils.getTimeago();
+ const timeago = getTimeago();
return timeago.format(time);
},
tooltipTitle(time) {
- return gl.utils.formatDate(time);
+ return formatDate(time);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/models/label.js b/app/assets/javascripts/vue_shared/models/label.js
new file mode 100644
index 00000000000..70b9efe0c68
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/models/label.js
@@ -0,0 +1,13 @@
+class ListLabel {
+ constructor(obj) {
+ this.id = obj.id;
+ this.title = obj.title;
+ this.type = obj.type;
+ this.color = obj.color;
+ this.textColor = obj.text_color;
+ this.description = obj.description;
+ this.priority = (obj.priority !== null) ? obj.priority : Infinity;
+ }
+}
+
+window.ListLabel = ListLabel;
diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js
index f83c4b00761..2c7886ec308 100644
--- a/app/assets/javascripts/vue_shared/translate.js
+++ b/app/assets/javascripts/vue_shared/translate.js
@@ -2,6 +2,7 @@ import {
__,
n__,
s__,
+ sprintf,
} from '../locale';
export default (Vue) => {
@@ -37,6 +38,7 @@ export default (Vue) => {
@returns {String} Translated context based text
**/
s__,
+ sprintf,
},
});
};
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
index 740930dce5b..b9693892f45 100644
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
+import csrf from '../lib/utils/csrf';
Vue.use(VueResource);
@@ -14,11 +15,20 @@ Vue.http.interceptors.push((request, next) => {
});
});
-// Inject CSRF token so we don't break any tests.
+// Inject CSRF token and parse headers.
+// New Vue Resource version uses Headers, we are expecting a plain object to render pagination
+// and polling.
Vue.http.interceptors.push((request, next) => {
- if ($.rails) {
+ request.headers.set(csrf.headerKey, csrf.token);
+
+ next((response) => {
+ // Headers object has a `forEach` property that iterates through all values.
+ const headers = {};
+
+ response.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
// eslint-disable-next-line no-param-reassign
- request.headers['X-CSRF-Token'] = $.rails.csrfToken();
- }
- next();
+ response.headers = headers;
+ });
});
diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js
new file mode 100644
index 00000000000..ced847294ae
--- /dev/null
+++ b/app/assets/javascripts/webpack.js
@@ -0,0 +1,9 @@
+/**
+ * This is the first script loaded by webpack's runtime. It is used to manually configure
+ * config.output.publicPath to account for relative_url_root or CDN settings which cannot be
+ * baked-in to our webpack bundles.
+ */
+
+if (gon && gon.webpack_public_path) {
+ __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line camelcase
+}
diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js
deleted file mode 100644
index 4194c1bc08d..00000000000
--- a/app/assets/javascripts/wikis.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/* eslint-disable no-param-reassign */
-/* global Breakpoints */
-
-import 'vendor/jquery.nicescroll';
-import './breakpoints';
-
-((global) => {
- class Wikis {
- constructor() {
- this.bp = Breakpoints.get();
- this.sidebarEl = document.querySelector('.js-wiki-sidebar');
- this.sidebarExpanded = false;
- $(this.sidebarEl).niceScroll();
-
- const sidebarToggles = document.querySelectorAll('.js-sidebar-wiki-toggle');
- for (let i = 0; i < sidebarToggles.length; i += 1) {
- sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e));
- }
-
- this.newWikiForm = document.querySelector('form.new-wiki-page');
- if (this.newWikiForm) {
- this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e));
- }
-
- window.addEventListener('resize', () => this.renderSidebar());
- this.renderSidebar();
- }
-
- handleNewWikiSubmit(e) {
- if (!this.newWikiForm) return;
-
- const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
- const slug = gl.text.slugify(slugInput.value);
-
- if (slug.length > 0) {
- const wikisPath = slugInput.getAttribute('data-wikis-path');
- window.location.href = `${wikisPath}/${slug}`;
- e.preventDefault();
- }
- }
-
- handleToggleSidebar(e) {
- e.preventDefault();
- this.sidebarExpanded = !this.sidebarExpanded;
- this.renderSidebar();
- }
-
- sidebarCanCollapse() {
- const bootstrapBreakpoint = this.bp.getBreakpointSize();
- return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
- }
-
- renderSidebar() {
- if (!this.sidebarEl) return;
- const { classList } = this.sidebarEl;
- if (this.sidebarExpanded || !this.sidebarCanCollapse()) {
- if (!classList.contains('right-sidebar-expanded')) {
- classList.remove('right-sidebar-collapsed');
- classList.add('right-sidebar-expanded');
- }
- } else if (classList.contains('right-sidebar-expanded')) {
- classList.add('right-sidebar-collapsed');
- classList.remove('right-sidebar-expanded');
- }
- }
- }
-
- global.Wikis = Wikis;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index b7fe552dec2..f68a4f28714 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,17 +1,17 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len */
-/* global Mousetrap */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len, class-methods-use-this */
// Zen Mode (full screen) textarea
//
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
+import $ from 'jquery';
import 'vendor/jquery.scrollTo';
import Dropzone from 'dropzone';
-import 'mousetrap';
+import Mousetrap from 'mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
-window.Dropzone = Dropzone;
+Dropzone.autoDiscover = false;
//
// ### Events
@@ -34,65 +34,66 @@ window.Dropzone = Dropzone;
// **Cancelable** No
// **Target** a.js-zen-leave
//
-(function() {
- this.ZenMode = (function() {
- function ZenMode() {
- this.active_backdrop = null;
- this.active_textarea = null;
- $(document).on('click', '.js-zen-enter', function(e) {
- e.preventDefault();
- return $(e.currentTarget).trigger('zen_mode:enter');
- });
- $(document).on('click', '.js-zen-leave', function(e) {
+
+export default class ZenMode {
+ constructor() {
+ this.active_backdrop = null;
+ this.active_textarea = null;
+ $(document).on('click', '.js-zen-enter', function(e) {
+ e.preventDefault();
+ return $(e.currentTarget).trigger('zen_mode:enter');
+ });
+ $(document).on('click', '.js-zen-leave', function(e) {
+ e.preventDefault();
+ return $(e.currentTarget).trigger('zen_mode:leave');
+ });
+ $(document).on('zen_mode:enter', (function(_this) {
+ return function(e) {
+ return _this.enter($(e.target).closest('.md-area').find('.zen-backdrop'));
+ };
+ })(this));
+ $(document).on('zen_mode:leave', (function(_this) {
+ return function(e) {
+ return _this.exit();
+ };
+ })(this));
+ $(document).on('keydown', function(e) {
+ // Esc
+ if (e.keyCode === 27) {
e.preventDefault();
- return $(e.currentTarget).trigger('zen_mode:leave');
- });
- $(document).on('zen_mode:enter', (function(_this) {
- return function(e) {
- return _this.enter($(e.target).closest('.md-area').find('.zen-backdrop'));
- };
- })(this));
- $(document).on('zen_mode:leave', (function(_this) {
- return function(e) {
- return _this.exit();
- };
- })(this));
- $(document).on('keydown', function(e) {
- // Esc
- if (e.keyCode === 27) {
- e.preventDefault();
- return $(document).trigger('zen_mode:leave');
- }
- });
- }
+ return $(document).trigger('zen_mode:leave');
+ }
+ });
+ }
- ZenMode.prototype.enter = function(backdrop) {
- Mousetrap.pause();
- this.active_backdrop = $(backdrop);
- this.active_backdrop.addClass('fullscreen');
- this.active_textarea = this.active_backdrop.find('textarea');
- // Prevent a user-resized textarea from persisting to fullscreen
- this.active_textarea.removeAttr('style');
- return this.active_textarea.focus();
- };
+ enter(backdrop) {
+ Mousetrap.pause();
+ this.active_backdrop = $(backdrop);
+ this.active_backdrop.addClass('fullscreen');
+ this.active_textarea = this.active_backdrop.find('textarea');
+ // Prevent a user-resized textarea from persisting to fullscreen
+ this.active_textarea.removeAttr('style');
+ this.active_textarea.focus();
+ }
- ZenMode.prototype.exit = function() {
- if (this.active_textarea) {
- Mousetrap.unpause();
- this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen');
- this.scrollTo(this.active_textarea);
- this.active_textarea = null;
- this.active_backdrop = null;
- return Dropzone.forElement('.div-dropzone').enable();
- }
- };
+ exit() {
+ if (this.active_textarea) {
+ Mousetrap.unpause();
+ this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen');
+ this.scrollTo(this.active_textarea);
+ this.active_textarea = null;
+ this.active_backdrop = null;
- ZenMode.prototype.scrollTo = function(zen_area) {
- return $.scrollTo(zen_area, 0, {
- offset: -150
- });
- };
+ const $dropzone = $('.div-dropzone');
+ if ($dropzone && !$dropzone.hasClass('js-invalid-dropzone')) {
+ Dropzone.forElement('.div-dropzone').enable();
+ }
+ }
+ }
- return ZenMode;
- })();
-}).call(window);
+ scrollTo(zen_area) {
+ return $.scrollTo(zen_area, 0, {
+ offset: -150
+ });
+ }
+}
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 83a8eeaafde..0665622fe4a 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -42,4 +42,4 @@
/*
* Styles for JS behaviors.
*/
-@import "behaviors.scss";
+@import "behaviors";
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 9dc9f9a9068..2fccfa4011c 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -4,49 +4,61 @@
@import 'framework/tw_bootstrap';
@import "framework/layout";
-@import "framework/animations.scss";
-@import "framework/avatar.scss";
-@import "framework/asciidoctor.scss";
-@import "framework/blocks.scss";
-@import "framework/buttons.scss";
-@import "framework/badges.scss";
-@import "framework/calendar.scss";
-@import "framework/callout.scss";
-@import "framework/common.scss";
-@import "framework/dropdowns.scss";
-@import "framework/files.scss";
-@import "framework/filters.scss";
-@import "framework/flash.scss";
-@import "framework/forms.scss";
-@import "framework/gfm.scss";
-@import "framework/header.scss";
-@import "framework/highlight.scss";
-@import "framework/issue_box.scss";
-@import "framework/jquery.scss";
-@import "framework/lists.scss";
-@import "framework/logo.scss";
-@import "framework/markdown_area.scss";
-@import "framework/mobile.scss";
-@import "framework/modal.scss";
-@import "framework/nav.scss";
-@import "framework/pagination.scss";
-@import "framework/panels.scss";
-@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";
+@import "framework/animations";
+@import "framework/vue_transitions";
+@import "framework/avatar";
+@import "framework/asciidoctor";
+@import "framework/banner";
+@import "framework/blocks";
+@import "framework/buttons";
+@import "framework/badges";
+@import "framework/calendar";
+@import "framework/callout";
+@import "framework/common";
+@import "framework/dropdowns";
+@import "framework/files";
+@import "framework/filters";
+@import "framework/flash";
+@import "framework/forms";
+@import "framework/gfm";
+@import "framework/gitlab_theme";
+@import "framework/header";
+@import "framework/highlight";
+@import "framework/issue_box";
+@import "framework/jquery";
+@import "framework/lists";
+@import "framework/logo";
+@import "framework/markdown_area";
+@import "framework/media_object";
+@import "framework/mobile";
+@import "framework/modal";
+@import "framework/pagination";
+@import "framework/panels";
+@import "framework/popup";
+@import "framework/secondary_navigation_elements";
+@import "framework/selects";
+@import "framework/sidebar";
+@import "framework/contextual_sidebar";
+@import "framework/tables";
+@import "framework/notes";
+@import "framework/tabs";
+@import "framework/timeline";
+@import "framework/tooltips";
+@import "framework/toggle";
+@import "framework/typography";
+@import "framework/zen";
@import "framework/blank";
-@import "framework/wells.scss";
-@import "framework/page-header.scss";
-@import "framework/awards.scss";
-@import "framework/images.scss";
-@import "framework/broadcast-messages";
-@import "framework/emojis.scss";
-@import "framework/emoji-sprites.scss";
-@import "framework/icons.scss";
-@import "framework/snippets.scss";
-@import "framework/memory_graph.scss";
-@import "framework/responsive-tables.scss";
+@import "framework/wells";
+@import "framework/page_header";
+@import "framework/awards";
+@import "framework/images";
+@import "framework/broadcast_messages";
+@import "framework/emojis";
+@import "framework/emoji_sprites";
+@import "framework/icons";
+@import "framework/snippets";
+@import "framework/memory_graph";
+@import "framework/responsive_tables";
+@import "framework/stacked_progress_bar";
+@import "framework/ci_variable_list";
+@import "framework/feature_highlight";
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 3cd7f81da47..728f9a27aca 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -115,8 +115,7 @@
@return $unfoldedTransition;
}
-.btn,
-.global-dropdown-toggle {
+.btn {
@include transition(background-color, border-color, color, box-shadow);
}
@@ -126,7 +125,7 @@
@include transition(border-color);
}
-.note-action-button .link-highlight,
+.note-action-button,
.toolbar-btn,
.dropdown-toggle-caret {
@include transition(color);
@@ -187,3 +186,88 @@ a {
.fade-in-full {
animation: fadeInFull $fade-in-duration 1;
}
+
+
+.animation-container {
+ background: $repo-editor-grey;
+ height: 40px;
+ overflow: hidden;
+ position: relative;
+
+ &.animation-container-small {
+ height: 12px;
+ }
+
+ &.animation-container-right {
+ .skeleton-line-2 {
+ left: 0;
+ right: 150px;
+ }
+ }
+
+ &::before {
+ animation-duration: 1s;
+ animation-fill-mode: forwards;
+ animation-iteration-count: infinite;
+ animation-name: blockTextShine;
+ animation-timing-function: linear;
+ background-image: $repo-editor-linear-gradient;
+ background-repeat: no-repeat;
+ background-size: 800px 45px;
+ content: ' ';
+ display: block;
+ height: 100%;
+ position: relative;
+ }
+
+ div {
+ background: $white-light;
+ height: 6px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ }
+
+ .skeleton-line-1 {
+ left: 0;
+ top: 8px;
+ }
+
+ .skeleton-line-2 {
+ left: 150px;
+ top: 0;
+ height: 10px;
+ }
+
+ .skeleton-line-3 {
+ left: 0;
+ top: 23px;
+ }
+
+ .skeleton-line-4 {
+ left: 0;
+ top: 38px;
+ }
+
+ .skeleton-line-5 {
+ left: 200px;
+ top: 28px;
+ height: 10px;
+ }
+
+ .skeleton-line-6 {
+ top: 14px;
+ left: 230px;
+ height: 10px;
+ }
+}
+
+@keyframes blockTextShine {
+ 0% {
+ transform: translateX(-468px);
+ }
+
+ 100% {
+ transform: translateX(468px);
+ }
+}
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 4ae2b164d2e..077d0424093 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -23,6 +23,7 @@
&.s60 { @include avatar-size(60px, 12px); }
&.s70 { @include avatar-size(70px, 14px); }
&.s90 { @include avatar-size(90px, 15px); }
+ &.s100 { @include avatar-size(100px, 15px); }
&.s110 { @include avatar-size(110px, 15px); }
&.s140 { @include avatar-size(140px, 15px); }
&.s160 { @include avatar-size(160px, 20px); }
@@ -35,12 +36,13 @@
width: 40px;
height: 40px;
padding: 0;
+ background: $avatar-background;
+ overflow: hidden;
&.avatar-inline {
float: none;
display: inline-block;
- margin-left: 4px;
- margin-bottom: 2px;
+ margin-left: 2px;
flex-shrink: 0;
-webkit-flex-shrink: 0;
@@ -56,11 +58,11 @@
&.avatar-tile {
border-radius: 0;
- border: none;
+ border: 0;
}
&:not([href]):hover {
- border-color: rgba($avatar-border, .2);
+ border-color: darken($avatar-border, 10%);
}
}
@@ -69,14 +71,15 @@
vertical-align: top;
&.s16 { font-size: 12px; line-height: 1.33; }
- &.s24 { font-size: 14px; line-height: 1.8; }
+ &.s24 { font-size: 13px; line-height: 1.8; }
&.s26 { font-size: 20px; line-height: 1.33; }
&.s32 { font-size: 20px; line-height: 30px; }
&.s40 { font-size: 16px; line-height: 38px; }
&.s60 { font-size: 32px; line-height: 58px; }
&.s70 { font-size: 34px; line-height: 70px; }
&.s90 { font-size: 36px; line-height: 88px; }
- &.s110 { font-size: 40px; line-height: 108px; font-weight: 300; }
+ &.s100 { font-size: 36px; line-height: 98px; }
+ &.s110 { font-size: 40px; line-height: 108px; font-weight: $gl-font-weight-normal; }
&.s140 { font-size: 72px; line-height: 138px; }
&.s160 { font-size: 96px; line-height: 158px; }
}
@@ -86,20 +89,26 @@
overflow: hidden;
display: flex;
+ a {
+ display: flex;
+ }
+
.avatar {
border-radius: 0;
- border: none;
+ border: 0;
height: auto;
width: 100%;
margin: 0;
align-self: center;
}
+
+ &.s40 { min-width: 40px; min-height: 40px; }
}
.avatar-counter {
background-color: $gray-darkest;
color: $white-light;
- border: 1px solid $border-color;
+ border: 1px solid $avatar-border;
border-radius: 1em;
font-family: $regular_font;
font-size: 9px;
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 19166757e64..a538b5a2946 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -9,6 +9,7 @@
}
.emoji-menu {
+ display: none;
position: absolute;
top: 0;
margin-top: 3px;
@@ -24,9 +25,13 @@
opacity: 0;
transform: scale(.2);
transform-origin: 0 -45px;
- transition: .3s cubic-bezier(.67,.06,.19,1.44);
+ transition: .3s cubic-bezier(.67, .06, .19, 1.44);
transition-property: transform, opacity;
+ &.is-rendered {
+ display: block;
+ }
+
&.is-aligned-right {
transform-origin: 100% -45px;
}
@@ -169,12 +174,13 @@
&.user-authored {
cursor: default;
- opacity: 0.65;
+ background-color: $gray-light;
+ border-color: $theme-gray-200;
+ color: $gl-text-color-disabled;
- &:hover,
- &:active {
- background-color: $white-light;
- border-color: $border-color;
+ gl-emoji {
+ opacity: 0.4;
+ filter: grayscale(100%);
}
}
@@ -231,11 +237,11 @@
.award-control-icon-positive,
.award-control-icon-super-positive {
+ @include transition(opacity, transform);
position: absolute;
left: 10px;
bottom: 6px;
opacity: 0;
- @include transition(opacity, transform);
}
.award-control-text {
diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss
index 47a8f44c709..6bbe32df772 100644
--- a/app/assets/stylesheets/framework/badges.scss
+++ b/app/assets/stylesheets/framework/badges.scss
@@ -1,5 +1,5 @@
.badge {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
background-color: $badge-bg;
color: $badge-color;
vertical-align: baseline;
diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss
new file mode 100644
index 00000000000..6433b0c7855
--- /dev/null
+++ b/app/assets/stylesheets/framework/banner.scss
@@ -0,0 +1,25 @@
+.banner-callout {
+ display: flex;
+ position: relative;
+ flex-wrap: wrap;
+
+ .banner-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ opacity: 1;
+
+ .dismiss-icon {
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ }
+ }
+
+ .banner-graphic {
+ margin: 20px auto;
+ }
+
+ &.banner-non-empty-state {
+ border-bottom: 1px solid $border-color;
+ }
+}
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
index a2fa2e7769b..9982a5779af 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -1,51 +1,101 @@
+.blank-state-parent-container {
+ .section-container {
+ padding: 10px;
+ }
+
+ .section-body {
+ width: 100%;
+ height: 100%;
+ padding-bottom: 25px;
+ border-radius: $border-radius-default;
+ }
+}
+
+.blank-state-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-around;
+ height: 100%;
+}
+
.blank-state-welcome {
text-align: center;
- border-bottom: 1px solid $border-color;
+ padding: 20px 0 40px;
+
+ .blank-state-welcome-title {
+ font-size: 24px;
+ }
.blank-state-text {
margin-bottom: 0;
}
}
-.blank-state {
- padding-top: 20px;
- padding-bottom: 20px;
- text-align: center;
-}
+.blank-state-link {
+ display: block;
+ color: $gl-text-color;
+ flex: 0 0 100%;
+ margin-bottom: 15px;
-.blank-state-no-icon {
- padding-top: 40px;
- padding-bottom: 40px;
-}
+ @media (min-width: $screen-sm-min) {
+ flex: 0 0 49%;
-.blank-state-icon {
- padding-bottom: 20px;
- color: $gray-darkest;
- font-size: 56px;
+ &:nth-child(odd) {
+ margin-right: 5px;
+ }
+
+ &:nth-child(even) {
+ margin-left: 5px;
+ }
+ }
- path,
- polygon {
- fill: currentColor;
+ &:hover {
+ background-color: $gray-light;
+ text-decoration: none;
+ color: $gl-text-color;
}
}
-.blank-state-title {
- margin-top: 0;
- margin-bottom: 5px;
- font-size: 18px;
- font-weight: normal;
+.blank-state-center {
+ padding-top: 20px;
+ padding-bottom: 20px;
+ text-align: center;
}
-.blank-state-text {
- margin-top: 0;
- margin-bottom: $gl-padding;
- font-size: 14px;
+.blank-state {
+ padding: 20px;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
- > strong {
- font-weight: 600;
+ @media (min-width: $screen-sm-min) {
+ display: flex;
+ align-items: center;
+ padding: 50px 30px;
}
}
-.blank-state-welcome-title {
- font-size: 24px;
+.blank-state,
+.blank-state-center {
+ .blank-state-icon {
+ svg {
+ display: block;
+ margin: auto;
+ }
+ }
+
+ .blank-state-title {
+ margin-top: 0;
+ font-size: 18px;
+ }
+
+ .blank-state-body {
+ @media (max-width: $screen-xs-max) {
+ text-align: center;
+ margin-top: 20px;
+ }
+
+ @media (min-width: $screen-sm-min) {
+ padding-left: 20px;
+ }
+ }
}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 95a08c960ea..c5c7afe25be 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -8,9 +8,8 @@
text-align: center;
padding: 20px;
color: $gl-text-color;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
font-size: 14px;
- line-height: 36px;
&.diff-collapsed {
padding: 5px;
@@ -39,7 +38,11 @@
}
&.top-block {
- border-top: none;
+ border-top: 0;
+
+ .container-fluid {
+ background-color: inherit;
+ }
}
&.middle-block {
@@ -59,7 +62,7 @@
&.footer-block {
margin-top: 0;
- border-bottom: none;
+ border-bottom: 0;
margin-bottom: -$gl-padding;
}
@@ -96,11 +99,7 @@
&.build-content {
background-color: $white-light;
- border-top: none;
- }
-
- &.top-block .container-fluid {
- background-color: inherit;
+ border-top: 0;
}
}
@@ -207,13 +206,30 @@
&.user-cover-block {
padding: 24px 0 0;
+
+ .nav-links {
+ width: 100%;
+ float: none;
+
+ &.scrolling-tabs {
+ float: none;
+ }
+ }
+
+ li:first-child {
+ margin-left: auto;
+ }
+
+ li:last-child {
+ margin-right: auto;
+ }
}
.group-info {
h1 {
display: inline;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
font-size: 24px;
color: $gl-text-color;
}
@@ -260,7 +276,7 @@
position: relative;
border: 1px solid $blue-300;
border-radius: $border-radius-default;
- background-color: $blue-25;
+ background-color: $blue-50;
justify-content: center;
.dismiss-button {
@@ -270,12 +286,12 @@
cursor: pointer;
color: $blue-300;
z-index: 1;
- border: none;
+ border: 0;
background-color: transparent;
&:hover,
&:focus {
- border: none;
+ border: 0;
color: $blue-400;
}
}
@@ -319,16 +335,6 @@
padding: $gl-padding;
}
- .svg-content {
- text-align: center;
-
- svg {
- max-width: 425px;
- width: 100%;
- padding: $gl-padding;
- }
- }
-
.emoji-icon {
display: inline-block;
}
@@ -346,3 +352,7 @@
display: -webkit-flex;
display: flex;
}
+
+.flex-right {
+ margin-left: auto;
+}
diff --git a/app/assets/stylesheets/framework/broadcast-messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index 9b54fb94cdc..9b54fb94cdc 100644
--- a/app/assets/stylesheets/framework/broadcast-messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 4369ae78bde..6b89387ab5f 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -1,7 +1,29 @@
+@mixin btn-comment-icon {
+ border-radius: 50%;
+ background: $white-light;
+ padding: 1px 5px;
+ font-size: 12px;
+ color: $blue-500;
+ width: 24px;
+ height: 24px;
+ border: 1px solid $blue-500;
+
+ &:hover,
+ &.inverted {
+ background: $blue-500;
+ border-color: $blue-600;
+ color: $white-light;
+ }
+
+ &:active {
+ outline: 0;
+ }
+}
+
@mixin btn-default {
border-radius: 3px;
font-size: $gl-font-size;
- font-weight: 400;
+ font-weight: $gl-font-weight-normal;
padding: $gl-vert-padding $gl-btn-padding;
&:focus,
@@ -20,17 +42,29 @@
color: $text;
border-color: $border;
+ > .icon {
+ color: $text;
+ }
+
&:hover,
&:focus {
background-color: $hover-background;
border-color: $hover-border;
color: $hover-text;
+
+ > .icon {
+ color: $hover-text;
+ }
}
&:active {
background-color: $active-background;
border-color: $active-border;
color: $hover-text;
+
+ > .icon {
+ color: $hover-text;
+ }
}
}
@@ -54,17 +88,6 @@
border-color: $border-dark;
color: $color;
}
-
- svg {
-
- path {
- fill: $color;
- }
-
- use {
- stroke: $color;
- }
- }
}
@mixin btn-green {
@@ -108,6 +131,13 @@
}
}
+@mixin btn-svg {
+ height: $gl-padding;
+ width: $gl-padding;
+ top: 0;
+ vertical-align: text-top;
+}
+
.btn {
@include btn-default;
@include btn-white;
@@ -118,7 +148,6 @@
outline: 0;
}
- &.btn-small,
&.btn-sm {
padding: 4px 10px;
font-size: 13px;
@@ -147,6 +176,11 @@
&.btn-remove {
@include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
}
+
+ &.btn-primary,
+ &.btn-info {
+ @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
+ }
}
&.btn-gray {
@@ -163,7 +197,8 @@
@include btn-orange;
}
- &.btn-close {
+ &.btn-close,
+ &.btn-close-color {
@include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
}
@@ -181,7 +216,8 @@
float: right;
}
- &.btn-reopen {
+ &.btn-reopen,
+ .btn-reopen-color {
/* should be same as parent class for now */
}
@@ -189,14 +225,6 @@
@include btn-with-margin;
}
- &.disabled {
- pointer-events: auto !important;
- }
-
- &[disabled] {
- pointer-events: none !important;
- }
-
.fa-caret-down,
.fa-chevron-down {
margin-left: 5px;
@@ -208,6 +236,16 @@
}
}
+ &.dot-highlight::after {
+ content: '';
+ background-color: $blue-500;
+ width: $gl-padding * 0.5;
+ height: $gl-padding * 0.5;
+ display: inline-block;
+ border-radius: 50%;
+ margin-left: 3px;
+ }
+
svg {
height: 15px;
width: 15px;
@@ -257,6 +295,12 @@
}
}
+.btn-align-content {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
.btn-group {
&.btn-grouped {
@include btn-with-margin;
@@ -264,7 +308,7 @@
}
.btn-clipboard {
- border: none;
+ border: 0;
padding: 0 5px;
}
@@ -367,8 +411,77 @@
padding: 0;
background: transparent;
border: 0;
+ border-radius: 0;
+ &:hover,
+ &:active,
&:focus {
outline: 0;
+ background: transparent;
+ box-shadow: none;
+ }
+}
+
+.btn-link.btn-secondary-hover-link {
+ color: $gl-text-color-secondary;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $gl-link-color;
+ text-decoration: none;
+ }
+}
+
+.btn-link.btn-primary-hover-link {
+ color: inherit;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $gl-link-color;
+ text-decoration: none;
+ }
+}
+
+.btn-missing {
+ color: $notes-light-color;
+ border: 1px dashed $border-gray-normal-dashed;
+ border-radius: $border-radius-default;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $notes-light-color;
+ background-color: $white-normal;
+ }
+}
+
+.btn-svg svg {
+ @include btn-svg;
+}
+
+// All disabled buttons, regardless of color, type, etc
+%disabled {
+ background-color: $gray-light !important;
+ border-color: $theme-gray-200 !important;
+ color: $gl-text-color-disabled !important;
+ opacity: 1 !important;
+ cursor: default !important;
+
+ i {
+ color: $gl-text-color-disabled !important;
+ }
+}
+
+.btn.disabled,
+.btn[disabled],
+fieldset[disabled] .btn,
+.dropdown-toggle[disabled],
+[disabled].dropdown-menu-toggle {
+ @extend %disabled;
+
+ &:hover {
+ @extend %disabled;
}
}
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 759401a7806..c165ec0b94b 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -45,19 +45,20 @@
margin-top: -23px;
float: right;
font-size: 12px;
+ direction: ltr;
}
.pika-single.gitlab-theme {
.pika-label {
color: $gl-text-color-secondary;
font-size: 14px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
th {
padding: 2px 0;
color: $note-disabled-comment-color;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
text-transform: lowercase;
border-top: 1px solid $calendar-border-color;
}
@@ -87,15 +88,15 @@
.is-today {
.pika-day {
color: inherit;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
}
.is-selected .pika-day,
.pika-day:hover,
- .is-today .pika-day:hover {
- background: $gl-primary;
- color: $white-light;
+ .is-today .pika-day {
+ background: $gray-darker;
+ color: $gl-text-color;
box-shadow: none;
}
}
diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss
index e0e46dd73af..1bd94c0acba 100644
--- a/app/assets/stylesheets/framework/callout.scss
+++ b/app/assets/stylesheets/framework/callout.scss
@@ -12,15 +12,15 @@
border-left: 3px solid $border-color;
color: $text-color;
background: $gray-light;
-}
-.bs-callout h4 {
- margin-top: 0;
- margin-bottom: 5px;
-}
+ h4 {
+ margin-top: 0;
+ margin-bottom: 5px;
+ }
-.bs-callout p:last-child {
- margin-bottom: 0;
+ p:last-child {
+ margin-bottom: 0;
+ }
}
/* Variations */
diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss
new file mode 100644
index 00000000000..5fe835dd8f9
--- /dev/null
+++ b/app/assets/stylesheets/framework/ci_variable_list.scss
@@ -0,0 +1,99 @@
+.ci-variable-list {
+ margin-left: 0;
+ margin-bottom: 0;
+ padding-left: 0;
+ list-style: none;
+ clear: both;
+}
+
+.ci-variable-row {
+ display: flex;
+ align-items: flex-start;
+
+ @media (max-width: $screen-xs-max) {
+ align-items: flex-end;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: $gl-btn-padding;
+
+ @media (max-width: $screen-xs-max) {
+ margin-bottom: 3 * $gl-btn-padding;
+ }
+ }
+
+ &:last-child {
+ .ci-variable-body-item:last-child {
+ margin-right: $ci-variable-remove-button-width;
+
+ @media (max-width: $screen-xs-max) {
+ margin-right: 0;
+ }
+ }
+
+ .ci-variable-row-remove-button {
+ display: none;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .ci-variable-row-body {
+ margin-right: $ci-variable-remove-button-width;
+ }
+ }
+ }
+}
+
+.ci-variable-row-body {
+ display: flex;
+ align-items: flex-start;
+ width: 100%;
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+ }
+}
+
+.ci-variable-body-item {
+ flex: 1;
+
+ &:not(:last-child) {
+ margin-right: $gl-btn-padding;
+
+ @media (max-width: $screen-xs-max) {
+ margin-right: 0;
+ margin-bottom: $gl-btn-padding;
+ }
+ }
+}
+
+.ci-variable-protected-item {
+ flex: 0 1 auto;
+ display: flex;
+ align-items: center;
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+.ci-variable-row-remove-button {
+ @include transition(color);
+ flex-shrink: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: $ci-variable-remove-button-width;
+ height: $input-height;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ color: $gl-text-color-secondary;
+
+ &:hover,
+ &:focus {
+ outline: none;
+ color: $gl-text-color;
+ }
+
+ &[disabled] {
+ color: $gl-text-color-disabled;
+ }
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 00c981f64c5..d0dda50a835 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -5,30 +5,44 @@
.cgreen { color: $common-green; }
.cdark { color: $common-gray-dark; }
-/** COMMON CLASSES **/
-.prepend-top-0 { margin-top: 0; }
-.prepend-top-5 { margin-top: 5px; }
-.prepend-top-10 { margin-top: 10px; }
-.prepend-top-default { margin-top: $gl-padding !important; }
-.prepend-top-20 { margin-top: 20px; }
-.prepend-left-5 { margin-left: 5px; }
-.prepend-left-10 { margin-left: 10px; }
-.prepend-left-default { margin-left: $gl-padding; }
-.prepend-left-20 { margin-left: 20px; }
-.append-right-5 { margin-right: 5px; }
-.append-right-10 { margin-right: 10px; }
-.append-right-default { margin-right: $gl-padding; }
-.append-right-20 { margin-right: 20px; }
-.append-bottom-0 { margin-bottom: 0; }
-.append-bottom-10 { margin-bottom: 10px; }
-.append-bottom-15 { margin-bottom: 15px; }
-.append-bottom-20 { margin-bottom: 20px; }
-.append-bottom-default { margin-bottom: $gl-padding; }
-.inline { display: inline-block; }
-.center { text-align: center; }
-.vertical-align-middle { vertical-align: middle; }
+.text-plain,
+.text-plain:hover {
+ color: $gl-text-color;
+}
+
+.text-secondary {
+ color: $gl-text-color-secondary;
+}
+
+.text-tertiary {
+ color: $gl-text-color-tertiary;
+}
+
+.text-primary,
+.text-primary:hover {
+ color: $brand-primary;
+}
+
+.text-success,
+.text-success:hover {
+ color: $brand-success;
+}
+
+.text-danger,
+.text-danger:hover {
+ color: $brand-danger;
+}
+
+.text-warning,
+.text-warning:hover {
+ color: $brand-warning;
+}
+
+.text-info,
+.text-info:hover {
+ color: $brand-info;
+}
-.underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; }
.light { color: $common-gray; }
@@ -36,12 +50,12 @@
color: $common-gray;
font-size: 14px;
margin-bottom: 12px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
line-height: 24px;
}
.bold {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.tab-content {
@@ -51,7 +65,7 @@
pre {
&.clean {
background: none;
- border: none;
+ border: 0;
margin: 0;
padding: 0;
}
@@ -76,6 +90,14 @@ hr {
.str-truncated {
@include str-truncated;
+
+ &-60 {
+ @include str-truncated(60%);
+ }
+
+ &-100 {
+ @include str-truncated(100%);
+ }
}
.block-truncated {
@@ -89,7 +111,7 @@ hr {
}
}
-.item-title { font-weight: 600; }
+.item-title { font-weight: $gl-font-weight-bold; }
/** FLASH message **/
.author_link,
@@ -101,10 +123,17 @@ hr {
font-size: 14px;
}
-table a code {
- position: relative;
- top: -2px;
- margin-right: 3px;
+table {
+ a code {
+ position: relative;
+ top: -2px;
+ margin-right: 3px;
+ }
+
+ td.permission-x {
+ background: $table-permission-x-bg !important;
+ text-align: center;
+ }
}
.loading {
@@ -118,20 +147,15 @@ table a code {
span.update-author {
display: block;
color: $update-author-color;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
font-style: italic;
strong {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
font-style: normal;
}
}
-.user-mention {
- color: $user-mention-color;
- font-weight: bold;
-}
-
.field_with_errors {
display: inline;
}
@@ -155,7 +179,7 @@ li.note {
img { max-width: 100%; }
.note-title {
li {
- border-bottom: none !important;
+ border-bottom: 0 !important;
}
}
}
@@ -200,7 +224,7 @@ li.note {
pre {
background: $white-light;
- border: none;
+ border: 0;
font-size: 12px;
}
}
@@ -217,19 +241,6 @@ li.note {
}
}
-.browser-alert {
- padding: 10px;
- text-align: center;
- background: $error-bg;
- color: $white-light;
- font-weight: bold;
-
- a {
- color: $white-light;
- text-decoration: underline;
- }
-}
-
.warning_message {
border-left: 4px solid $warning-message-border;
color: $warning-message-color;
@@ -294,13 +305,6 @@ img.emoji {
margin-bottom: 10px;
}
-table {
- td.permission-x {
- background: $table-permission-x-bg !important;
- text-align: center;
- }
-}
-
.btn-sign-in {
text-shadow: none;
@@ -336,15 +340,10 @@ table {
text-align: center;
}
-#nprogress .spinner {
- top: 15px !important;
- right: 10px !important;
-}
-
.header-with-avatar {
h3 {
margin: 0;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
.username {
@@ -371,10 +370,15 @@ table {
.dropzone .dz-preview .dz-progress {
border-color: $border-color !important;
+
+ .dz-upload {
+ background: $gl-success !important;
+ }
+
}
-.dropzone .dz-preview .dz-progress .dz-upload {
- background: $gl-success !important;
+.dz-message {
+ margin: 0;
}
.space-right {
@@ -406,16 +410,17 @@ table {
}
.hide-bottom-border {
- border-bottom: none !important;
+ border-bottom: 0 !important;
}
.gl-accessibility {
&:focus {
+ display: flex;
+ align-items: center;
top: 1px;
left: 1px;
width: auto;
height: 100%;
- line-height: 50px;
padding: 0 10px;
clip: auto;
text-decoration: none;
@@ -430,16 +435,6 @@ table {
border-radius: $border-radius-default;
}
-.str-truncated {
- &-60 {
- @include str-truncated(60%);
- }
-
- &-100 {
- @include str-truncated(100%);
- }
-}
-
.tooltip {
.tooltip-inner {
word-wrap: break-word;
@@ -451,3 +446,36 @@ table {
opacity: .5;
}
+.break-word {
+ word-wrap: break-word;
+}
+
+/** COMMON CLASSES **/
+.prepend-top-0 { margin-top: 0; }
+.prepend-top-5 { margin-top: 5px; }
+.prepend-top-8 { margin-top: $grid-size; }
+.prepend-top-10 { margin-top: 10px; }
+.prepend-top-15 { margin-top: 15px; }
+.prepend-top-default { margin-top: $gl-padding !important; }
+.prepend-top-16 { margin-top: 16px; }
+.prepend-top-20 { margin-top: 20px; }
+.prepend-left-4 { margin-left: 4px; }
+.prepend-left-5 { margin-left: 5px; }
+.prepend-left-8 { margin-left: 8px; }
+.prepend-left-10 { margin-left: 10px; }
+.prepend-left-default { margin-left: $gl-padding; }
+.prepend-left-20 { margin-left: 20px; }
+.append-right-5 { margin-right: 5px; }
+.append-right-8 { margin-right: 8px; }
+.append-right-10 { margin-right: 10px; }
+.append-right-default { margin-right: $gl-padding; }
+.append-right-20 { margin-right: 20px; }
+.append-bottom-0 { margin-bottom: 0; }
+.append-bottom-5 { margin-bottom: 5px; }
+.append-bottom-10 { margin-bottom: 10px; }
+.append-bottom-15 { margin-bottom: 15px; }
+.append-bottom-20 { margin-bottom: 20px; }
+.append-bottom-default { margin-bottom: $gl-padding; }
+.inline { display: inline-block; }
+.center { text-align: center; }
+.vertical-align-middle { vertical-align: middle; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
new file mode 100644
index 00000000000..e2d97d0298f
--- /dev/null
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -0,0 +1,457 @@
+.page-with-contextual-sidebar {
+ transition: padding-left $sidebar-transition-duration;
+
+ @media (min-width: $screen-md-min) {
+ padding-left: $contextual-sidebar-collapsed-width;
+ }
+
+ @media (min-width: $screen-lg-min) {
+ padding-left: $contextual-sidebar-width;
+ }
+
+ .issues-bulk-update.right-sidebar.right-sidebar-expanded
+ .issuable-sidebar-header {
+ padding: 10px 0 15px;
+ }
+}
+
+.page-with-icon-sidebar {
+ @media (min-width: $screen-sm-min) {
+ padding-left: $contextual-sidebar-collapsed-width;
+ }
+}
+
+.context-header {
+ position: relative;
+ margin-right: 2px;
+ width: $contextual-sidebar-width;
+
+ a {
+ transition: padding $sidebar-transition-duration;
+ font-weight: $gl-font-weight-bold;
+ display: flex;
+ align-items: center;
+ padding: 10px 16px 10px 10px;
+ color: $gl-text-color;
+ }
+
+ &:hover,
+ a:hover {
+ background-color: $link-hover-background;
+ color: $gl-text-color;
+ }
+
+ .avatar-container {
+ flex: 0 0 40px;
+ background-color: $white-light;
+ }
+
+ .sidebar-context-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.settings-avatar {
+ background-color: $white-light;
+
+ svg {
+ fill: $gl-text-color-secondary;
+ margin: auto;
+ }
+}
+
+.nav-sidebar {
+ transition: width $sidebar-transition-duration,
+ left $sidebar-transition-duration;
+ position: fixed;
+ z-index: 400;
+ width: $contextual-sidebar-width;
+ top: $header-height;
+ bottom: 0;
+ left: 0;
+ background-color: $gray-light;
+ box-shadow: inset -2px 0 0 $border-color;
+ transform: translate3d(0, 0, 0);
+
+ &:not(.sidebar-collapsed-desktop) {
+ @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) {
+ box-shadow: inset -2px 0 0 $border-color,
+ 2px 1px 3px $dropdown-shadow-color;
+ }
+ }
+
+ &.sidebar-collapsed-desktop {
+ width: $contextual-sidebar-collapsed-width;
+
+ .nav-sidebar-inner-scroll {
+ overflow-x: hidden;
+ }
+
+ .badge:not(.fly-out-badge),
+ .sidebar-context-title,
+ .nav-item-name {
+ display: none;
+ }
+
+ .sidebar-top-level-items > li > a {
+ min-height: 44px;
+ }
+
+ .fly-out-top-item {
+ display: block;
+ }
+
+ .avatar-container {
+ margin-right: 0;
+ }
+ }
+
+ &.sidebar-expanded-mobile {
+ left: 0;
+ }
+
+ a {
+ text-decoration: none;
+ }
+
+ ul {
+ padding-left: 0;
+ list-style: none;
+ }
+
+ li {
+ white-space: nowrap;
+
+ a {
+ transition: padding $sidebar-transition-duration;
+ display: flex;
+ align-items: center;
+ padding: 12px 15px;
+ color: $gl-text-color-secondary;
+ }
+
+ .nav-item-name {
+ flex: 1;
+ }
+
+ &.active {
+ > a {
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ left: (-$contextual-sidebar-width);
+ }
+
+ .nav-icon-container {
+ display: flex;
+ margin-right: 8px;
+ }
+
+ .fly-out-top-item {
+ display: none;
+ }
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.nav-sidebar-inner-scroll {
+ height: 100%;
+ width: 100%;
+ overflow: auto;
+
+ @media (min-width: $screen-sm-min) {
+ overflow: hidden;
+ }
+}
+
+.with-performance-bar .nav-sidebar {
+ top: $header-height + $performance-bar-height;
+}
+
+.sidebar-sub-level-items {
+ display: none;
+ padding-bottom: 8px;
+
+ > li {
+ a {
+ padding: 8px 16px 8px 40px;
+
+ &:hover,
+ &:focus {
+ background: $link-active-background;
+ color: $gl-text-color;
+ }
+ }
+
+ &.active {
+ a {
+ &,
+ &:hover,
+ &:focus {
+ background: $link-active-background;
+ }
+ }
+ }
+ }
+}
+
+.sidebar-top-level-items {
+ margin-bottom: 60px;
+
+ > li {
+ > a {
+ @media (min-width: $screen-sm-min) {
+ margin-right: 2px;
+ }
+
+ &:hover {
+ color: $gl-text-color;
+ }
+ }
+
+ &.is-showing-fly-out {
+ > a {
+ margin-right: 2px;
+ }
+
+ .sidebar-sub-level-items {
+ @media (min-width: $screen-sm-min) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ min-width: 150px;
+ margin-top: -1px;
+ padding: 4px 1px;
+ background-color: $white-light;
+ box-shadow: 2px 1px 3px $dropdown-shadow-color;
+ border: 1px solid $gray-darker;
+ border-left: 0;
+ border-radius: 0 3px 3px 0;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: -30px;
+ bottom: -30px;
+ left: -10px;
+ right: -30px;
+ z-index: -1;
+ }
+
+ &.is-above {
+ margin-top: 1px;
+ }
+
+ .divider {
+ height: 1px;
+ margin: 4px -1px;
+ padding: 0;
+ background-color: $dropdown-divider-color;
+ }
+
+ > .active {
+ box-shadow: none;
+
+ > a {
+ background-color: transparent;
+ }
+ }
+
+ a {
+ padding: 8px 16px;
+ color: $gl-text-color;
+
+ &:hover,
+ &:focus {
+ background-color: $gray-darker;
+ }
+ }
+ }
+ }
+ }
+
+ .badge {
+ background-color: $inactive-badge-background;
+ color: $gl-text-color-secondary;
+ }
+
+ &.active {
+ background: $link-active-background;
+
+ > a {
+ margin-left: 4px;
+ // Subtract width of left border on active element
+ padding-left: 11px;
+ }
+
+ .badge {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .sidebar-sub-level-items:not(.is-fly-out-only) {
+ display: block;
+ }
+ }
+
+ &.active > a:hover,
+ &.is-over > a {
+ background-color: $link-hover-background;
+ }
+ }
+}
+
+// Collapsed nav
+
+.toggle-sidebar-button,
+.close-nav-button {
+ width: $contextual-sidebar-width - 2px;
+ transition: width $sidebar-transition-duration;
+ position: fixed;
+ bottom: 0;
+ padding: $gl-padding;
+ background-color: $gray-light;
+ border: 0;
+ border-top: 2px solid $border-color;
+ color: $gl-text-color-secondary;
+ display: flex;
+ align-items: center;
+ line-height: 1;
+
+ svg {
+ margin-right: 8px;
+ }
+
+ .icon-angle-double-right {
+ display: none;
+ }
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+}
+
+.toggle-sidebar-button {
+ @media (max-width: $screen-xs-max) {
+ display: none;
+ }
+}
+
+.collapse-text {
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.sidebar-collapsed-desktop {
+ .context-header {
+ height: 60px;
+ width: $contextual-sidebar-collapsed-width;
+
+ a {
+ padding: 10px 4px;
+ }
+ }
+
+ .sidebar-top-level-items > li {
+ .sidebar-sub-level-items {
+ &:not(.flyout-list) {
+ display: none;
+ }
+ }
+ }
+
+ .nav-icon-container {
+ margin-right: 0;
+ }
+
+ .toggle-sidebar-button {
+ padding: 16px;
+ width: $contextual-sidebar-collapsed-width - 2px;
+
+ .collapse-text,
+ .icon-angle-double-left {
+ display: none;
+ }
+
+ .icon-angle-double-right {
+ display: block;
+ margin: 0;
+ }
+ }
+}
+
+.fly-out-top-item {
+ > a {
+ display: flex;
+ }
+
+ .fly-out-badge {
+ margin-left: 8px;
+ }
+}
+
+.fly-out-top-item-name {
+ flex: 1;
+}
+
+// Mobile nav
+
+.close-nav-button {
+ display: none;
+}
+
+.toggle-mobile-nav {
+ display: none;
+ background-color: transparent;
+ border: 0;
+ padding: 6px 16px;
+ margin: 0 0 0 -15px;
+ height: 46px;
+
+ i {
+ font-size: 20px;
+ color: $gl-text-color-secondary;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: flex;
+ align-items: center;
+
+ i {
+ font-size: 18px;
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ + .breadcrumbs-links {
+ padding-left: $gl-padding;
+ border-left: 1px solid $gl-text-color-quaternary;
+ }
+ }
+}
+
+@media (max-width: $screen-xs-max) {
+ .close-nav-button {
+ display: flex;
+ }
+}
+
+.mobile-overlay {
+ display: none;
+
+ &.mobile-nav-open {
+ display: block;
+ position: fixed;
+ background-color: $black-transparent;
+ height: 100%;
+ width: 100%;
+ z-index: 300;
+ }
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 4f54ca24940..cc74cb72795 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -16,27 +16,21 @@
@mixin set-visible {
transform: translateY(0);
- visibility: visible;
- opacity: 1;
- transition-duration: 100ms, 150ms, 25ms;
- transition-delay: 35ms, 50ms, 25ms;
+ display: block;
}
@mixin set-invisible {
transform: translateY(-10px);
- visibility: hidden;
- opacity: 0;
- transition-property: opacity, transform, visibility;
- transition-duration: 70ms, 250ms, 250ms;
- transition-timing-function: linear, $dropdown-animation-timing;
- transition-delay: 25ms, 50ms, 0ms;
+ display: none;
}
.open {
.dropdown-menu,
.dropdown-menu-nav {
- display: block;
@include set-visible;
+ min-height: $dropdown-min-height;
+ max-height: $dropdown-max-height;
+ overflow-y: auto;
@media (max-width: $screen-xs-max) {
width: 100%;
@@ -54,6 +48,11 @@
}
}
+// Get search dropdown to line up with other nav dropdowns
+.search-input-container .dropdown-menu {
+ margin-top: 11px;
+}
+
.dropdown-toggle {
padding: 6px 8px 6px 10px;
background-color: $white-light;
@@ -64,11 +63,6 @@
border-radius: $border-radius-base;
white-space: nowrap;
- &[disabled] {
- opacity: .65;
- cursor: not-allowed;
- }
-
&.no-outline {
outline: 0;
}
@@ -142,21 +136,48 @@
}
}
+@mixin dropdown-item-hover {
+ background-color: $dropdown-item-hover-bg;
+ color: $gl-text-color;
+ outline: 0;
+
+ // make sure the text color is not overriden
+ &.text-danger {
+ color: $brand-danger;
+ }
+
+ .avatar {
+ border-color: $white-light;
+ }
+}
+
@mixin dropdown-link {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
display: block;
+ font-weight: $gl-font-weight-normal;
position: relative;
- padding: 5px 8px;
+ padding: 8px 16px;
color: $gl-text-color;
- line-height: initial;
- text-overflow: ellipsis;
- border-radius: 2px;
- white-space: nowrap;
+ line-height: normal;
+ white-space: normal;
overflow: hidden;
+ text-align: left;
+ width: 100%;
+
+ // make sure the text color is not overriden
+ &.text-danger {
+ color: $brand-danger;
+ }
&:hover,
+ &:active,
&:focus,
&.is-focused {
- background-color: $dropdown-link-hover-bg;
+ @include dropdown-item-hover;
+
text-decoration: none;
.badge {
@@ -164,14 +185,15 @@
}
}
- &.dropdown-menu-empty-link {
- &.is-focused {
- background-color: $dropdown-empty-row-bg;
- }
- }
-
&.dropdown-menu-user-link {
line-height: 16px;
+ padding-top: 10px;
+ padding-bottom: 7px;
+ white-space: nowrap;
+
+ .dropdown-menu-user-username {
+ display: block;
+ }
}
.icon-play {
@@ -184,26 +206,26 @@
.dropdown-menu,
.dropdown-menu-nav {
- display: block;
+ @include set-invisible;
position: absolute;
- width: 100%;
+ width: auto;
top: 100%;
left: 0;
- z-index: 9;
+ z-index: 300;
min-width: 240px;
- margin-top: 2px;
- margin-bottom: 0;
+ max-width: 500px;
+ margin-top: $dropdown-vertical-offset;
+ margin-bottom: 24px;
font-size: 14px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
padding: 8px 0;
background-color: $white-light;
border: 1px solid $dropdown-border-color;
border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color;
- @include set-invisible;
- @media (max-width: $screen-sm-min) {
- width: 100%;
+ &.dropdown-open-top {
+ margin-bottom: $dropdown-vertical-offset;
}
&.dropdown-open-left {
@@ -236,16 +258,27 @@
}
li {
+ display: block;
text-align: left;
list-style: none;
- padding: 0 10px;
+ padding: 0 1px;
+
+ a,
+ button,
+ .menu-item {
+ @include dropdown-link;
+ }
}
.divider {
height: 1px;
- margin: 6px 10px;
+ margin: #{$grid-size / 2} 0;
padding: 0;
background-color: $dropdown-divider-color;
+
+ &:hover {
+ background-color: $dropdown-divider-color;
+ }
}
.separator {
@@ -256,15 +289,18 @@
background-color: $dropdown-divider-color;
}
- a {
- @include dropdown-link;
+ .dropdown-menu-empty-item a {
+ &:hover,
+ &:focus {
+ background-color: transparent;
+ }
}
.dropdown-header {
color: $gl-text-color-secondary;
font-size: 13px;
line-height: 22px;
- padding: 0 16px;
+ padding: 8px 16px;
}
&.capitalize-header .dropdown-header {
@@ -272,14 +308,14 @@
}
.dropdown-bold-header {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
line-height: 22px;
padding: 0 16px;
}
.separator + .dropdown-header,
.separator + .dropdown-bold-header {
- padding-top: 2px;
+ padding-top: 10px;
}
.unclickable {
@@ -288,27 +324,78 @@
color: $gl-text-color-secondary;
}
- .badge {
- position: absolute;
- right: 8px;
- top: 5px;
+ .badge + span:not(.badge) {
+ // Expects up to 3 digits on the badge
+ margin-right: 40px;
+ }
+}
+
+.droplab-dropdown {
+ .dropdown-toggle > i {
+ pointer-events: none;
+ }
+
+ .dropdown-menu li {
+ cursor: pointer;
+
+ &.droplab-item-active button {
+ @include dropdown-item-hover;
+ }
+
+ > a,
+ > button {
+ display: flex;
+ margin: 0;
+ text-overflow: inherit;
+ text-align: left;
+
+ &.btn .fa:not(:last-child) {
+ margin-left: 5px;
+ }
+ }
+
+ &.droplab-item-selected i {
+ visibility: visible;
+ }
+
+ .icon {
+ visibility: hidden;
+ }
+
+ .description {
+ display: inline-block;
+ white-space: normal;
+ margin-left: 5px;
+
+ p {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .icon {
+ display: inline-block;
+ vertical-align: top;
+ padding-top: 2px;
}
}
-.filtered-search-box-input-container .dropdown-menu,
-.filtered-search-box-input-container .dropdown-menu-nav,
-.comment-type-dropdown .dropdown-menu {
+.droplab-dropdown .dropdown-menu,
+.droplab-dropdown .dropdown-menu-nav {
display: none;
opacity: 1;
visibility: visible;
transform: translateY(0);
}
+.comment-type-dropdown.open .dropdown-menu {
+ display: block;
+}
+
.filtered-search-box-input-container {
.dropdown-menu,
.dropdown-menu-nav {
max-width: 280px;
- width: auto;
}
}
@@ -362,14 +449,9 @@
}
}
-.dropdown-menu-user-link {
- padding-top: 10px;
- padding-bottom: 7px;
-}
-
.dropdown-menu-user-full-name {
display: block;
- font-weight: 500;
+ font-weight: $gl-font-weight-normal;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
@@ -395,45 +477,42 @@
.dropdown-menu-align-right {
left: auto;
right: 0;
- margin-top: -5px;
-
- @media (max-width: $screen-xs-max) {
- left: 0;
- }
}
.dropdown-menu-selectable {
- a {
- padding-left: 26px;
- position: relative;
-
- &.is-indeterminate,
- &.is-active {
- font-weight: 600;
- color: $gl-text-color;
-
- &::before {
- position: absolute;
- left: 6px;
- top: 50%;
- transform: translateY(-50%);
- font: normal normal normal 14px/1 FontAwesome;
- font-size: inherit;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ li {
+ a {
+ padding: 8px 40px;
+ position: relative;
+
+ &.is-indeterminate,
+ &.is-active {
+ color: $gl-text-color;
+
+ &::before {
+ position: absolute;
+ left: 16px;
+ top: 16px;
+ transform: translateY(-50%);
+ font: normal normal normal 14px/1 FontAwesome;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ &.dropdown-menu-user-link::before {
+ top: 50%;
+ }
}
- }
- &.is-indeterminate::before {
- content: "\f068";
- }
+ &.is-indeterminate::before {
+ content: "\f068";
+ }
- &.is-active::before {
- content: "\f00c";
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
+ &.is-active::before {
+ content: "\f00c";
+ }
}
}
}
@@ -443,7 +522,7 @@
position: relative;
padding: 2px 25px 10px;
margin: 0 10px 10px;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
line-height: 1;
text-align: center;
text-overflow: ellipsis;
@@ -515,6 +594,7 @@
.dropdown-input-field,
.default-dropdown-input {
+ display: block;
width: 100%;
min-height: 30px;
padding: 0 7px;
@@ -542,7 +622,7 @@
}
.dropdown-content {
- max-height: 215px;
+ max-height: 252px;
overflow-y: auto;
}
@@ -558,6 +638,11 @@
border-top: 1px solid $dropdown-divider-color;
}
+.dropdown-footer-content {
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
.dropdown-due-date-footer {
padding-top: 0;
margin-left: 10px;
@@ -574,6 +659,16 @@
}
}
+.dropdown-create-new-item-button {
+ @include dropdown-link;
+
+ width: 100%;
+ background-color: transparent;
+ border: 0;
+ text-align: left;
+ text-overflow: ellipsis;
+}
+
.dropdown-loading {
position: absolute;
top: 0;
@@ -604,14 +699,39 @@
border-radius: $border-radius-base;
}
+.git-revision-dropdown {
+ .dropdown-content {
+ max-height: 215px;
+ }
+}
+
+.sidebar-move-issue-dropdown {
+ .dropdown-content {
+ max-height: 160px;
+ }
+}
+
+.dropdown-menu-author {
+ .dropdown-content {
+ max-height: 215px;
+ }
+}
+
+.dropdown-menu-labels {
+ .dropdown-content {
+ max-height: 128px;
+ }
+}
+
+
.dropdown-menu-due-date {
.dropdown-content {
max-height: 230px;
}
.pika-single {
- position: relative!important;
- top: 0!important;
+ position: relative !important;
+ top: 0 !important;
border: 0;
box-shadow: none;
}
@@ -625,7 +745,7 @@
.dropdown-menu-inner-title {
display: block;
color: $gl-text-color;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.dropdown-menu-inner-content {
@@ -639,10 +759,6 @@
}
}
-.droplab-item-ignore {
- pointer-events: none;
-}
-
.pika-single.animate-picker.is-bound,
.pika-single.animate-picker.is-bound.is-hidden {
/*
@@ -657,9 +773,197 @@
.pika-single.animate-picker.is-bound {
@include set-visible;
+
+ &.is-hidden {
+ @include set-invisible;
+ overflow: hidden;
+ }
}
-.pika-single.animate-picker.is-bound.is-hidden {
- @include set-invisible;
- overflow: hidden;
+@media (max-width: $screen-xs-max) {
+ .navbar-gitlab {
+ li.header-projects,
+ li.header-more,
+ li.header-new,
+ li.header-user {
+ position: static;
+ }
+ }
+
+ header.navbar-gitlab .dropdown {
+ .dropdown-menu,
+ .dropdown-menu-nav {
+ width: 100%;
+ min-width: 100%;
+ }
+ }
+
+ header.navbar-gitlab-new .header-content .dropdown {
+ .dropdown-menu {
+ left: 0;
+ min-width: 100%;
+ }
+ }
+}
+
+header.header-content .dropdown-menu.projects-dropdown-menu {
+ padding: 0;
+}
+
+.projects-dropdown-container {
+ display: flex;
+ flex-direction: row;
+ width: 500px;
+ height: 334px;
+
+ .project-dropdown-sidebar,
+ .project-dropdown-content {
+ padding: 8px 0;
+ }
+
+ .loading-animation {
+ color: $almost-black;
+ }
+
+ .project-dropdown-sidebar {
+ width: 30%;
+ border-right: 1px solid $border-color;
+ }
+
+ .project-dropdown-content {
+ position: relative;
+ width: 70%;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-direction: column;
+ width: 100%;
+ height: auto;
+ flex: 1;
+
+ .project-dropdown-sidebar,
+ .project-dropdown-content {
+ width: 100%;
+ }
+
+ .project-dropdown-sidebar {
+ border-bottom: 1px solid $border-color;
+ border-right: 0;
+ }
+ }
+
+ .projects-list-frequent-container,
+ .projects-list-search-container, {
+ padding: 8px 0;
+ overflow-y: auto;
+ }
+
+ .section-header,
+ .projects-list-frequent-container li.section-empty,
+ .projects-list-search-container li.section-empty {
+ padding: 0 15px;
+ color: $gl-text-color-secondary;
+ font-size: $gl-font-size;
+ }
+
+ .projects-list-frequent-container,
+ .projects-list-search-container {
+ li.section-empty.section-failure {
+ color: $callout-danger-color;
+ }
+ }
+
+ .search-input-container {
+ position: relative;
+ padding: 4px $gl-padding;
+
+ .search-icon {
+ position: absolute;
+ top: 13px;
+ right: 25px;
+ color: $md-area-border;
+ }
+ }
+
+ .section-header {
+ font-weight: 700;
+ margin-top: 8px;
+ }
+
+ .projects-list-search-container {
+ height: 284px;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .projects-list-frequent-container {
+ width: auto;
+ height: auto;
+ padding-bottom: 0;
+ }
+ }
+}
+
+.projects-list-item-container {
+ .project-item-avatar-container
+ .project-item-metadata-container {
+ float: left;
+ }
+
+ .project-title,
+ .project-namespace {
+ max-width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &:hover {
+ .project-item-avatar-container .avatar {
+ border-color: $md-area-border;
+ }
+ }
+
+ .project-title {
+ font-size: $gl-font-size;
+ font-weight: 400;
+ line-height: 16px;
+ }
+
+ .project-namespace {
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 12px;
+ color: $gl-text-color-secondary;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .project-item-metadata-container {
+ float: none;
+ }
+ }
+}
+
+.dropdown-content-faded-mask {
+ position: relative;
+
+ .dropdown-list {
+ max-height: $dropdown-max-height;
+ overflow-y: auto;
+ position: relative;
+ }
+
+ &::after {
+ height: $dropdown-fade-mask-height;
+ width: 100%;
+ position: absolute;
+ bottom: 0;
+ background: linear-gradient(to top, $white-light 0, rgba($white-light, 0));
+ transition: opacity $fade-mask-transition-duration $fade-mask-transition-curve;
+ content: '';
+ pointer-events: none;
+ }
+
+ &.fade-out::after {
+ opacity: 0;
+ }
}
diff --git a/app/assets/stylesheets/framework/emoji-sprites.scss b/app/assets/stylesheets/framework/emoji-sprites.scss
deleted file mode 100644
index 925415f84b1..00000000000
--- a/app/assets/stylesheets/framework/emoji-sprites.scss
+++ /dev/null
@@ -1,1811 +0,0 @@
-.emoji-zzz { background-position: 0 0; }
-.emoji-1234 { background-position: -20px 0; }
-.emoji-1F627 { background-position: 0 -20px; }
-.emoji-8ball { background-position: -20px -20px; }
-.emoji-a { background-position: -40px 0; }
-.emoji-ab { background-position: -40px -20px; }
-.emoji-abc { background-position: 0 -40px; }
-.emoji-abcd { background-position: -20px -40px; }
-.emoji-accept { background-position: -40px -40px; }
-.emoji-aerial_tramway { background-position: -60px 0; }
-.emoji-airplane { background-position: -60px -20px; }
-.emoji-airplane_arriving { background-position: -60px -40px; }
-.emoji-airplane_departure { background-position: 0 -60px; }
-.emoji-airplane_small { background-position: -20px -60px; }
-.emoji-alarm_clock { background-position: -40px -60px; }
-.emoji-alembic { background-position: -60px -60px; }
-.emoji-alien { background-position: -80px 0; }
-.emoji-ambulance { background-position: -80px -20px; }
-.emoji-amphora { background-position: -80px -40px; }
-.emoji-anchor { background-position: -80px -60px; }
-.emoji-angel { background-position: 0 -80px; }
-.emoji-angel_tone1 { background-position: -20px -80px; }
-.emoji-angel_tone2 { background-position: -40px -80px; }
-.emoji-angel_tone3 { background-position: -60px -80px; }
-.emoji-angel_tone4 { background-position: -80px -80px; }
-.emoji-angel_tone5 { background-position: -100px 0; }
-.emoji-anger { background-position: -100px -20px; }
-.emoji-anger_right { background-position: -100px -40px; }
-.emoji-angry { background-position: -100px -60px; }
-.emoji-ant { background-position: -100px -80px; }
-.emoji-apple { background-position: 0 -100px; }
-.emoji-aquarius { background-position: -20px -100px; }
-.emoji-aries { background-position: -40px -100px; }
-.emoji-arrow_backward { background-position: -60px -100px; }
-.emoji-arrow_double_down { background-position: -80px -100px; }
-.emoji-arrow_double_up { background-position: -100px -100px; }
-.emoji-arrow_down { background-position: -120px 0; }
-.emoji-arrow_down_small { background-position: -120px -20px; }
-.emoji-arrow_forward { background-position: -120px -40px; }
-.emoji-arrow_heading_down { background-position: -120px -60px; }
-.emoji-arrow_heading_up { background-position: -120px -80px; }
-.emoji-arrow_left { background-position: -120px -100px; }
-.emoji-arrow_lower_left { background-position: 0 -120px; }
-.emoji-arrow_lower_right { background-position: -20px -120px; }
-.emoji-arrow_right { background-position: -40px -120px; }
-.emoji-arrow_right_hook { background-position: -60px -120px; }
-.emoji-arrow_up { background-position: -80px -120px; }
-.emoji-arrow_up_down { background-position: -100px -120px; }
-.emoji-arrow_up_small { background-position: -120px -120px; }
-.emoji-arrow_upper_left { background-position: -140px 0; }
-.emoji-arrow_upper_right { background-position: -140px -20px; }
-.emoji-arrows_clockwise { background-position: -140px -40px; }
-.emoji-arrows_counterclockwise { background-position: -140px -60px; }
-.emoji-art { background-position: -140px -80px; }
-.emoji-articulated_lorry { background-position: -140px -100px; }
-.emoji-asterisk { background-position: -140px -120px; }
-.emoji-astonished { background-position: 0 -140px; }
-.emoji-athletic_shoe { background-position: -20px -140px; }
-.emoji-atm { background-position: -40px -140px; }
-.emoji-atom { background-position: -60px -140px; }
-.emoji-avocado { background-position: -80px -140px; }
-.emoji-b { background-position: -100px -140px; }
-.emoji-baby { background-position: -120px -140px; }
-.emoji-baby_bottle { background-position: -140px -140px; }
-.emoji-baby_chick { background-position: -160px 0; }
-.emoji-baby_symbol { background-position: -160px -20px; }
-.emoji-baby_tone1 { background-position: -160px -40px; }
-.emoji-baby_tone2 { background-position: -160px -60px; }
-.emoji-baby_tone3 { background-position: -160px -80px; }
-.emoji-baby_tone4 { background-position: -160px -100px; }
-.emoji-baby_tone5 { background-position: -160px -120px; }
-.emoji-back { background-position: -160px -140px; }
-.emoji-bacon { background-position: 0 -160px; }
-.emoji-badminton { background-position: -20px -160px; }
-.emoji-baggage_claim { background-position: -40px -160px; }
-.emoji-balloon { background-position: -60px -160px; }
-.emoji-ballot_box { background-position: -80px -160px; }
-.emoji-ballot_box_with_check { background-position: -100px -160px; }
-.emoji-bamboo { background-position: -120px -160px; }
-.emoji-banana { background-position: -140px -160px; }
-.emoji-bangbang { background-position: -160px -160px; }
-.emoji-bank { background-position: -180px 0; }
-.emoji-bar_chart { background-position: -180px -20px; }
-.emoji-barber { background-position: -180px -40px; }
-.emoji-baseball { background-position: -180px -60px; }
-.emoji-basketball { background-position: -180px -80px; }
-.emoji-basketball_player { background-position: -180px -100px; }
-.emoji-basketball_player_tone1 { background-position: -180px -120px; }
-.emoji-basketball_player_tone2 { background-position: -180px -140px; }
-.emoji-basketball_player_tone3 { background-position: -180px -160px; }
-.emoji-basketball_player_tone4 { background-position: 0 -180px; }
-.emoji-basketball_player_tone5 { background-position: -20px -180px; }
-.emoji-bat { background-position: -40px -180px; }
-.emoji-bath { background-position: -60px -180px; }
-.emoji-bath_tone1 { background-position: -80px -180px; }
-.emoji-bath_tone2 { background-position: -100px -180px; }
-.emoji-bath_tone3 { background-position: -120px -180px; }
-.emoji-bath_tone4 { background-position: -140px -180px; }
-.emoji-bath_tone5 { background-position: -160px -180px; }
-.emoji-bathtub { background-position: -180px -180px; }
-.emoji-battery { background-position: -200px 0; }
-.emoji-beach { background-position: -200px -20px; }
-.emoji-beach_umbrella { background-position: -200px -40px; }
-.emoji-bear { background-position: -200px -60px; }
-.emoji-bed { background-position: -200px -80px; }
-.emoji-bee { background-position: -200px -100px; }
-.emoji-beer { background-position: -200px -120px; }
-.emoji-beers { background-position: -200px -140px; }
-.emoji-beetle { background-position: -200px -160px; }
-.emoji-beginner { background-position: -200px -180px; }
-.emoji-bell { background-position: 0 -200px; }
-.emoji-bellhop { background-position: -20px -200px; }
-.emoji-bento { background-position: -40px -200px; }
-.emoji-bicyclist { background-position: -60px -200px; }
-.emoji-bicyclist_tone1 { background-position: -80px -200px; }
-.emoji-bicyclist_tone2 { background-position: -100px -200px; }
-.emoji-bicyclist_tone3 { background-position: -120px -200px; }
-.emoji-bicyclist_tone4 { background-position: -140px -200px; }
-.emoji-bicyclist_tone5 { background-position: -160px -200px; }
-.emoji-bike { background-position: -180px -200px; }
-.emoji-bikini { background-position: -200px -200px; }
-.emoji-biohazard { background-position: -220px 0; }
-.emoji-bird { background-position: -220px -20px; }
-.emoji-birthday { background-position: -220px -40px; }
-.emoji-black_circle { background-position: -220px -60px; }
-.emoji-black_heart { background-position: -220px -80px; }
-.emoji-black_joker { background-position: -220px -100px; }
-.emoji-black_large_square { background-position: -220px -120px; }
-.emoji-black_medium_small_square { background-position: -220px -140px; }
-.emoji-black_medium_square { background-position: -220px -160px; }
-.emoji-black_nib { background-position: -220px -180px; }
-.emoji-black_small_square { background-position: -220px -200px; }
-.emoji-black_square_button { background-position: 0 -220px; }
-.emoji-blossom { background-position: -20px -220px; }
-.emoji-blowfish { background-position: -40px -220px; }
-.emoji-blue_book { background-position: -60px -220px; }
-.emoji-blue_car { background-position: -80px -220px; }
-.emoji-blue_heart { background-position: -100px -220px; }
-.emoji-blush { background-position: -120px -220px; }
-.emoji-boar { background-position: -140px -220px; }
-.emoji-bomb { background-position: -160px -220px; }
-.emoji-book { background-position: -180px -220px; }
-.emoji-bookmark { background-position: -200px -220px; }
-.emoji-bookmark_tabs { background-position: -220px -220px; }
-.emoji-books { background-position: -240px 0; }
-.emoji-boom { background-position: -240px -20px; }
-.emoji-boot { background-position: -240px -40px; }
-.emoji-bouquet { background-position: -240px -60px; }
-.emoji-bow { background-position: -240px -80px; }
-.emoji-bow_and_arrow { background-position: -240px -100px; }
-.emoji-bow_tone1 { background-position: -240px -120px; }
-.emoji-bow_tone2 { background-position: -240px -140px; }
-.emoji-bow_tone3 { background-position: -240px -160px; }
-.emoji-bow_tone4 { background-position: -240px -180px; }
-.emoji-bow_tone5 { background-position: -240px -200px; }
-.emoji-bowling { background-position: -240px -220px; }
-.emoji-boxing_glove { background-position: 0 -240px; }
-.emoji-boy { background-position: -20px -240px; }
-.emoji-boy_tone1 { background-position: -40px -240px; }
-.emoji-boy_tone2 { background-position: -60px -240px; }
-.emoji-boy_tone3 { background-position: -80px -240px; }
-.emoji-boy_tone4 { background-position: -100px -240px; }
-.emoji-boy_tone5 { background-position: -120px -240px; }
-.emoji-bread { background-position: -140px -240px; }
-.emoji-bride_with_veil { background-position: -160px -240px; }
-.emoji-bride_with_veil_tone1 { background-position: -180px -240px; }
-.emoji-bride_with_veil_tone2 { background-position: -200px -240px; }
-.emoji-bride_with_veil_tone3 { background-position: -220px -240px; }
-.emoji-bride_with_veil_tone4 { background-position: -240px -240px; }
-.emoji-bride_with_veil_tone5 { background-position: -260px 0; }
-.emoji-bridge_at_night { background-position: -260px -20px; }
-.emoji-briefcase { background-position: -260px -40px; }
-.emoji-broken_heart { background-position: -260px -60px; }
-.emoji-bug { background-position: -260px -80px; }
-.emoji-bulb { background-position: -260px -100px; }
-.emoji-bullettrain_front { background-position: -260px -120px; }
-.emoji-bullettrain_side { background-position: -260px -140px; }
-.emoji-burrito { background-position: -260px -160px; }
-.emoji-bus { background-position: -260px -180px; }
-.emoji-busstop { background-position: -260px -200px; }
-.emoji-bust_in_silhouette { background-position: -260px -220px; }
-.emoji-busts_in_silhouette { background-position: -260px -240px; }
-.emoji-butterfly { background-position: 0 -260px; }
-.emoji-cactus { background-position: -20px -260px; }
-.emoji-cake { background-position: -40px -260px; }
-.emoji-calendar { background-position: -60px -260px; }
-.emoji-calendar_spiral { background-position: -80px -260px; }
-.emoji-call_me { background-position: -100px -260px; }
-.emoji-call_me_tone1 { background-position: -120px -260px; }
-.emoji-call_me_tone2 { background-position: -140px -260px; }
-.emoji-call_me_tone3 { background-position: -160px -260px; }
-.emoji-call_me_tone4 { background-position: -180px -260px; }
-.emoji-call_me_tone5 { background-position: -200px -260px; }
-.emoji-calling { background-position: -220px -260px; }
-.emoji-camel { background-position: -240px -260px; }
-.emoji-camera { background-position: -260px -260px; }
-.emoji-camera_with_flash { background-position: -280px 0; }
-.emoji-camping { background-position: -280px -20px; }
-.emoji-cancer { background-position: -280px -40px; }
-.emoji-candle { background-position: -280px -60px; }
-.emoji-candy { background-position: -280px -80px; }
-.emoji-canoe { background-position: -280px -100px; }
-.emoji-capital_abcd { background-position: -280px -120px; }
-.emoji-capricorn { background-position: -280px -140px; }
-.emoji-card_box { background-position: -280px -160px; }
-.emoji-card_index { background-position: -280px -180px; }
-.emoji-carousel_horse { background-position: -280px -200px; }
-.emoji-carrot { background-position: -280px -220px; }
-.emoji-cartwheel { background-position: -280px -240px; }
-.emoji-cartwheel_tone1 { background-position: -280px -260px; }
-.emoji-cartwheel_tone2 { background-position: 0 -280px; }
-.emoji-cartwheel_tone3 { background-position: -20px -280px; }
-.emoji-cartwheel_tone4 { background-position: -40px -280px; }
-.emoji-cartwheel_tone5 { background-position: -60px -280px; }
-.emoji-cat { background-position: -80px -280px; }
-.emoji-cat2 { background-position: -100px -280px; }
-.emoji-cd { background-position: -120px -280px; }
-.emoji-chains { background-position: -140px -280px; }
-.emoji-champagne { background-position: -160px -280px; }
-.emoji-champagne_glass { background-position: -180px -280px; }
-.emoji-chart { background-position: -200px -280px; }
-.emoji-chart_with_downwards_trend { background-position: -220px -280px; }
-.emoji-chart_with_upwards_trend { background-position: -240px -280px; }
-.emoji-checkered_flag { background-position: -260px -280px; }
-.emoji-cheese { background-position: -280px -280px; }
-.emoji-cherries { background-position: -300px 0; }
-.emoji-cherry_blossom { background-position: -300px -20px; }
-.emoji-chestnut { background-position: -300px -40px; }
-.emoji-chicken { background-position: -300px -60px; }
-.emoji-children_crossing { background-position: -300px -80px; }
-.emoji-chipmunk { background-position: -300px -100px; }
-.emoji-chocolate_bar { background-position: -300px -120px; }
-.emoji-christmas_tree { background-position: -300px -140px; }
-.emoji-church { background-position: -300px -160px; }
-.emoji-cinema { background-position: -300px -180px; }
-.emoji-circus_tent { background-position: -300px -200px; }
-.emoji-city_dusk { background-position: -300px -220px; }
-.emoji-city_sunset { background-position: -300px -240px; }
-.emoji-cityscape { background-position: -300px -260px; }
-.emoji-cl { background-position: -300px -280px; }
-.emoji-clap { background-position: 0 -300px; }
-.emoji-clap_tone1 { background-position: -20px -300px; }
-.emoji-clap_tone2 { background-position: -40px -300px; }
-.emoji-clap_tone3 { background-position: -60px -300px; }
-.emoji-clap_tone4 { background-position: -80px -300px; }
-.emoji-clap_tone5 { background-position: -100px -300px; }
-.emoji-clapper { background-position: -120px -300px; }
-.emoji-classical_building { background-position: -140px -300px; }
-.emoji-clipboard { background-position: -160px -300px; }
-.emoji-clock { background-position: -180px -300px; }
-.emoji-clock1 { background-position: -200px -300px; }
-.emoji-clock10 { background-position: -220px -300px; }
-.emoji-clock1030 { background-position: -240px -300px; }
-.emoji-clock11 { background-position: -260px -300px; }
-.emoji-clock1130 { background-position: -280px -300px; }
-.emoji-clock12 { background-position: -300px -300px; }
-.emoji-clock1230 { background-position: -320px 0; }
-.emoji-clock130 { background-position: -320px -20px; }
-.emoji-clock2 { background-position: -320px -40px; }
-.emoji-clock230 { background-position: -320px -60px; }
-.emoji-clock3 { background-position: -320px -80px; }
-.emoji-clock330 { background-position: -320px -100px; }
-.emoji-clock4 { background-position: -320px -120px; }
-.emoji-clock430 { background-position: -320px -140px; }
-.emoji-clock5 { background-position: -320px -160px; }
-.emoji-clock530 { background-position: -320px -180px; }
-.emoji-clock6 { background-position: -320px -200px; }
-.emoji-clock630 { background-position: -320px -220px; }
-.emoji-clock7 { background-position: -320px -240px; }
-.emoji-clock730 { background-position: -320px -260px; }
-.emoji-clock8 { background-position: -320px -280px; }
-.emoji-clock830 { background-position: -320px -300px; }
-.emoji-clock9 { background-position: 0 -320px; }
-.emoji-clock930 { background-position: -20px -320px; }
-.emoji-closed_book { background-position: -40px -320px; }
-.emoji-closed_lock_with_key { background-position: -60px -320px; }
-.emoji-closed_umbrella { background-position: -80px -320px; }
-.emoji-cloud { background-position: -100px -320px; }
-.emoji-cloud_lightning { background-position: -120px -320px; }
-.emoji-cloud_rain { background-position: -140px -320px; }
-.emoji-cloud_snow { background-position: -160px -320px; }
-.emoji-cloud_tornado { background-position: -180px -320px; }
-.emoji-clown { background-position: -200px -320px; }
-.emoji-clubs { background-position: -220px -320px; }
-.emoji-cocktail { background-position: -240px -320px; }
-.emoji-coffee { background-position: -260px -320px; }
-.emoji-coffin { background-position: -280px -320px; }
-.emoji-cold_sweat { background-position: -300px -320px; }
-.emoji-comet { background-position: -320px -320px; }
-.emoji-compression { background-position: -340px 0; }
-.emoji-computer { background-position: -340px -20px; }
-.emoji-confetti_ball { background-position: -340px -40px; }
-.emoji-confounded { background-position: -340px -60px; }
-.emoji-confused { background-position: -340px -80px; }
-.emoji-congratulations { background-position: -340px -100px; }
-.emoji-construction { background-position: -340px -120px; }
-.emoji-construction_site { background-position: -340px -140px; }
-.emoji-construction_worker { background-position: -340px -160px; }
-.emoji-construction_worker_tone1 { background-position: -340px -180px; }
-.emoji-construction_worker_tone2 { background-position: -340px -200px; }
-.emoji-construction_worker_tone3 { background-position: -340px -220px; }
-.emoji-construction_worker_tone4 { background-position: -340px -240px; }
-.emoji-construction_worker_tone5 { background-position: -340px -260px; }
-.emoji-control_knobs { background-position: -340px -280px; }
-.emoji-convenience_store { background-position: -340px -300px; }
-.emoji-cookie { background-position: -340px -320px; }
-.emoji-cooking { background-position: 0 -340px; }
-.emoji-cool { background-position: -20px -340px; }
-.emoji-cop { background-position: -40px -340px; }
-.emoji-cop_tone1 { background-position: -60px -340px; }
-.emoji-cop_tone2 { background-position: -80px -340px; }
-.emoji-cop_tone3 { background-position: -100px -340px; }
-.emoji-cop_tone4 { background-position: -120px -340px; }
-.emoji-cop_tone5 { background-position: -140px -340px; }
-.emoji-copyright { background-position: -160px -340px; }
-.emoji-corn { background-position: -180px -340px; }
-.emoji-couch { background-position: -200px -340px; }
-.emoji-couple { background-position: -220px -340px; }
-.emoji-couple_mm { background-position: -240px -340px; }
-.emoji-couple_with_heart { background-position: -260px -340px; }
-.emoji-couple_ww { background-position: -280px -340px; }
-.emoji-couplekiss { background-position: -300px -340px; }
-.emoji-cow { background-position: -320px -340px; }
-.emoji-cow2 { background-position: -340px -340px; }
-.emoji-cowboy { background-position: -360px 0; }
-.emoji-crab { background-position: -360px -20px; }
-.emoji-crayon { background-position: -360px -40px; }
-.emoji-credit_card { background-position: -360px -60px; }
-.emoji-crescent_moon { background-position: -360px -80px; }
-.emoji-cricket { background-position: -360px -100px; }
-.emoji-crocodile { background-position: -360px -120px; }
-.emoji-croissant { background-position: -360px -140px; }
-.emoji-cross { background-position: -360px -160px; }
-.emoji-crossed_flags { background-position: -360px -180px; }
-.emoji-crossed_swords { background-position: -360px -200px; }
-.emoji-crown { background-position: -360px -220px; }
-.emoji-cruise_ship { background-position: -360px -240px; }
-.emoji-cry { background-position: -360px -260px; }
-.emoji-crying_cat_face { background-position: -360px -280px; }
-.emoji-crystal_ball { background-position: -360px -300px; }
-.emoji-cucumber { background-position: -360px -320px; }
-.emoji-cupid { background-position: -360px -340px; }
-.emoji-curly_loop { background-position: 0 -360px; }
-.emoji-currency_exchange { background-position: -20px -360px; }
-.emoji-curry { background-position: -40px -360px; }
-.emoji-custard { background-position: -60px -360px; }
-.emoji-customs { background-position: -80px -360px; }
-.emoji-cyclone { background-position: -100px -360px; }
-.emoji-dagger { background-position: -120px -360px; }
-.emoji-dancer { background-position: -140px -360px; }
-.emoji-dancer_tone1 { background-position: -160px -360px; }
-.emoji-dancer_tone2 { background-position: -180px -360px; }
-.emoji-dancer_tone3 { background-position: -200px -360px; }
-.emoji-dancer_tone4 { background-position: -220px -360px; }
-.emoji-dancer_tone5 { background-position: -240px -360px; }
-.emoji-dancers { background-position: -260px -360px; }
-.emoji-dango { background-position: -280px -360px; }
-.emoji-dark_sunglasses { background-position: -300px -360px; }
-.emoji-dart { background-position: -320px -360px; }
-.emoji-dash { background-position: -340px -360px; }
-.emoji-date { background-position: -360px -360px; }
-.emoji-deciduous_tree { background-position: -380px 0; }
-.emoji-deer { background-position: -380px -20px; }
-.emoji-department_store { background-position: -380px -40px; }
-.emoji-desert { background-position: -380px -60px; }
-.emoji-desktop { background-position: -380px -80px; }
-.emoji-diamond_shape_with_a_dot_inside { background-position: -380px -100px; }
-.emoji-diamonds { background-position: -380px -120px; }
-.emoji-disappointed { background-position: -380px -140px; }
-.emoji-disappointed_relieved { background-position: -380px -160px; }
-.emoji-dividers { background-position: -380px -180px; }
-.emoji-dizzy { background-position: -380px -200px; }
-.emoji-dizzy_face { background-position: -380px -220px; }
-.emoji-do_not_litter { background-position: -380px -240px; }
-.emoji-dog { background-position: -380px -260px; }
-.emoji-dog2 { background-position: -380px -280px; }
-.emoji-dollar { background-position: -380px -300px; }
-.emoji-dolls { background-position: -380px -320px; }
-.emoji-dolphin { background-position: -380px -340px; }
-.emoji-door { background-position: -380px -360px; }
-.emoji-doughnut { background-position: 0 -380px; }
-.emoji-dove { background-position: -20px -380px; }
-.emoji-dragon { background-position: -40px -380px; }
-.emoji-dragon_face { background-position: -60px -380px; }
-.emoji-dress { background-position: -80px -380px; }
-.emoji-dromedary_camel { background-position: -100px -380px; }
-.emoji-drooling_face { background-position: -120px -380px; }
-.emoji-droplet { background-position: -140px -380px; }
-.emoji-drum { background-position: -160px -380px; }
-.emoji-duck { background-position: -180px -380px; }
-.emoji-dvd { background-position: -200px -380px; }
-.emoji-e-mail { background-position: -220px -380px; }
-.emoji-eagle { background-position: -240px -380px; }
-.emoji-ear { background-position: -260px -380px; }
-.emoji-ear_of_rice { background-position: -280px -380px; }
-.emoji-ear_tone1 { background-position: -300px -380px; }
-.emoji-ear_tone2 { background-position: -320px -380px; }
-.emoji-ear_tone3 { background-position: -340px -380px; }
-.emoji-ear_tone4 { background-position: -360px -380px; }
-.emoji-ear_tone5 { background-position: -380px -380px; }
-.emoji-earth_africa { background-position: -400px 0; }
-.emoji-earth_americas { background-position: -400px -20px; }
-.emoji-earth_asia { background-position: -400px -40px; }
-.emoji-egg { background-position: -400px -60px; }
-.emoji-eggplant { background-position: -400px -80px; }
-.emoji-eight { background-position: -400px -100px; }
-.emoji-eight_pointed_black_star { background-position: -400px -120px; }
-.emoji-eight_spoked_asterisk { background-position: -400px -140px; }
-.emoji-eject { background-position: -400px -160px; }
-.emoji-electric_plug { background-position: -400px -180px; }
-.emoji-elephant { background-position: -400px -200px; }
-.emoji-end { background-position: -400px -220px; }
-.emoji-envelope { background-position: -400px -240px; }
-.emoji-envelope_with_arrow { background-position: -400px -260px; }
-.emoji-euro { background-position: -400px -280px; }
-.emoji-european_castle { background-position: -400px -300px; }
-.emoji-european_post_office { background-position: -400px -320px; }
-.emoji-evergreen_tree { background-position: -400px -340px; }
-.emoji-exclamation { background-position: -400px -360px; }
-.emoji-expressionless { background-position: -400px -380px; }
-.emoji-eye { background-position: 0 -400px; }
-.emoji-eye_in_speech_bubble { background-position: -20px -400px; }
-.emoji-eyeglasses { background-position: -40px -400px; }
-.emoji-eyes { background-position: -60px -400px; }
-.emoji-face_palm { background-position: -80px -400px; }
-.emoji-face_palm_tone1 { background-position: -100px -400px; }
-.emoji-face_palm_tone2 { background-position: -120px -400px; }
-.emoji-face_palm_tone3 { background-position: -140px -400px; }
-.emoji-face_palm_tone4 { background-position: -160px -400px; }
-.emoji-face_palm_tone5 { background-position: -180px -400px; }
-.emoji-factory { background-position: -200px -400px; }
-.emoji-fallen_leaf { background-position: -220px -400px; }
-.emoji-family { background-position: -240px -400px; }
-.emoji-family_mmb { background-position: -260px -400px; }
-.emoji-family_mmbb { background-position: -280px -400px; }
-.emoji-family_mmg { background-position: -300px -400px; }
-.emoji-family_mmgb { background-position: -320px -400px; }
-.emoji-family_mmgg { background-position: -340px -400px; }
-.emoji-family_mwbb { background-position: -360px -400px; }
-.emoji-family_mwg { background-position: -380px -400px; }
-.emoji-family_mwgb { background-position: -400px -400px; }
-.emoji-family_mwgg { background-position: -420px 0; }
-.emoji-family_wwb { background-position: -420px -20px; }
-.emoji-family_wwbb { background-position: -420px -40px; }
-.emoji-family_wwg { background-position: -420px -60px; }
-.emoji-family_wwgb { background-position: -420px -80px; }
-.emoji-family_wwgg { background-position: -420px -100px; }
-.emoji-fast_forward { background-position: -420px -120px; }
-.emoji-fax { background-position: -420px -140px; }
-.emoji-fearful { background-position: -420px -160px; }
-.emoji-feet { background-position: -420px -180px; }
-.emoji-fencer { background-position: -420px -200px; }
-.emoji-ferris_wheel { background-position: -420px -220px; }
-.emoji-ferry { background-position: -420px -240px; }
-.emoji-field_hockey { background-position: -420px -260px; }
-.emoji-file_cabinet { background-position: -420px -280px; }
-.emoji-file_folder { background-position: -420px -300px; }
-.emoji-film_frames { background-position: -420px -320px; }
-.emoji-fingers_crossed { background-position: -420px -340px; }
-.emoji-fingers_crossed_tone1 { background-position: -420px -360px; }
-.emoji-fingers_crossed_tone2 { background-position: -420px -380px; }
-.emoji-fingers_crossed_tone3 { background-position: -420px -400px; }
-.emoji-fingers_crossed_tone4 { background-position: 0 -420px; }
-.emoji-fingers_crossed_tone5 { background-position: -20px -420px; }
-.emoji-fire { background-position: -40px -420px; }
-.emoji-fire_engine { background-position: -60px -420px; }
-.emoji-fireworks { background-position: -80px -420px; }
-.emoji-first_place { background-position: -100px -420px; }
-.emoji-first_quarter_moon { background-position: -120px -420px; }
-.emoji-first_quarter_moon_with_face { background-position: -140px -420px; }
-.emoji-fish { background-position: -160px -420px; }
-.emoji-fish_cake { background-position: -180px -420px; }
-.emoji-fishing_pole_and_fish { background-position: -200px -420px; }
-.emoji-fist { background-position: -220px -420px; }
-.emoji-fist_tone1 { background-position: -240px -420px; }
-.emoji-fist_tone2 { background-position: -260px -420px; }
-.emoji-fist_tone3 { background-position: -280px -420px; }
-.emoji-fist_tone4 { background-position: -300px -420px; }
-.emoji-fist_tone5 { background-position: -320px -420px; }
-.emoji-five { background-position: -340px -420px; }
-.emoji-flag_ac { background-position: -360px -420px; }
-.emoji-flag_ad { background-position: -380px -420px; }
-.emoji-flag_ae { background-position: -400px -420px; }
-.emoji-flag_af { background-position: -420px -420px; }
-.emoji-flag_ag { background-position: -440px 0; }
-.emoji-flag_ai { background-position: -440px -20px; }
-.emoji-flag_al { background-position: -440px -40px; }
-.emoji-flag_am { background-position: -440px -60px; }
-.emoji-flag_ao { background-position: -440px -80px; }
-.emoji-flag_aq { background-position: -440px -100px; }
-.emoji-flag_ar { background-position: -440px -120px; }
-.emoji-flag_as { background-position: -440px -140px; }
-.emoji-flag_at { background-position: -440px -160px; }
-.emoji-flag_au { background-position: -440px -180px; }
-.emoji-flag_aw { background-position: -440px -200px; }
-.emoji-flag_ax { background-position: -440px -220px; }
-.emoji-flag_az { background-position: -440px -240px; }
-.emoji-flag_ba { background-position: -440px -260px; }
-.emoji-flag_bb { background-position: -440px -280px; }
-.emoji-flag_bd { background-position: -440px -300px; }
-.emoji-flag_be { background-position: -440px -320px; }
-.emoji-flag_bf { background-position: -440px -340px; }
-.emoji-flag_bg { background-position: -440px -360px; }
-.emoji-flag_bh { background-position: -440px -380px; }
-.emoji-flag_bi { background-position: -440px -400px; }
-.emoji-flag_bj { background-position: -440px -420px; }
-.emoji-flag_bl { background-position: 0 -440px; }
-.emoji-flag_black { background-position: -20px -440px; }
-.emoji-flag_bm { background-position: -40px -440px; }
-.emoji-flag_bn { background-position: -60px -440px; }
-.emoji-flag_bo { background-position: -80px -440px; }
-.emoji-flag_bq { background-position: -100px -440px; }
-.emoji-flag_br { background-position: -120px -440px; }
-.emoji-flag_bs { background-position: -140px -440px; }
-.emoji-flag_bt { background-position: -160px -440px; }
-.emoji-flag_bv { background-position: -180px -440px; }
-.emoji-flag_bw { background-position: -200px -440px; }
-.emoji-flag_by { background-position: -220px -440px; }
-.emoji-flag_bz { background-position: -240px -440px; }
-.emoji-flag_ca { background-position: -260px -440px; }
-.emoji-flag_cc { background-position: -280px -440px; }
-.emoji-flag_cd { background-position: -300px -440px; }
-.emoji-flag_cf { background-position: -320px -440px; }
-.emoji-flag_cg { background-position: -340px -440px; }
-.emoji-flag_ch { background-position: -360px -440px; }
-.emoji-flag_ci { background-position: -380px -440px; }
-.emoji-flag_ck { background-position: -400px -440px; }
-.emoji-flag_cl { background-position: -420px -440px; }
-.emoji-flag_cm { background-position: -440px -440px; }
-.emoji-flag_cn { background-position: -460px 0; }
-.emoji-flag_co { background-position: -460px -20px; }
-.emoji-flag_cp { background-position: -460px -40px; }
-.emoji-flag_cr { background-position: -460px -60px; }
-.emoji-flag_cu { background-position: -460px -80px; }
-.emoji-flag_cv { background-position: -460px -100px; }
-.emoji-flag_cw { background-position: -460px -120px; }
-.emoji-flag_cx { background-position: -460px -140px; }
-.emoji-flag_cy { background-position: -460px -160px; }
-.emoji-flag_cz { background-position: -460px -180px; }
-.emoji-flag_de { background-position: -460px -200px; }
-.emoji-flag_dg { background-position: -460px -220px; }
-.emoji-flag_dj { background-position: -460px -240px; }
-.emoji-flag_dk { background-position: -460px -260px; }
-.emoji-flag_dm { background-position: -460px -280px; }
-.emoji-flag_do { background-position: -460px -300px; }
-.emoji-flag_dz { background-position: -460px -320px; }
-.emoji-flag_ea { background-position: -460px -340px; }
-.emoji-flag_ec { background-position: -460px -360px; }
-.emoji-flag_ee { background-position: -460px -380px; }
-.emoji-flag_eg { background-position: -460px -400px; }
-.emoji-flag_eh { background-position: -460px -420px; }
-.emoji-flag_er { background-position: -460px -440px; }
-.emoji-flag_es { background-position: 0 -460px; }
-.emoji-flag_et { background-position: -20px -460px; }
-.emoji-flag_eu { background-position: -40px -460px; }
-.emoji-flag_fi { background-position: -60px -460px; }
-.emoji-flag_fj { background-position: -80px -460px; }
-.emoji-flag_fk { background-position: -100px -460px; }
-.emoji-flag_fm { background-position: -120px -460px; }
-.emoji-flag_fo { background-position: -140px -460px; }
-.emoji-flag_fr { background-position: -160px -460px; }
-.emoji-flag_ga { background-position: -180px -460px; }
-.emoji-flag_gb { background-position: -200px -460px; }
-.emoji-flag_gd { background-position: -220px -460px; }
-.emoji-flag_ge { background-position: -240px -460px; }
-.emoji-flag_gf { background-position: -260px -460px; }
-.emoji-flag_gg { background-position: -280px -460px; }
-.emoji-flag_gh { background-position: -300px -460px; }
-.emoji-flag_gi { background-position: -320px -460px; }
-.emoji-flag_gl { background-position: -340px -460px; }
-.emoji-flag_gm { background-position: -360px -460px; }
-.emoji-flag_gn { background-position: -380px -460px; }
-.emoji-flag_gp { background-position: -400px -460px; }
-.emoji-flag_gq { background-position: -420px -460px; }
-.emoji-flag_gr { background-position: -440px -460px; }
-.emoji-flag_gs { background-position: -460px -460px; }
-.emoji-flag_gt { background-position: -480px 0; }
-.emoji-flag_gu { background-position: -480px -20px; }
-.emoji-flag_gw { background-position: -480px -40px; }
-.emoji-flag_gy { background-position: -480px -60px; }
-.emoji-flag_hk { background-position: -480px -80px; }
-.emoji-flag_hm { background-position: -480px -100px; }
-.emoji-flag_hn { background-position: -480px -120px; }
-.emoji-flag_hr { background-position: -480px -140px; }
-.emoji-flag_ht { background-position: -480px -160px; }
-.emoji-flag_hu { background-position: -480px -180px; }
-.emoji-flag_ic { background-position: -480px -200px; }
-.emoji-flag_id { background-position: -480px -220px; }
-.emoji-flag_ie { background-position: -480px -240px; }
-.emoji-flag_il { background-position: -480px -260px; }
-.emoji-flag_im { background-position: -480px -280px; }
-.emoji-flag_in { background-position: -480px -300px; }
-.emoji-flag_io { background-position: -480px -320px; }
-.emoji-flag_iq { background-position: -480px -340px; }
-.emoji-flag_ir { background-position: -480px -360px; }
-.emoji-flag_is { background-position: -480px -380px; }
-.emoji-flag_it { background-position: -480px -400px; }
-.emoji-flag_je { background-position: -480px -420px; }
-.emoji-flag_jm { background-position: -480px -440px; }
-.emoji-flag_jo { background-position: -480px -460px; }
-.emoji-flag_jp { background-position: 0 -480px; }
-.emoji-flag_ke { background-position: -20px -480px; }
-.emoji-flag_kg { background-position: -40px -480px; }
-.emoji-flag_kh { background-position: -60px -480px; }
-.emoji-flag_ki { background-position: -80px -480px; }
-.emoji-flag_km { background-position: -100px -480px; }
-.emoji-flag_kn { background-position: -120px -480px; }
-.emoji-flag_kp { background-position: -140px -480px; }
-.emoji-flag_kr { background-position: -160px -480px; }
-.emoji-flag_kw { background-position: -180px -480px; }
-.emoji-flag_ky { background-position: -200px -480px; }
-.emoji-flag_kz { background-position: -220px -480px; }
-.emoji-flag_la { background-position: -240px -480px; }
-.emoji-flag_lb { background-position: -260px -480px; }
-.emoji-flag_lc { background-position: -280px -480px; }
-.emoji-flag_li { background-position: -300px -480px; }
-.emoji-flag_lk { background-position: -320px -480px; }
-.emoji-flag_lr { background-position: -340px -480px; }
-.emoji-flag_ls { background-position: -360px -480px; }
-.emoji-flag_lt { background-position: -380px -480px; }
-.emoji-flag_lu { background-position: -400px -480px; }
-.emoji-flag_lv { background-position: -420px -480px; }
-.emoji-flag_ly { background-position: -440px -480px; }
-.emoji-flag_ma { background-position: -460px -480px; }
-.emoji-flag_mc { background-position: -480px -480px; }
-.emoji-flag_md { background-position: -500px 0; }
-.emoji-flag_me { background-position: -500px -20px; }
-.emoji-flag_mf { background-position: -500px -40px; }
-.emoji-flag_mg { background-position: -500px -60px; }
-.emoji-flag_mh { background-position: -500px -80px; }
-.emoji-flag_mk { background-position: -500px -100px; }
-.emoji-flag_ml { background-position: -500px -120px; }
-.emoji-flag_mm { background-position: -500px -140px; }
-.emoji-flag_mn { background-position: -500px -160px; }
-.emoji-flag_mo { background-position: -500px -180px; }
-.emoji-flag_mp { background-position: -500px -200px; }
-.emoji-flag_mq { background-position: -500px -220px; }
-.emoji-flag_mr { background-position: -500px -240px; }
-.emoji-flag_ms { background-position: -500px -260px; }
-.emoji-flag_mt { background-position: -500px -280px; }
-.emoji-flag_mu { background-position: -500px -300px; }
-.emoji-flag_mv { background-position: -500px -320px; }
-.emoji-flag_mw { background-position: -500px -340px; }
-.emoji-flag_mx { background-position: -500px -360px; }
-.emoji-flag_my { background-position: -500px -380px; }
-.emoji-flag_mz { background-position: -500px -400px; }
-.emoji-flag_na { background-position: -500px -420px; }
-.emoji-flag_nc { background-position: -500px -440px; }
-.emoji-flag_ne { background-position: -500px -460px; }
-.emoji-flag_nf { background-position: -500px -480px; }
-.emoji-flag_ng { background-position: 0 -500px; }
-.emoji-flag_ni { background-position: -20px -500px; }
-.emoji-flag_nl { background-position: -40px -500px; }
-.emoji-flag_no { background-position: -60px -500px; }
-.emoji-flag_np { background-position: -80px -500px; }
-.emoji-flag_nr { background-position: -100px -500px; }
-.emoji-flag_nu { background-position: -120px -500px; }
-.emoji-flag_nz { background-position: -140px -500px; }
-.emoji-flag_om { background-position: -160px -500px; }
-.emoji-flag_pa { background-position: -180px -500px; }
-.emoji-flag_pe { background-position: -200px -500px; }
-.emoji-flag_pf { background-position: -220px -500px; }
-.emoji-flag_pg { background-position: -240px -500px; }
-.emoji-flag_ph { background-position: -260px -500px; }
-.emoji-flag_pk { background-position: -280px -500px; }
-.emoji-flag_pl { background-position: -300px -500px; }
-.emoji-flag_pm { background-position: -320px -500px; }
-.emoji-flag_pn { background-position: -340px -500px; }
-.emoji-flag_pr { background-position: -360px -500px; }
-.emoji-flag_ps { background-position: -380px -500px; }
-.emoji-flag_pt { background-position: -400px -500px; }
-.emoji-flag_pw { background-position: -420px -500px; }
-.emoji-flag_py { background-position: -440px -500px; }
-.emoji-flag_qa { background-position: -460px -500px; }
-.emoji-flag_re { background-position: -480px -500px; }
-.emoji-flag_ro { background-position: -500px -500px; }
-.emoji-flag_rs { background-position: -520px 0; }
-.emoji-flag_ru { background-position: -520px -20px; }
-.emoji-flag_rw { background-position: -520px -40px; }
-.emoji-flag_sa { background-position: -520px -60px; }
-.emoji-flag_sb { background-position: -520px -80px; }
-.emoji-flag_sc { background-position: -520px -100px; }
-.emoji-flag_sd { background-position: -520px -120px; }
-.emoji-flag_se { background-position: -520px -140px; }
-.emoji-flag_sg { background-position: -520px -160px; }
-.emoji-flag_sh { background-position: -520px -180px; }
-.emoji-flag_si { background-position: -520px -200px; }
-.emoji-flag_sj { background-position: -520px -220px; }
-.emoji-flag_sk { background-position: -520px -240px; }
-.emoji-flag_sl { background-position: -520px -260px; }
-.emoji-flag_sm { background-position: -520px -280px; }
-.emoji-flag_sn { background-position: -520px -300px; }
-.emoji-flag_so { background-position: -520px -320px; }
-.emoji-flag_sr { background-position: -520px -340px; }
-.emoji-flag_ss { background-position: -520px -360px; }
-.emoji-flag_st { background-position: -520px -380px; }
-.emoji-flag_sv { background-position: -520px -400px; }
-.emoji-flag_sx { background-position: -520px -420px; }
-.emoji-flag_sy { background-position: -520px -440px; }
-.emoji-flag_sz { background-position: -520px -460px; }
-.emoji-flag_ta { background-position: -520px -480px; }
-.emoji-flag_tc { background-position: -520px -500px; }
-.emoji-flag_td { background-position: 0 -520px; }
-.emoji-flag_tf { background-position: -20px -520px; }
-.emoji-flag_tg { background-position: -40px -520px; }
-.emoji-flag_th { background-position: -60px -520px; }
-.emoji-flag_tj { background-position: -80px -520px; }
-.emoji-flag_tk { background-position: -100px -520px; }
-.emoji-flag_tl { background-position: -120px -520px; }
-.emoji-flag_tm { background-position: -140px -520px; }
-.emoji-flag_tn { background-position: -160px -520px; }
-.emoji-flag_to { background-position: -180px -520px; }
-.emoji-flag_tr { background-position: -200px -520px; }
-.emoji-flag_tt { background-position: -220px -520px; }
-.emoji-flag_tv { background-position: -240px -520px; }
-.emoji-flag_tw { background-position: -260px -520px; }
-.emoji-flag_tz { background-position: -280px -520px; }
-.emoji-flag_ua { background-position: -300px -520px; }
-.emoji-flag_ug { background-position: -320px -520px; }
-.emoji-flag_um { background-position: -340px -520px; }
-.emoji-flag_us { background-position: -360px -520px; }
-.emoji-flag_uy { background-position: -380px -520px; }
-.emoji-flag_uz { background-position: -400px -520px; }
-.emoji-flag_va { background-position: -420px -520px; }
-.emoji-flag_vc { background-position: -440px -520px; }
-.emoji-flag_ve { background-position: -460px -520px; }
-.emoji-flag_vg { background-position: -480px -520px; }
-.emoji-flag_vi { background-position: -500px -520px; }
-.emoji-flag_vn { background-position: -520px -520px; }
-.emoji-flag_vu { background-position: -540px 0; }
-.emoji-flag_wf { background-position: -540px -20px; }
-.emoji-flag_white { background-position: -540px -40px; }
-.emoji-flag_ws { background-position: -540px -60px; }
-.emoji-flag_xk { background-position: -540px -80px; }
-.emoji-flag_ye { background-position: -540px -100px; }
-.emoji-flag_yt { background-position: -540px -120px; }
-.emoji-flag_za { background-position: -540px -140px; }
-.emoji-flag_zm { background-position: -540px -160px; }
-.emoji-flag_zw { background-position: -540px -180px; }
-.emoji-flags { background-position: -540px -200px; }
-.emoji-flashlight { background-position: -540px -220px; }
-.emoji-fleur-de-lis { background-position: -540px -240px; }
-.emoji-floppy_disk { background-position: -540px -260px; }
-.emoji-flower_playing_cards { background-position: -540px -280px; }
-.emoji-flushed { background-position: -540px -300px; }
-.emoji-fog { background-position: -540px -320px; }
-.emoji-foggy { background-position: -540px -340px; }
-.emoji-football { background-position: -540px -360px; }
-.emoji-footprints { background-position: -540px -380px; }
-.emoji-fork_and_knife { background-position: -540px -400px; }
-.emoji-fork_knife_plate { background-position: -540px -420px; }
-.emoji-fountain { background-position: -540px -440px; }
-.emoji-four { background-position: -540px -460px; }
-.emoji-four_leaf_clover { background-position: -540px -480px; }
-.emoji-fox { background-position: -540px -500px; }
-.emoji-frame_photo { background-position: -540px -520px; }
-.emoji-free { background-position: 0 -540px; }
-.emoji-french_bread { background-position: -20px -540px; }
-.emoji-fried_shrimp { background-position: -40px -540px; }
-.emoji-fries { background-position: -60px -540px; }
-.emoji-frog { background-position: -80px -540px; }
-.emoji-frowning { background-position: -100px -540px; }
-.emoji-frowning2 { background-position: -120px -540px; }
-.emoji-fuelpump { background-position: -140px -540px; }
-.emoji-full_moon { background-position: -160px -540px; }
-.emoji-full_moon_with_face { background-position: -180px -540px; }
-.emoji-game_die { background-position: -200px -540px; }
-.emoji-gear { background-position: -220px -540px; }
-.emoji-gem { background-position: -240px -540px; }
-.emoji-gemini { background-position: -260px -540px; }
-.emoji-ghost { background-position: -280px -540px; }
-.emoji-gift { background-position: -300px -540px; }
-.emoji-gift_heart { background-position: -320px -540px; }
-.emoji-girl { background-position: -340px -540px; }
-.emoji-girl_tone1 { background-position: -360px -540px; }
-.emoji-girl_tone2 { background-position: -380px -540px; }
-.emoji-girl_tone3 { background-position: -400px -540px; }
-.emoji-girl_tone4 { background-position: -420px -540px; }
-.emoji-girl_tone5 { background-position: -440px -540px; }
-.emoji-globe_with_meridians { background-position: -460px -540px; }
-.emoji-goal { background-position: -480px -540px; }
-.emoji-goat { background-position: -500px -540px; }
-.emoji-golf { background-position: -520px -540px; }
-.emoji-golfer { background-position: -540px -540px; }
-.emoji-gorilla { background-position: -560px 0; }
-.emoji-grapes { background-position: -560px -20px; }
-.emoji-green_apple { background-position: -560px -40px; }
-.emoji-green_book { background-position: -560px -60px; }
-.emoji-green_heart { background-position: -560px -80px; }
-.emoji-grey_exclamation { background-position: -560px -100px; }
-.emoji-grey_question { background-position: -560px -120px; }
-.emoji-grimacing { background-position: -560px -140px; }
-.emoji-grin { background-position: -560px -160px; }
-.emoji-grinning { background-position: -560px -180px; }
-.emoji-guardsman { background-position: -560px -200px; }
-.emoji-guardsman_tone1 { background-position: -560px -220px; }
-.emoji-guardsman_tone2 { background-position: -560px -240px; }
-.emoji-guardsman_tone3 { background-position: -560px -260px; }
-.emoji-guardsman_tone4 { background-position: -560px -280px; }
-.emoji-guardsman_tone5 { background-position: -560px -300px; }
-.emoji-guitar { background-position: -560px -320px; }
-.emoji-gun { background-position: -560px -340px; }
-.emoji-haircut { background-position: -560px -360px; }
-.emoji-haircut_tone1 { background-position: -560px -380px; }
-.emoji-haircut_tone2 { background-position: -560px -400px; }
-.emoji-haircut_tone3 { background-position: -560px -420px; }
-.emoji-haircut_tone4 { background-position: -560px -440px; }
-.emoji-haircut_tone5 { background-position: -560px -460px; }
-.emoji-hamburger { background-position: -560px -480px; }
-.emoji-hammer { background-position: -560px -500px; }
-.emoji-hammer_pick { background-position: -560px -520px; }
-.emoji-hamster { background-position: -560px -540px; }
-.emoji-hand_splayed { background-position: 0 -560px; }
-.emoji-hand_splayed_tone1 { background-position: -20px -560px; }
-.emoji-hand_splayed_tone2 { background-position: -40px -560px; }
-.emoji-hand_splayed_tone3 { background-position: -60px -560px; }
-.emoji-hand_splayed_tone4 { background-position: -80px -560px; }
-.emoji-hand_splayed_tone5 { background-position: -100px -560px; }
-.emoji-handbag { background-position: -120px -560px; }
-.emoji-handball { background-position: -140px -560px; }
-.emoji-handball_tone1 { background-position: -160px -560px; }
-.emoji-handball_tone2 { background-position: -180px -560px; }
-.emoji-handball_tone3 { background-position: -200px -560px; }
-.emoji-handball_tone4 { background-position: -220px -560px; }
-.emoji-handball_tone5 { background-position: -240px -560px; }
-.emoji-handshake { background-position: -260px -560px; }
-.emoji-handshake_tone1 { background-position: -280px -560px; }
-.emoji-handshake_tone2 { background-position: -300px -560px; }
-.emoji-handshake_tone3 { background-position: -320px -560px; }
-.emoji-handshake_tone4 { background-position: -340px -560px; }
-.emoji-handshake_tone5 { background-position: -360px -560px; }
-.emoji-hash { background-position: -380px -560px; }
-.emoji-hatched_chick { background-position: -400px -560px; }
-.emoji-hatching_chick { background-position: -420px -560px; }
-.emoji-head_bandage { background-position: -440px -560px; }
-.emoji-headphones { background-position: -460px -560px; }
-.emoji-hear_no_evil { background-position: -480px -560px; }
-.emoji-heart { background-position: -500px -560px; }
-.emoji-heart_decoration { background-position: -520px -560px; }
-.emoji-heart_exclamation { background-position: -540px -560px; }
-.emoji-heart_eyes { background-position: -560px -560px; }
-.emoji-heart_eyes_cat { background-position: -580px 0; }
-.emoji-heartbeat { background-position: -580px -20px; }
-.emoji-heartpulse { background-position: -580px -40px; }
-.emoji-hearts { background-position: -580px -60px; }
-.emoji-heavy_check_mark { background-position: -580px -80px; }
-.emoji-heavy_division_sign { background-position: -580px -100px; }
-.emoji-heavy_dollar_sign { background-position: -580px -120px; }
-.emoji-heavy_minus_sign { background-position: -580px -140px; }
-.emoji-heavy_multiplication_x { background-position: -580px -160px; }
-.emoji-heavy_plus_sign { background-position: -580px -180px; }
-.emoji-helicopter { background-position: -580px -200px; }
-.emoji-helmet_with_cross { background-position: -580px -220px; }
-.emoji-herb { background-position: -580px -240px; }
-.emoji-hibiscus { background-position: -580px -260px; }
-.emoji-high_brightness { background-position: -580px -280px; }
-.emoji-high_heel { background-position: -580px -300px; }
-.emoji-hockey { background-position: -580px -320px; }
-.emoji-hole { background-position: -580px -340px; }
-.emoji-homes { background-position: -580px -360px; }
-.emoji-honey_pot { background-position: -580px -380px; }
-.emoji-horse { background-position: -580px -400px; }
-.emoji-horse_racing { background-position: -580px -420px; }
-.emoji-horse_racing_tone1 { background-position: -580px -440px; }
-.emoji-horse_racing_tone2 { background-position: -580px -460px; }
-.emoji-horse_racing_tone3 { background-position: -580px -480px; }
-.emoji-horse_racing_tone4 { background-position: -580px -500px; }
-.emoji-horse_racing_tone5 { background-position: -580px -520px; }
-.emoji-hospital { background-position: -580px -540px; }
-.emoji-hot_pepper { background-position: -580px -560px; }
-.emoji-hotdog { background-position: 0 -580px; }
-.emoji-hotel { background-position: -20px -580px; }
-.emoji-hotsprings { background-position: -40px -580px; }
-.emoji-hourglass { background-position: -60px -580px; }
-.emoji-hourglass_flowing_sand { background-position: -80px -580px; }
-.emoji-house { background-position: -100px -580px; }
-.emoji-house_abandoned { background-position: -120px -580px; }
-.emoji-house_with_garden { background-position: -140px -580px; }
-.emoji-hugging { background-position: -160px -580px; }
-.emoji-hushed { background-position: -180px -580px; }
-.emoji-ice_cream { background-position: -200px -580px; }
-.emoji-ice_skate { background-position: -220px -580px; }
-.emoji-icecream { background-position: -240px -580px; }
-.emoji-id { background-position: -260px -580px; }
-.emoji-ideograph_advantage { background-position: -280px -580px; }
-.emoji-imp { background-position: -300px -580px; }
-.emoji-inbox_tray { background-position: -320px -580px; }
-.emoji-incoming_envelope { background-position: -340px -580px; }
-.emoji-information_desk_person { background-position: -360px -580px; }
-.emoji-information_desk_person_tone1 { background-position: -380px -580px; }
-.emoji-information_desk_person_tone2 { background-position: -400px -580px; }
-.emoji-information_desk_person_tone3 { background-position: -420px -580px; }
-.emoji-information_desk_person_tone4 { background-position: -440px -580px; }
-.emoji-information_desk_person_tone5 { background-position: -460px -580px; }
-.emoji-information_source { background-position: -480px -580px; }
-.emoji-innocent { background-position: -500px -580px; }
-.emoji-interrobang { background-position: -520px -580px; }
-.emoji-iphone { background-position: -540px -580px; }
-.emoji-island { background-position: -560px -580px; }
-.emoji-izakaya_lantern { background-position: -580px -580px; }
-.emoji-jack_o_lantern { background-position: -600px 0; }
-.emoji-japan { background-position: -600px -20px; }
-.emoji-japanese_castle { background-position: -600px -40px; }
-.emoji-japanese_goblin { background-position: -600px -60px; }
-.emoji-japanese_ogre { background-position: -600px -80px; }
-.emoji-jeans { background-position: -600px -100px; }
-.emoji-joy { background-position: -600px -120px; }
-.emoji-joy_cat { background-position: -600px -140px; }
-.emoji-joystick { background-position: -600px -160px; }
-.emoji-juggling { background-position: -600px -180px; }
-.emoji-juggling_tone1 { background-position: -600px -200px; }
-.emoji-juggling_tone2 { background-position: -600px -220px; }
-.emoji-juggling_tone3 { background-position: -600px -240px; }
-.emoji-juggling_tone4 { background-position: -600px -260px; }
-.emoji-juggling_tone5 { background-position: -600px -280px; }
-.emoji-kaaba { background-position: -600px -300px; }
-.emoji-key { background-position: -600px -320px; }
-.emoji-key2 { background-position: -600px -340px; }
-.emoji-keyboard { background-position: -600px -360px; }
-.emoji-kimono { background-position: -600px -380px; }
-.emoji-kiss { background-position: -600px -400px; }
-.emoji-kiss_mm { background-position: -600px -420px; }
-.emoji-kiss_ww { background-position: -600px -440px; }
-.emoji-kissing { background-position: -600px -460px; }
-.emoji-kissing_cat { background-position: -600px -480px; }
-.emoji-kissing_closed_eyes { background-position: -600px -500px; }
-.emoji-kissing_heart { background-position: -600px -520px; }
-.emoji-kissing_smiling_eyes { background-position: -600px -540px; }
-.emoji-kiwi { background-position: -600px -560px; }
-.emoji-knife { background-position: -600px -580px; }
-.emoji-koala { background-position: 0 -600px; }
-.emoji-koko { background-position: -20px -600px; }
-.emoji-label { background-position: -40px -600px; }
-.emoji-large_blue_circle { background-position: -60px -600px; }
-.emoji-large_blue_diamond { background-position: -80px -600px; }
-.emoji-large_orange_diamond { background-position: -100px -600px; }
-.emoji-last_quarter_moon { background-position: -120px -600px; }
-.emoji-last_quarter_moon_with_face { background-position: -140px -600px; }
-.emoji-laughing { background-position: -160px -600px; }
-.emoji-leaves { background-position: -180px -600px; }
-.emoji-ledger { background-position: -200px -600px; }
-.emoji-left_facing_fist { background-position: -220px -600px; }
-.emoji-left_facing_fist_tone1 { background-position: -240px -600px; }
-.emoji-left_facing_fist_tone2 { background-position: -260px -600px; }
-.emoji-left_facing_fist_tone3 { background-position: -280px -600px; }
-.emoji-left_facing_fist_tone4 { background-position: -300px -600px; }
-.emoji-left_facing_fist_tone5 { background-position: -320px -600px; }
-.emoji-left_luggage { background-position: -340px -600px; }
-.emoji-left_right_arrow { background-position: -360px -600px; }
-.emoji-leftwards_arrow_with_hook { background-position: -380px -600px; }
-.emoji-lemon { background-position: -400px -600px; }
-.emoji-leo { background-position: -420px -600px; }
-.emoji-leopard { background-position: -440px -600px; }
-.emoji-level_slider { background-position: -460px -600px; }
-.emoji-levitate { background-position: -480px -600px; }
-.emoji-libra { background-position: -500px -600px; }
-.emoji-lifter { background-position: -520px -600px; }
-.emoji-lifter_tone1 { background-position: -540px -600px; }
-.emoji-lifter_tone2 { background-position: -560px -600px; }
-.emoji-lifter_tone3 { background-position: -580px -600px; }
-.emoji-lifter_tone4 { background-position: -600px -600px; }
-.emoji-lifter_tone5 { background-position: -620px 0; }
-.emoji-light_rail { background-position: -620px -20px; }
-.emoji-link { background-position: -620px -40px; }
-.emoji-lion_face { background-position: -620px -60px; }
-.emoji-lips { background-position: -620px -80px; }
-.emoji-lipstick { background-position: -620px -100px; }
-.emoji-lizard { background-position: -620px -120px; }
-.emoji-lock { background-position: -620px -140px; }
-.emoji-lock_with_ink_pen { background-position: -620px -160px; }
-.emoji-lollipop { background-position: -620px -180px; }
-.emoji-loop { background-position: -620px -200px; }
-.emoji-loud_sound { background-position: -620px -220px; }
-.emoji-loudspeaker { background-position: -620px -240px; }
-.emoji-love_hotel { background-position: -620px -260px; }
-.emoji-love_letter { background-position: -620px -280px; }
-.emoji-low_brightness { background-position: -620px -300px; }
-.emoji-lying_face { background-position: -620px -320px; }
-.emoji-m { background-position: -620px -340px; }
-.emoji-mag { background-position: -620px -360px; }
-.emoji-mag_right { background-position: -620px -380px; }
-.emoji-mahjong { background-position: -620px -400px; }
-.emoji-mailbox { background-position: -620px -420px; }
-.emoji-mailbox_closed { background-position: -620px -440px; }
-.emoji-mailbox_with_mail { background-position: -620px -460px; }
-.emoji-mailbox_with_no_mail { background-position: -620px -480px; }
-.emoji-man { background-position: -620px -500px; }
-.emoji-man_dancing { background-position: -620px -520px; }
-.emoji-man_dancing_tone1 { background-position: -620px -540px; }
-.emoji-man_dancing_tone2 { background-position: -620px -560px; }
-.emoji-man_dancing_tone3 { background-position: -620px -580px; }
-.emoji-man_dancing_tone4 { background-position: -620px -600px; }
-.emoji-man_dancing_tone5 { background-position: 0 -620px; }
-.emoji-man_in_tuxedo { background-position: -20px -620px; }
-.emoji-man_in_tuxedo_tone1 { background-position: -40px -620px; }
-.emoji-man_in_tuxedo_tone2 { background-position: -60px -620px; }
-.emoji-man_in_tuxedo_tone3 { background-position: -80px -620px; }
-.emoji-man_in_tuxedo_tone4 { background-position: -100px -620px; }
-.emoji-man_in_tuxedo_tone5 { background-position: -120px -620px; }
-.emoji-man_tone1 { background-position: -140px -620px; }
-.emoji-man_tone2 { background-position: -160px -620px; }
-.emoji-man_tone3 { background-position: -180px -620px; }
-.emoji-man_tone4 { background-position: -200px -620px; }
-.emoji-man_tone5 { background-position: -220px -620px; }
-.emoji-man_with_gua_pi_mao { background-position: -240px -620px; }
-.emoji-man_with_gua_pi_mao_tone1 { background-position: -260px -620px; }
-.emoji-man_with_gua_pi_mao_tone2 { background-position: -280px -620px; }
-.emoji-man_with_gua_pi_mao_tone3 { background-position: -300px -620px; }
-.emoji-man_with_gua_pi_mao_tone4 { background-position: -320px -620px; }
-.emoji-man_with_gua_pi_mao_tone5 { background-position: -340px -620px; }
-.emoji-man_with_turban { background-position: -360px -620px; }
-.emoji-man_with_turban_tone1 { background-position: -380px -620px; }
-.emoji-man_with_turban_tone2 { background-position: -400px -620px; }
-.emoji-man_with_turban_tone3 { background-position: -420px -620px; }
-.emoji-man_with_turban_tone4 { background-position: -440px -620px; }
-.emoji-man_with_turban_tone5 { background-position: -460px -620px; }
-.emoji-mans_shoe { background-position: -480px -620px; }
-.emoji-map { background-position: -500px -620px; }
-.emoji-maple_leaf { background-position: -520px -620px; }
-.emoji-martial_arts_uniform { background-position: -540px -620px; }
-.emoji-mask { background-position: -560px -620px; }
-.emoji-massage { background-position: -580px -620px; }
-.emoji-massage_tone1 { background-position: -600px -620px; }
-.emoji-massage_tone2 { background-position: -620px -620px; }
-.emoji-massage_tone3 { background-position: -640px 0; }
-.emoji-massage_tone4 { background-position: -640px -20px; }
-.emoji-massage_tone5 { background-position: -640px -40px; }
-.emoji-meat_on_bone { background-position: -640px -60px; }
-.emoji-medal { background-position: -640px -80px; }
-.emoji-mega { background-position: -640px -100px; }
-.emoji-melon { background-position: -640px -120px; }
-.emoji-menorah { background-position: -640px -140px; }
-.emoji-mens { background-position: -640px -160px; }
-.emoji-metal { background-position: -640px -180px; }
-.emoji-metal_tone1 { background-position: -640px -200px; }
-.emoji-metal_tone2 { background-position: -640px -220px; }
-.emoji-metal_tone3 { background-position: -640px -240px; }
-.emoji-metal_tone4 { background-position: -640px -260px; }
-.emoji-metal_tone5 { background-position: -640px -280px; }
-.emoji-metro { background-position: -640px -300px; }
-.emoji-microphone { background-position: -640px -320px; }
-.emoji-microphone2 { background-position: -640px -340px; }
-.emoji-microscope { background-position: -640px -360px; }
-.emoji-middle_finger { background-position: -640px -380px; }
-.emoji-middle_finger_tone1 { background-position: -640px -400px; }
-.emoji-middle_finger_tone2 { background-position: -640px -420px; }
-.emoji-middle_finger_tone3 { background-position: -640px -440px; }
-.emoji-middle_finger_tone4 { background-position: -640px -460px; }
-.emoji-middle_finger_tone5 { background-position: -640px -480px; }
-.emoji-military_medal { background-position: -640px -500px; }
-.emoji-milk { background-position: -640px -520px; }
-.emoji-milky_way { background-position: -640px -540px; }
-.emoji-minibus { background-position: -640px -560px; }
-.emoji-minidisc { background-position: -640px -580px; }
-.emoji-mobile_phone_off { background-position: -640px -600px; }
-.emoji-money_mouth { background-position: -640px -620px; }
-.emoji-money_with_wings { background-position: 0 -640px; }
-.emoji-moneybag { background-position: -20px -640px; }
-.emoji-monkey { background-position: -40px -640px; }
-.emoji-monkey_face { background-position: -60px -640px; }
-.emoji-monorail { background-position: -80px -640px; }
-.emoji-mortar_board { background-position: -100px -640px; }
-.emoji-mosque { background-position: -120px -640px; }
-.emoji-motor_scooter { background-position: -140px -640px; }
-.emoji-motorboat { background-position: -160px -640px; }
-.emoji-motorcycle { background-position: -180px -640px; }
-.emoji-motorway { background-position: -200px -640px; }
-.emoji-mount_fuji { background-position: -220px -640px; }
-.emoji-mountain { background-position: -240px -640px; }
-.emoji-mountain_bicyclist { background-position: -260px -640px; }
-.emoji-mountain_bicyclist_tone1 { background-position: -280px -640px; }
-.emoji-mountain_bicyclist_tone2 { background-position: -300px -640px; }
-.emoji-mountain_bicyclist_tone3 { background-position: -320px -640px; }
-.emoji-mountain_bicyclist_tone4 { background-position: -340px -640px; }
-.emoji-mountain_bicyclist_tone5 { background-position: -360px -640px; }
-.emoji-mountain_cableway { background-position: -380px -640px; }
-.emoji-mountain_railway { background-position: -400px -640px; }
-.emoji-mountain_snow { background-position: -420px -640px; }
-.emoji-mouse { background-position: -440px -640px; }
-.emoji-mouse2 { background-position: -460px -640px; }
-.emoji-mouse_three_button { background-position: -480px -640px; }
-.emoji-movie_camera { background-position: -500px -640px; }
-.emoji-moyai { background-position: -520px -640px; }
-.emoji-mrs_claus { background-position: -540px -640px; }
-.emoji-mrs_claus_tone1 { background-position: -560px -640px; }
-.emoji-mrs_claus_tone2 { background-position: -580px -640px; }
-.emoji-mrs_claus_tone3 { background-position: -600px -640px; }
-.emoji-mrs_claus_tone4 { background-position: -620px -640px; }
-.emoji-mrs_claus_tone5 { background-position: -640px -640px; }
-.emoji-muscle { background-position: -660px 0; }
-.emoji-muscle_tone1 { background-position: -660px -20px; }
-.emoji-muscle_tone2 { background-position: -660px -40px; }
-.emoji-muscle_tone3 { background-position: -660px -60px; }
-.emoji-muscle_tone4 { background-position: -660px -80px; }
-.emoji-muscle_tone5 { background-position: -660px -100px; }
-.emoji-mushroom { background-position: -660px -120px; }
-.emoji-musical_keyboard { background-position: -660px -140px; }
-.emoji-musical_note { background-position: -660px -160px; }
-.emoji-musical_score { background-position: -660px -180px; }
-.emoji-mute { background-position: -660px -200px; }
-.emoji-nail_care { background-position: -660px -220px; }
-.emoji-nail_care_tone1 { background-position: -660px -240px; }
-.emoji-nail_care_tone2 { background-position: -660px -260px; }
-.emoji-nail_care_tone3 { background-position: -660px -280px; }
-.emoji-nail_care_tone4 { background-position: -660px -300px; }
-.emoji-nail_care_tone5 { background-position: -660px -320px; }
-.emoji-name_badge { background-position: -660px -340px; }
-.emoji-nauseated_face { background-position: -660px -360px; }
-.emoji-necktie { background-position: -660px -380px; }
-.emoji-negative_squared_cross_mark { background-position: -660px -400px; }
-.emoji-nerd { background-position: -660px -420px; }
-.emoji-neutral_face { background-position: -660px -440px; }
-.emoji-new { background-position: -660px -460px; }
-.emoji-new_moon { background-position: -660px -480px; }
-.emoji-new_moon_with_face { background-position: -660px -500px; }
-.emoji-newspaper { background-position: -660px -520px; }
-.emoji-newspaper2 { background-position: -660px -540px; }
-.emoji-ng { background-position: -660px -560px; }
-.emoji-night_with_stars { background-position: -660px -580px; }
-.emoji-nine { background-position: -660px -600px; }
-.emoji-no_bell { background-position: -660px -620px; }
-.emoji-no_bicycles { background-position: -660px -640px; }
-.emoji-no_entry { background-position: 0 -660px; }
-.emoji-no_entry_sign { background-position: -20px -660px; }
-.emoji-no_good { background-position: -40px -660px; }
-.emoji-no_good_tone1 { background-position: -60px -660px; }
-.emoji-no_good_tone2 { background-position: -80px -660px; }
-.emoji-no_good_tone3 { background-position: -100px -660px; }
-.emoji-no_good_tone4 { background-position: -120px -660px; }
-.emoji-no_good_tone5 { background-position: -140px -660px; }
-.emoji-no_mobile_phones { background-position: -160px -660px; }
-.emoji-no_mouth { background-position: -180px -660px; }
-.emoji-no_pedestrians { background-position: -200px -660px; }
-.emoji-no_smoking { background-position: -220px -660px; }
-.emoji-non-potable_water { background-position: -240px -660px; }
-.emoji-nose { background-position: -260px -660px; }
-.emoji-nose_tone1 { background-position: -280px -660px; }
-.emoji-nose_tone2 { background-position: -300px -660px; }
-.emoji-nose_tone3 { background-position: -320px -660px; }
-.emoji-nose_tone4 { background-position: -340px -660px; }
-.emoji-nose_tone5 { background-position: -360px -660px; }
-.emoji-notebook { background-position: -380px -660px; }
-.emoji-notebook_with_decorative_cover { background-position: -400px -660px; }
-.emoji-notepad_spiral { background-position: -420px -660px; }
-.emoji-notes { background-position: -440px -660px; }
-.emoji-nut_and_bolt { background-position: -460px -660px; }
-.emoji-o { background-position: -480px -660px; }
-.emoji-o2 { background-position: -500px -660px; }
-.emoji-ocean { background-position: -520px -660px; }
-.emoji-octagonal_sign { background-position: -540px -660px; }
-.emoji-octopus { background-position: -560px -660px; }
-.emoji-oden { background-position: -580px -660px; }
-.emoji-office { background-position: -600px -660px; }
-.emoji-oil { background-position: -620px -660px; }
-.emoji-ok { background-position: -640px -660px; }
-.emoji-ok_hand { background-position: -660px -660px; }
-.emoji-ok_hand_tone1 { background-position: -680px 0; }
-.emoji-ok_hand_tone2 { background-position: -680px -20px; }
-.emoji-ok_hand_tone3 { background-position: -680px -40px; }
-.emoji-ok_hand_tone4 { background-position: -680px -60px; }
-.emoji-ok_hand_tone5 { background-position: -680px -80px; }
-.emoji-ok_woman { background-position: -680px -100px; }
-.emoji-ok_woman_tone1 { background-position: -680px -120px; }
-.emoji-ok_woman_tone2 { background-position: -680px -140px; }
-.emoji-ok_woman_tone3 { background-position: -680px -160px; }
-.emoji-ok_woman_tone4 { background-position: -680px -180px; }
-.emoji-ok_woman_tone5 { background-position: -680px -200px; }
-.emoji-older_man { background-position: -680px -220px; }
-.emoji-older_man_tone1 { background-position: -680px -240px; }
-.emoji-older_man_tone2 { background-position: -680px -260px; }
-.emoji-older_man_tone3 { background-position: -680px -280px; }
-.emoji-older_man_tone4 { background-position: -680px -300px; }
-.emoji-older_man_tone5 { background-position: -680px -320px; }
-.emoji-older_woman { background-position: -680px -340px; }
-.emoji-older_woman_tone1 { background-position: -680px -360px; }
-.emoji-older_woman_tone2 { background-position: -680px -380px; }
-.emoji-older_woman_tone3 { background-position: -680px -400px; }
-.emoji-older_woman_tone4 { background-position: -680px -420px; }
-.emoji-older_woman_tone5 { background-position: -680px -440px; }
-.emoji-om_symbol { background-position: -680px -460px; }
-.emoji-on { background-position: -680px -480px; }
-.emoji-oncoming_automobile { background-position: -680px -500px; }
-.emoji-oncoming_bus { background-position: -680px -520px; }
-.emoji-oncoming_police_car { background-position: -680px -540px; }
-.emoji-oncoming_taxi { background-position: -680px -560px; }
-.emoji-one { background-position: -680px -580px; }
-.emoji-open_file_folder { background-position: -680px -600px; }
-.emoji-open_hands { background-position: -680px -620px; }
-.emoji-open_hands_tone1 { background-position: -680px -640px; }
-.emoji-open_hands_tone2 { background-position: -680px -660px; }
-.emoji-open_hands_tone3 { background-position: 0 -680px; }
-.emoji-open_hands_tone4 { background-position: -20px -680px; }
-.emoji-open_hands_tone5 { background-position: -40px -680px; }
-.emoji-open_mouth { background-position: -60px -680px; }
-.emoji-ophiuchus { background-position: -80px -680px; }
-.emoji-orange_book { background-position: -100px -680px; }
-.emoji-orthodox_cross { background-position: -120px -680px; }
-.emoji-outbox_tray { background-position: -140px -680px; }
-.emoji-owl { background-position: -160px -680px; }
-.emoji-ox { background-position: -180px -680px; }
-.emoji-package { background-position: -200px -680px; }
-.emoji-page_facing_up { background-position: -220px -680px; }
-.emoji-page_with_curl { background-position: -240px -680px; }
-.emoji-pager { background-position: -260px -680px; }
-.emoji-paintbrush { background-position: -280px -680px; }
-.emoji-palm_tree { background-position: -300px -680px; }
-.emoji-pancakes { background-position: -320px -680px; }
-.emoji-panda_face { background-position: -340px -680px; }
-.emoji-paperclip { background-position: -360px -680px; }
-.emoji-paperclips { background-position: -380px -680px; }
-.emoji-park { background-position: -400px -680px; }
-.emoji-parking { background-position: -420px -680px; }
-.emoji-part_alternation_mark { background-position: -440px -680px; }
-.emoji-partly_sunny { background-position: -460px -680px; }
-.emoji-passport_control { background-position: -480px -680px; }
-.emoji-pause_button { background-position: -500px -680px; }
-.emoji-peace { background-position: -520px -680px; }
-.emoji-peach { background-position: -540px -680px; }
-.emoji-peanuts { background-position: -560px -680px; }
-.emoji-pear { background-position: -580px -680px; }
-.emoji-pen_ballpoint { background-position: -600px -680px; }
-.emoji-pen_fountain { background-position: -620px -680px; }
-.emoji-pencil { background-position: -640px -680px; }
-.emoji-pencil2 { background-position: -660px -680px; }
-.emoji-penguin { background-position: -680px -680px; }
-.emoji-pensive { background-position: -700px 0; }
-.emoji-performing_arts { background-position: -700px -20px; }
-.emoji-persevere { background-position: -700px -40px; }
-.emoji-person_frowning { background-position: -700px -60px; }
-.emoji-person_frowning_tone1 { background-position: -700px -80px; }
-.emoji-person_frowning_tone2 { background-position: -700px -100px; }
-.emoji-person_frowning_tone3 { background-position: -700px -120px; }
-.emoji-person_frowning_tone4 { background-position: -700px -140px; }
-.emoji-person_frowning_tone5 { background-position: -700px -160px; }
-.emoji-person_with_blond_hair { background-position: -700px -180px; }
-.emoji-person_with_blond_hair_tone1 { background-position: -700px -200px; }
-.emoji-person_with_blond_hair_tone2 { background-position: -700px -220px; }
-.emoji-person_with_blond_hair_tone3 { background-position: -700px -240px; }
-.emoji-person_with_blond_hair_tone4 { background-position: -700px -260px; }
-.emoji-person_with_blond_hair_tone5 { background-position: -700px -280px; }
-.emoji-person_with_pouting_face { background-position: -700px -300px; }
-.emoji-person_with_pouting_face_tone1 { background-position: -700px -320px; }
-.emoji-person_with_pouting_face_tone2 { background-position: -700px -340px; }
-.emoji-person_with_pouting_face_tone3 { background-position: -700px -360px; }
-.emoji-person_with_pouting_face_tone4 { background-position: -700px -380px; }
-.emoji-person_with_pouting_face_tone5 { background-position: -700px -400px; }
-.emoji-pick { background-position: -700px -420px; }
-.emoji-pig { background-position: -700px -440px; }
-.emoji-pig2 { background-position: -700px -460px; }
-.emoji-pig_nose { background-position: -700px -480px; }
-.emoji-pill { background-position: -700px -500px; }
-.emoji-pineapple { background-position: -700px -520px; }
-.emoji-ping_pong { background-position: -700px -540px; }
-.emoji-pisces { background-position: -700px -560px; }
-.emoji-pizza { background-position: -700px -580px; }
-.emoji-place_of_worship { background-position: -700px -600px; }
-.emoji-play_pause { background-position: -700px -620px; }
-.emoji-point_down { background-position: -700px -640px; }
-.emoji-point_down_tone1 { background-position: -700px -660px; }
-.emoji-point_down_tone2 { background-position: -700px -680px; }
-.emoji-point_down_tone3 { background-position: 0 -700px; }
-.emoji-point_down_tone4 { background-position: -20px -700px; }
-.emoji-point_down_tone5 { background-position: -40px -700px; }
-.emoji-point_left { background-position: -60px -700px; }
-.emoji-point_left_tone1 { background-position: -80px -700px; }
-.emoji-point_left_tone2 { background-position: -100px -700px; }
-.emoji-point_left_tone3 { background-position: -120px -700px; }
-.emoji-point_left_tone4 { background-position: -140px -700px; }
-.emoji-point_left_tone5 { background-position: -160px -700px; }
-.emoji-point_right { background-position: -180px -700px; }
-.emoji-point_right_tone1 { background-position: -200px -700px; }
-.emoji-point_right_tone2 { background-position: -220px -700px; }
-.emoji-point_right_tone3 { background-position: -240px -700px; }
-.emoji-point_right_tone4 { background-position: -260px -700px; }
-.emoji-point_right_tone5 { background-position: -280px -700px; }
-.emoji-point_up { background-position: -300px -700px; }
-.emoji-point_up_2 { background-position: -320px -700px; }
-.emoji-point_up_2_tone1 { background-position: -340px -700px; }
-.emoji-point_up_2_tone2 { background-position: -360px -700px; }
-.emoji-point_up_2_tone3 { background-position: -380px -700px; }
-.emoji-point_up_2_tone4 { background-position: -400px -700px; }
-.emoji-point_up_2_tone5 { background-position: -420px -700px; }
-.emoji-point_up_tone1 { background-position: -440px -700px; }
-.emoji-point_up_tone2 { background-position: -460px -700px; }
-.emoji-point_up_tone3 { background-position: -480px -700px; }
-.emoji-point_up_tone4 { background-position: -500px -700px; }
-.emoji-point_up_tone5 { background-position: -520px -700px; }
-.emoji-police_car { background-position: -540px -700px; }
-.emoji-poodle { background-position: -560px -700px; }
-.emoji-poop { background-position: -580px -700px; }
-.emoji-popcorn { background-position: -600px -700px; }
-.emoji-post_office { background-position: -620px -700px; }
-.emoji-postal_horn { background-position: -640px -700px; }
-.emoji-postbox { background-position: -660px -700px; }
-.emoji-potable_water { background-position: -680px -700px; }
-.emoji-potato { background-position: -700px -700px; }
-.emoji-pouch { background-position: -720px 0; }
-.emoji-poultry_leg { background-position: -720px -20px; }
-.emoji-pound { background-position: -720px -40px; }
-.emoji-pouting_cat { background-position: -720px -60px; }
-.emoji-pray { background-position: -720px -80px; }
-.emoji-pray_tone1 { background-position: -720px -100px; }
-.emoji-pray_tone2 { background-position: -720px -120px; }
-.emoji-pray_tone3 { background-position: -720px -140px; }
-.emoji-pray_tone4 { background-position: -720px -160px; }
-.emoji-pray_tone5 { background-position: -720px -180px; }
-.emoji-prayer_beads { background-position: -720px -200px; }
-.emoji-pregnant_woman { background-position: -720px -220px; }
-.emoji-pregnant_woman_tone1 { background-position: -720px -240px; }
-.emoji-pregnant_woman_tone2 { background-position: -720px -260px; }
-.emoji-pregnant_woman_tone3 { background-position: -720px -280px; }
-.emoji-pregnant_woman_tone4 { background-position: -720px -300px; }
-.emoji-pregnant_woman_tone5 { background-position: -720px -320px; }
-.emoji-prince { background-position: -720px -340px; }
-.emoji-prince_tone1 { background-position: -720px -360px; }
-.emoji-prince_tone2 { background-position: -720px -380px; }
-.emoji-prince_tone3 { background-position: -720px -400px; }
-.emoji-prince_tone4 { background-position: -720px -420px; }
-.emoji-prince_tone5 { background-position: -720px -440px; }
-.emoji-princess { background-position: -720px -460px; }
-.emoji-princess_tone1 { background-position: -720px -480px; }
-.emoji-princess_tone2 { background-position: -720px -500px; }
-.emoji-princess_tone3 { background-position: -720px -520px; }
-.emoji-princess_tone4 { background-position: -720px -540px; }
-.emoji-princess_tone5 { background-position: -720px -560px; }
-.emoji-printer { background-position: -720px -580px; }
-.emoji-projector { background-position: -720px -600px; }
-.emoji-punch { background-position: -720px -620px; }
-.emoji-punch_tone1 { background-position: -720px -640px; }
-.emoji-punch_tone2 { background-position: -720px -660px; }
-.emoji-punch_tone3 { background-position: -720px -680px; }
-.emoji-punch_tone4 { background-position: -720px -700px; }
-.emoji-punch_tone5 { background-position: 0 -720px; }
-.emoji-purple_heart { background-position: -20px -720px; }
-.emoji-purse { background-position: -40px -720px; }
-.emoji-pushpin { background-position: -60px -720px; }
-.emoji-put_litter_in_its_place { background-position: -80px -720px; }
-.emoji-question { background-position: -100px -720px; }
-.emoji-rabbit { background-position: -120px -720px; }
-.emoji-rabbit2 { background-position: -140px -720px; }
-.emoji-race_car { background-position: -160px -720px; }
-.emoji-racehorse { background-position: -180px -720px; }
-.emoji-radio { background-position: -200px -720px; }
-.emoji-radio_button { background-position: -220px -720px; }
-.emoji-radioactive { background-position: -240px -720px; }
-.emoji-rage { background-position: -260px -720px; }
-.emoji-railway_car { background-position: -280px -720px; }
-.emoji-railway_track { background-position: -300px -720px; }
-.emoji-rainbow { background-position: -320px -720px; }
-.emoji-raised_back_of_hand { background-position: -340px -720px; }
-.emoji-raised_back_of_hand_tone1 { background-position: -360px -720px; }
-.emoji-raised_back_of_hand_tone2 { background-position: -380px -720px; }
-.emoji-raised_back_of_hand_tone3 { background-position: -400px -720px; }
-.emoji-raised_back_of_hand_tone4 { background-position: -420px -720px; }
-.emoji-raised_back_of_hand_tone5 { background-position: -440px -720px; }
-.emoji-raised_hand { background-position: -460px -720px; }
-.emoji-raised_hand_tone1 { background-position: -480px -720px; }
-.emoji-raised_hand_tone2 { background-position: -500px -720px; }
-.emoji-raised_hand_tone3 { background-position: -520px -720px; }
-.emoji-raised_hand_tone4 { background-position: -540px -720px; }
-.emoji-raised_hand_tone5 { background-position: -560px -720px; }
-.emoji-raised_hands { background-position: -580px -720px; }
-.emoji-raised_hands_tone1 { background-position: -600px -720px; }
-.emoji-raised_hands_tone2 { background-position: -620px -720px; }
-.emoji-raised_hands_tone3 { background-position: -640px -720px; }
-.emoji-raised_hands_tone4 { background-position: -660px -720px; }
-.emoji-raised_hands_tone5 { background-position: -680px -720px; }
-.emoji-raising_hand { background-position: -700px -720px; }
-.emoji-raising_hand_tone1 { background-position: -720px -720px; }
-.emoji-raising_hand_tone2 { background-position: -740px 0; }
-.emoji-raising_hand_tone3 { background-position: -740px -20px; }
-.emoji-raising_hand_tone4 { background-position: -740px -40px; }
-.emoji-raising_hand_tone5 { background-position: -740px -60px; }
-.emoji-ram { background-position: -740px -80px; }
-.emoji-ramen { background-position: -740px -100px; }
-.emoji-rat { background-position: -740px -120px; }
-.emoji-record_button { background-position: -740px -140px; }
-.emoji-recycle { background-position: -740px -160px; }
-.emoji-red_car { background-position: -740px -180px; }
-.emoji-red_circle { background-position: -740px -200px; }
-.emoji-registered { background-position: -740px -220px; }
-.emoji-relaxed { background-position: -740px -240px; }
-.emoji-relieved { background-position: -740px -260px; }
-.emoji-reminder_ribbon { background-position: -740px -280px; }
-.emoji-repeat { background-position: -740px -300px; }
-.emoji-repeat_one { background-position: -740px -320px; }
-.emoji-restroom { background-position: -740px -340px; }
-.emoji-revolving_hearts { background-position: -740px -360px; }
-.emoji-rewind { background-position: -740px -380px; }
-.emoji-rhino { background-position: -740px -400px; }
-.emoji-ribbon { background-position: -740px -420px; }
-.emoji-rice { background-position: -740px -440px; }
-.emoji-rice_ball { background-position: -740px -460px; }
-.emoji-rice_cracker { background-position: -740px -480px; }
-.emoji-rice_scene { background-position: -740px -500px; }
-.emoji-right_facing_fist { background-position: -740px -520px; }
-.emoji-right_facing_fist_tone1 { background-position: -740px -540px; }
-.emoji-right_facing_fist_tone2 { background-position: -740px -560px; }
-.emoji-right_facing_fist_tone3 { background-position: -740px -580px; }
-.emoji-right_facing_fist_tone4 { background-position: -740px -600px; }
-.emoji-right_facing_fist_tone5 { background-position: -740px -620px; }
-.emoji-ring { background-position: -740px -640px; }
-.emoji-robot { background-position: -740px -660px; }
-.emoji-rocket { background-position: -740px -680px; }
-.emoji-rofl { background-position: -740px -700px; }
-.emoji-roller_coaster { background-position: -740px -720px; }
-.emoji-rolling_eyes { background-position: 0 -740px; }
-.emoji-rooster { background-position: -20px -740px; }
-.emoji-rose { background-position: -40px -740px; }
-.emoji-rosette { background-position: -60px -740px; }
-.emoji-rotating_light { background-position: -80px -740px; }
-.emoji-round_pushpin { background-position: -100px -740px; }
-.emoji-rowboat { background-position: -120px -740px; }
-.emoji-rowboat_tone1 { background-position: -140px -740px; }
-.emoji-rowboat_tone2 { background-position: -160px -740px; }
-.emoji-rowboat_tone3 { background-position: -180px -740px; }
-.emoji-rowboat_tone4 { background-position: -200px -740px; }
-.emoji-rowboat_tone5 { background-position: -220px -740px; }
-.emoji-rugby_football { background-position: -240px -740px; }
-.emoji-runner { background-position: -260px -740px; }
-.emoji-runner_tone1 { background-position: -280px -740px; }
-.emoji-runner_tone2 { background-position: -300px -740px; }
-.emoji-runner_tone3 { background-position: -320px -740px; }
-.emoji-runner_tone4 { background-position: -340px -740px; }
-.emoji-runner_tone5 { background-position: -360px -740px; }
-.emoji-running_shirt_with_sash { background-position: -380px -740px; }
-.emoji-sa { background-position: -400px -740px; }
-.emoji-sagittarius { background-position: -420px -740px; }
-.emoji-sailboat { background-position: -440px -740px; }
-.emoji-sake { background-position: -460px -740px; }
-.emoji-salad { background-position: -480px -740px; }
-.emoji-sandal { background-position: -500px -740px; }
-.emoji-santa { background-position: -520px -740px; }
-.emoji-santa_tone1 { background-position: -540px -740px; }
-.emoji-santa_tone2 { background-position: -560px -740px; }
-.emoji-santa_tone3 { background-position: -580px -740px; }
-.emoji-santa_tone4 { background-position: -600px -740px; }
-.emoji-santa_tone5 { background-position: -620px -740px; }
-.emoji-satellite { background-position: -640px -740px; }
-.emoji-satellite_orbital { background-position: -660px -740px; }
-.emoji-saxophone { background-position: -680px -740px; }
-.emoji-scales { background-position: -700px -740px; }
-.emoji-school { background-position: -720px -740px; }
-.emoji-school_satchel { background-position: -740px -740px; }
-.emoji-scissors { background-position: -760px 0; }
-.emoji-scooter { background-position: -760px -20px; }
-.emoji-scorpion { background-position: -760px -40px; }
-.emoji-scorpius { background-position: -760px -60px; }
-.emoji-scream { background-position: -760px -80px; }
-.emoji-scream_cat { background-position: -760px -100px; }
-.emoji-scroll { background-position: -760px -120px; }
-.emoji-seat { background-position: -760px -140px; }
-.emoji-second_place { background-position: -760px -160px; }
-.emoji-secret { background-position: -760px -180px; }
-.emoji-see_no_evil { background-position: -760px -200px; }
-.emoji-seedling { background-position: -760px -220px; }
-.emoji-selfie { background-position: -760px -240px; }
-.emoji-selfie_tone1 { background-position: -760px -260px; }
-.emoji-selfie_tone2 { background-position: -760px -280px; }
-.emoji-selfie_tone3 { background-position: -760px -300px; }
-.emoji-selfie_tone4 { background-position: -760px -320px; }
-.emoji-selfie_tone5 { background-position: -760px -340px; }
-.emoji-seven { background-position: -760px -360px; }
-.emoji-shallow_pan_of_food { background-position: -760px -380px; }
-.emoji-shamrock { background-position: -760px -400px; }
-.emoji-shark { background-position: -760px -420px; }
-.emoji-shaved_ice { background-position: -760px -440px; }
-.emoji-sheep { background-position: -760px -460px; }
-.emoji-shell { background-position: -760px -480px; }
-.emoji-shield { background-position: -760px -500px; }
-.emoji-shinto_shrine { background-position: -760px -520px; }
-.emoji-ship { background-position: -760px -540px; }
-.emoji-shirt { background-position: -760px -560px; }
-.emoji-shopping_bags { background-position: -760px -580px; }
-.emoji-shopping_cart { background-position: -760px -600px; }
-.emoji-shower { background-position: -760px -620px; }
-.emoji-shrimp { background-position: -760px -640px; }
-.emoji-shrug { background-position: -760px -660px; }
-.emoji-shrug_tone1 { background-position: -760px -680px; }
-.emoji-shrug_tone2 { background-position: -760px -700px; }
-.emoji-shrug_tone3 { background-position: -760px -720px; }
-.emoji-shrug_tone4 { background-position: -760px -740px; }
-.emoji-shrug_tone5 { background-position: 0 -760px; }
-.emoji-signal_strength { background-position: -20px -760px; }
-.emoji-six { background-position: -40px -760px; }
-.emoji-six_pointed_star { background-position: -60px -760px; }
-.emoji-ski { background-position: -80px -760px; }
-.emoji-skier { background-position: -100px -760px; }
-.emoji-skull { background-position: -120px -760px; }
-.emoji-skull_crossbones { background-position: -140px -760px; }
-.emoji-sleeping { background-position: -160px -760px; }
-.emoji-sleeping_accommodation { background-position: -180px -760px; }
-.emoji-sleepy { background-position: -200px -760px; }
-.emoji-slight_frown { background-position: -220px -760px; }
-.emoji-slight_smile { background-position: -240px -760px; }
-.emoji-slot_machine { background-position: -260px -760px; }
-.emoji-small_blue_diamond { background-position: -280px -760px; }
-.emoji-small_orange_diamond { background-position: -300px -760px; }
-.emoji-small_red_triangle { background-position: -320px -760px; }
-.emoji-small_red_triangle_down { background-position: -340px -760px; }
-.emoji-smile { background-position: -360px -760px; }
-.emoji-smile_cat { background-position: -380px -760px; }
-.emoji-smiley { background-position: -400px -760px; }
-.emoji-smiley_cat { background-position: -420px -760px; }
-.emoji-smiling_imp { background-position: -440px -760px; }
-.emoji-smirk { background-position: -460px -760px; }
-.emoji-smirk_cat { background-position: -480px -760px; }
-.emoji-smoking { background-position: -500px -760px; }
-.emoji-snail { background-position: -520px -760px; }
-.emoji-snake { background-position: -540px -760px; }
-.emoji-sneezing_face { background-position: -560px -760px; }
-.emoji-snowboarder { background-position: -580px -760px; }
-.emoji-snowflake { background-position: -600px -760px; }
-.emoji-snowman { background-position: -620px -760px; }
-.emoji-snowman2 { background-position: -640px -760px; }
-.emoji-sob { background-position: -660px -760px; }
-.emoji-soccer { background-position: -680px -760px; }
-.emoji-soon { background-position: -700px -760px; }
-.emoji-sos { background-position: -720px -760px; }
-.emoji-sound { background-position: -740px -760px; }
-.emoji-space_invader { background-position: -760px -760px; }
-.emoji-spades { background-position: -780px 0; }
-.emoji-spaghetti { background-position: -780px -20px; }
-.emoji-sparkle { background-position: -780px -40px; }
-.emoji-sparkler { background-position: -780px -60px; }
-.emoji-sparkles { background-position: -780px -80px; }
-.emoji-sparkling_heart { background-position: -780px -100px; }
-.emoji-speak_no_evil { background-position: -780px -120px; }
-.emoji-speaker { background-position: -780px -140px; }
-.emoji-speaking_head { background-position: -780px -160px; }
-.emoji-speech_balloon { background-position: -780px -180px; }
-.emoji-speedboat { background-position: -780px -200px; }
-.emoji-spider { background-position: -780px -220px; }
-.emoji-spider_web { background-position: -780px -240px; }
-.emoji-spoon { background-position: -780px -260px; }
-.emoji-spy { background-position: -780px -280px; }
-.emoji-spy_tone1 { background-position: -780px -300px; }
-.emoji-spy_tone2 { background-position: -780px -320px; }
-.emoji-spy_tone3 { background-position: -780px -340px; }
-.emoji-spy_tone4 { background-position: -780px -360px; }
-.emoji-spy_tone5 { background-position: -780px -380px; }
-.emoji-squid { background-position: -780px -400px; }
-.emoji-stadium { background-position: -780px -420px; }
-.emoji-star { background-position: -780px -440px; }
-.emoji-star2 { background-position: -780px -460px; }
-.emoji-star_and_crescent { background-position: -780px -480px; }
-.emoji-star_of_david { background-position: -780px -500px; }
-.emoji-stars { background-position: -780px -520px; }
-.emoji-station { background-position: -780px -540px; }
-.emoji-statue_of_liberty { background-position: -780px -560px; }
-.emoji-steam_locomotive { background-position: -780px -580px; }
-.emoji-stew { background-position: -780px -600px; }
-.emoji-stop_button { background-position: -780px -620px; }
-.emoji-stopwatch { background-position: -780px -640px; }
-.emoji-straight_ruler { background-position: -780px -660px; }
-.emoji-strawberry { background-position: -780px -680px; }
-.emoji-stuck_out_tongue { background-position: -780px -700px; }
-.emoji-stuck_out_tongue_closed_eyes { background-position: -780px -720px; }
-.emoji-stuck_out_tongue_winking_eye { background-position: -780px -740px; }
-.emoji-stuffed_flatbread { background-position: -780px -760px; }
-.emoji-sun_with_face { background-position: 0 -780px; }
-.emoji-sunflower { background-position: -20px -780px; }
-.emoji-sunglasses { background-position: -40px -780px; }
-.emoji-sunny { background-position: -60px -780px; }
-.emoji-sunrise { background-position: -80px -780px; }
-.emoji-sunrise_over_mountains { background-position: -100px -780px; }
-.emoji-surfer { background-position: -120px -780px; }
-.emoji-surfer_tone1 { background-position: -140px -780px; }
-.emoji-surfer_tone2 { background-position: -160px -780px; }
-.emoji-surfer_tone3 { background-position: -180px -780px; }
-.emoji-surfer_tone4 { background-position: -200px -780px; }
-.emoji-surfer_tone5 { background-position: -220px -780px; }
-.emoji-sushi { background-position: -240px -780px; }
-.emoji-suspension_railway { background-position: -260px -780px; }
-.emoji-sweat { background-position: -280px -780px; }
-.emoji-sweat_drops { background-position: -300px -780px; }
-.emoji-sweat_smile { background-position: -320px -780px; }
-.emoji-sweet_potato { background-position: -340px -780px; }
-.emoji-swimmer { background-position: -360px -780px; }
-.emoji-swimmer_tone1 { background-position: -380px -780px; }
-.emoji-swimmer_tone2 { background-position: -400px -780px; }
-.emoji-swimmer_tone3 { background-position: -420px -780px; }
-.emoji-swimmer_tone4 { background-position: -440px -780px; }
-.emoji-swimmer_tone5 { background-position: -460px -780px; }
-.emoji-symbols { background-position: -480px -780px; }
-.emoji-synagogue { background-position: -500px -780px; }
-.emoji-syringe { background-position: -520px -780px; }
-.emoji-taco { background-position: -540px -780px; }
-.emoji-tada { background-position: -560px -780px; }
-.emoji-tanabata_tree { background-position: -580px -780px; }
-.emoji-tangerine { background-position: -600px -780px; }
-.emoji-taurus { background-position: -620px -780px; }
-.emoji-taxi { background-position: -640px -780px; }
-.emoji-tea { background-position: -660px -780px; }
-.emoji-telephone { background-position: -680px -780px; }
-.emoji-telephone_receiver { background-position: -700px -780px; }
-.emoji-telescope { background-position: -720px -780px; }
-.emoji-ten { background-position: -740px -780px; }
-.emoji-tennis { background-position: -760px -780px; }
-.emoji-tent { background-position: -780px -780px; }
-.emoji-thermometer { background-position: -800px 0; }
-.emoji-thermometer_face { background-position: -800px -20px; }
-.emoji-thinking { background-position: -800px -40px; }
-.emoji-third_place { background-position: -800px -60px; }
-.emoji-thought_balloon { background-position: -800px -80px; }
-.emoji-three { background-position: -800px -100px; }
-.emoji-thumbsdown { background-position: -800px -120px; }
-.emoji-thumbsdown_tone1 { background-position: -800px -140px; }
-.emoji-thumbsdown_tone2 { background-position: -800px -160px; }
-.emoji-thumbsdown_tone3 { background-position: -800px -180px; }
-.emoji-thumbsdown_tone4 { background-position: -800px -200px; }
-.emoji-thumbsdown_tone5 { background-position: -800px -220px; }
-.emoji-thumbsup { background-position: -800px -240px; }
-.emoji-thumbsup_tone1 { background-position: -800px -260px; }
-.emoji-thumbsup_tone2 { background-position: -800px -280px; }
-.emoji-thumbsup_tone3 { background-position: -800px -300px; }
-.emoji-thumbsup_tone4 { background-position: -800px -320px; }
-.emoji-thumbsup_tone5 { background-position: -800px -340px; }
-.emoji-thunder_cloud_rain { background-position: -800px -360px; }
-.emoji-ticket { background-position: -800px -380px; }
-.emoji-tickets { background-position: -800px -400px; }
-.emoji-tiger { background-position: -800px -420px; }
-.emoji-tiger2 { background-position: -800px -440px; }
-.emoji-timer { background-position: -800px -460px; }
-.emoji-tired_face { background-position: -800px -480px; }
-.emoji-tm { background-position: -800px -500px; }
-.emoji-toilet { background-position: -800px -520px; }
-.emoji-tokyo_tower { background-position: -800px -540px; }
-.emoji-tomato { background-position: -800px -560px; }
-.emoji-tone1 { background-position: -800px -580px; }
-.emoji-tone2 { background-position: -800px -600px; }
-.emoji-tone3 { background-position: -800px -620px; }
-.emoji-tone4 { background-position: -800px -640px; }
-.emoji-tone5 { background-position: -800px -660px; }
-.emoji-tongue { background-position: -800px -680px; }
-.emoji-tools { background-position: -800px -700px; }
-.emoji-top { background-position: -800px -720px; }
-.emoji-tophat { background-position: -800px -740px; }
-.emoji-track_next { background-position: -800px -760px; }
-.emoji-track_previous { background-position: -800px -780px; }
-.emoji-trackball { background-position: 0 -800px; }
-.emoji-tractor { background-position: -20px -800px; }
-.emoji-traffic_light { background-position: -40px -800px; }
-.emoji-train { background-position: -60px -800px; }
-.emoji-train2 { background-position: -80px -800px; }
-.emoji-tram { background-position: -100px -800px; }
-.emoji-triangular_flag_on_post { background-position: -120px -800px; }
-.emoji-triangular_ruler { background-position: -140px -800px; }
-.emoji-trident { background-position: -160px -800px; }
-.emoji-triumph { background-position: -180px -800px; }
-.emoji-trolleybus { background-position: -200px -800px; }
-.emoji-trophy { background-position: -220px -800px; }
-.emoji-tropical_drink { background-position: -240px -800px; }
-.emoji-tropical_fish { background-position: -260px -800px; }
-.emoji-truck { background-position: -280px -800px; }
-.emoji-trumpet { background-position: -300px -800px; }
-.emoji-tulip { background-position: -320px -800px; }
-.emoji-tumbler_glass { background-position: -340px -800px; }
-.emoji-turkey { background-position: -360px -800px; }
-.emoji-turtle { background-position: -380px -800px; }
-.emoji-tv { background-position: -400px -800px; }
-.emoji-twisted_rightwards_arrows { background-position: -420px -800px; }
-.emoji-two { background-position: -440px -800px; }
-.emoji-two_hearts { background-position: -460px -800px; }
-.emoji-two_men_holding_hands { background-position: -480px -800px; }
-.emoji-two_women_holding_hands { background-position: -500px -800px; }
-.emoji-u5272 { background-position: -520px -800px; }
-.emoji-u5408 { background-position: -540px -800px; }
-.emoji-u55b6 { background-position: -560px -800px; }
-.emoji-u6307 { background-position: -580px -800px; }
-.emoji-u6708 { background-position: -600px -800px; }
-.emoji-u6709 { background-position: -620px -800px; }
-.emoji-u6e80 { background-position: -640px -800px; }
-.emoji-u7121 { background-position: -660px -800px; }
-.emoji-u7533 { background-position: -680px -800px; }
-.emoji-u7981 { background-position: -700px -800px; }
-.emoji-u7a7a { background-position: -720px -800px; }
-.emoji-umbrella { background-position: -740px -800px; }
-.emoji-umbrella2 { background-position: -760px -800px; }
-.emoji-unamused { background-position: -780px -800px; }
-.emoji-underage { background-position: -800px -800px; }
-.emoji-unicorn { background-position: -820px 0; }
-.emoji-unlock { background-position: -820px -20px; }
-.emoji-up { background-position: -820px -40px; }
-.emoji-upside_down { background-position: -820px -60px; }
-.emoji-urn { background-position: -820px -80px; }
-.emoji-v { background-position: -820px -100px; }
-.emoji-v_tone1 { background-position: -820px -120px; }
-.emoji-v_tone2 { background-position: -820px -140px; }
-.emoji-v_tone3 { background-position: -820px -160px; }
-.emoji-v_tone4 { background-position: -820px -180px; }
-.emoji-v_tone5 { background-position: -820px -200px; }
-.emoji-vertical_traffic_light { background-position: -820px -220px; }
-.emoji-vhs { background-position: -820px -240px; }
-.emoji-vibration_mode { background-position: -820px -260px; }
-.emoji-video_camera { background-position: -820px -280px; }
-.emoji-video_game { background-position: -820px -300px; }
-.emoji-violin { background-position: -820px -320px; }
-.emoji-virgo { background-position: -820px -340px; }
-.emoji-volcano { background-position: -820px -360px; }
-.emoji-volleyball { background-position: -820px -380px; }
-.emoji-vs { background-position: -820px -400px; }
-.emoji-vulcan { background-position: -820px -420px; }
-.emoji-vulcan_tone1 { background-position: -820px -440px; }
-.emoji-vulcan_tone2 { background-position: -820px -460px; }
-.emoji-vulcan_tone3 { background-position: -820px -480px; }
-.emoji-vulcan_tone4 { background-position: -820px -500px; }
-.emoji-vulcan_tone5 { background-position: -820px -520px; }
-.emoji-walking { background-position: -820px -540px; }
-.emoji-walking_tone1 { background-position: -820px -560px; }
-.emoji-walking_tone2 { background-position: -820px -580px; }
-.emoji-walking_tone3 { background-position: -820px -600px; }
-.emoji-walking_tone4 { background-position: -820px -620px; }
-.emoji-walking_tone5 { background-position: -820px -640px; }
-.emoji-waning_crescent_moon { background-position: -820px -660px; }
-.emoji-waning_gibbous_moon { background-position: -820px -680px; }
-.emoji-warning { background-position: -820px -700px; }
-.emoji-wastebasket { background-position: -820px -720px; }
-.emoji-watch { background-position: -820px -740px; }
-.emoji-water_buffalo { background-position: -820px -760px; }
-.emoji-water_polo { background-position: -820px -780px; }
-.emoji-water_polo_tone1 { background-position: -820px -800px; }
-.emoji-water_polo_tone2 { background-position: 0 -820px; }
-.emoji-water_polo_tone3 { background-position: -20px -820px; }
-.emoji-water_polo_tone4 { background-position: -40px -820px; }
-.emoji-water_polo_tone5 { background-position: -60px -820px; }
-.emoji-watermelon { background-position: -80px -820px; }
-.emoji-wave { background-position: -100px -820px; }
-.emoji-wave_tone1 { background-position: -120px -820px; }
-.emoji-wave_tone2 { background-position: -140px -820px; }
-.emoji-wave_tone3 { background-position: -160px -820px; }
-.emoji-wave_tone4 { background-position: -180px -820px; }
-.emoji-wave_tone5 { background-position: -200px -820px; }
-.emoji-wavy_dash { background-position: -220px -820px; }
-.emoji-waxing_crescent_moon { background-position: -240px -820px; }
-.emoji-waxing_gibbous_moon { background-position: -260px -820px; }
-.emoji-wc { background-position: -280px -820px; }
-.emoji-weary { background-position: -300px -820px; }
-.emoji-wedding { background-position: -320px -820px; }
-.emoji-whale { background-position: -340px -820px; }
-.emoji-whale2 { background-position: -360px -820px; }
-.emoji-wheel_of_dharma { background-position: -380px -820px; }
-.emoji-wheelchair { background-position: -400px -820px; }
-.emoji-white_check_mark { background-position: -420px -820px; }
-.emoji-white_circle { background-position: -440px -820px; }
-.emoji-white_flower { background-position: -460px -820px; }
-.emoji-white_large_square { background-position: -480px -820px; }
-.emoji-white_medium_small_square { background-position: -500px -820px; }
-.emoji-white_medium_square { background-position: -520px -820px; }
-.emoji-white_small_square { background-position: -540px -820px; }
-.emoji-white_square_button { background-position: -560px -820px; }
-.emoji-white_sun_cloud { background-position: -580px -820px; }
-.emoji-white_sun_rain_cloud { background-position: -600px -820px; }
-.emoji-white_sun_small_cloud { background-position: -620px -820px; }
-.emoji-wilted_rose { background-position: -640px -820px; }
-.emoji-wind_blowing_face { background-position: -660px -820px; }
-.emoji-wind_chime { background-position: -680px -820px; }
-.emoji-wine_glass { background-position: -700px -820px; }
-.emoji-wink { background-position: -720px -820px; }
-.emoji-wolf { background-position: -740px -820px; }
-.emoji-woman { background-position: -760px -820px; }
-.emoji-woman_tone1 { background-position: -780px -820px; }
-.emoji-woman_tone2 { background-position: -800px -820px; }
-.emoji-woman_tone3 { background-position: -820px -820px; }
-.emoji-woman_tone4 { background-position: -840px 0; }
-.emoji-woman_tone5 { background-position: -840px -20px; }
-.emoji-womans_clothes { background-position: -840px -40px; }
-.emoji-womans_hat { background-position: -840px -60px; }
-.emoji-womens { background-position: -840px -80px; }
-.emoji-worried { background-position: -840px -100px; }
-.emoji-wrench { background-position: -840px -120px; }
-.emoji-wrestlers { background-position: -840px -140px; }
-.emoji-wrestlers_tone1 { background-position: -840px -160px; }
-.emoji-wrestlers_tone2 { background-position: -840px -180px; }
-.emoji-wrestlers_tone3 { background-position: -840px -200px; }
-.emoji-wrestlers_tone4 { background-position: -840px -220px; }
-.emoji-wrestlers_tone5 { background-position: -840px -240px; }
-.emoji-writing_hand { background-position: -840px -260px; }
-.emoji-writing_hand_tone1 { background-position: -840px -280px; }
-.emoji-writing_hand_tone2 { background-position: -840px -300px; }
-.emoji-writing_hand_tone3 { background-position: -840px -320px; }
-.emoji-writing_hand_tone4 { background-position: -840px -340px; }
-.emoji-writing_hand_tone5 { background-position: -840px -360px; }
-.emoji-x { background-position: -840px -380px; }
-.emoji-yellow_heart { background-position: -840px -400px; }
-.emoji-yen { background-position: -840px -420px; }
-.emoji-yin_yang { background-position: -840px -440px; }
-.emoji-yum { background-position: -840px -460px; }
-.emoji-zap { background-position: -840px -480px; }
-.emoji-zero { background-position: -840px -500px; }
-.emoji-zipper_mouth { background-position: -840px -520px; }
-.emoji-100 { background-position: -840px -540px; }
-
-.emoji-icon {
- background-image: image-url('emoji.png');
- background-repeat: no-repeat;
- color: transparent;
- text-indent: -99em;
- height: 20px;
- width: 20px;
-
- @media only screen and (-webkit-min-device-pixel-ratio: 2),
- only screen and (min--moz-device-pixel-ratio: 2),
- only screen and (-o-min-device-pixel-ratio: 2/1),
- only screen and (min-device-pixel-ratio: 2),
- only screen and (min-resolution: 192dpi),
- only screen and (min-resolution: 2dppx) {
- background-image: image-url('emoji@2x.png');
- background-size: 860px 840px;
- }
-}
diff --git a/app/assets/stylesheets/framework/emoji_sprites.scss b/app/assets/stylesheets/framework/emoji_sprites.scss
new file mode 100644
index 00000000000..0174e17b660
--- /dev/null
+++ b/app/assets/stylesheets/framework/emoji_sprites.scss
@@ -0,0 +1,1813 @@
+.emoji-zzz { background-position: 0 0; }
+.emoji-1234 { background-position: -20px 0; }
+.emoji-1F627 { background-position: 0 -20px; }
+.emoji-8ball { background-position: -20px -20px; }
+.emoji-a { background-position: -40px 0; }
+.emoji-ab { background-position: -40px -20px; }
+.emoji-abc { background-position: 0 -40px; }
+.emoji-abcd { background-position: -20px -40px; }
+.emoji-accept { background-position: -40px -40px; }
+.emoji-aerial_tramway { background-position: -60px 0; }
+.emoji-airplane { background-position: -60px -20px; }
+.emoji-airplane_arriving { background-position: -60px -40px; }
+.emoji-airplane_departure { background-position: 0 -60px; }
+.emoji-airplane_small { background-position: -20px -60px; }
+.emoji-alarm_clock { background-position: -40px -60px; }
+.emoji-alembic { background-position: -60px -60px; }
+.emoji-alien { background-position: -80px 0; }
+.emoji-ambulance { background-position: -80px -20px; }
+.emoji-amphora { background-position: -80px -40px; }
+.emoji-anchor { background-position: -80px -60px; }
+.emoji-angel { background-position: 0 -80px; }
+.emoji-angel_tone1 { background-position: -20px -80px; }
+.emoji-angel_tone2 { background-position: -40px -80px; }
+.emoji-angel_tone3 { background-position: -60px -80px; }
+.emoji-angel_tone4 { background-position: -80px -80px; }
+.emoji-angel_tone5 { background-position: -100px 0; }
+.emoji-anger { background-position: -100px -20px; }
+.emoji-anger_right { background-position: -100px -40px; }
+.emoji-angry { background-position: -100px -60px; }
+.emoji-ant { background-position: -100px -80px; }
+.emoji-apple { background-position: 0 -100px; }
+.emoji-aquarius { background-position: -20px -100px; }
+.emoji-aries { background-position: -40px -100px; }
+.emoji-arrow_backward { background-position: -60px -100px; }
+.emoji-arrow_double_down { background-position: -80px -100px; }
+.emoji-arrow_double_up { background-position: -100px -100px; }
+.emoji-arrow_down { background-position: -120px 0; }
+.emoji-arrow_down_small { background-position: -120px -20px; }
+.emoji-arrow_forward { background-position: -120px -40px; }
+.emoji-arrow_heading_down { background-position: -120px -60px; }
+.emoji-arrow_heading_up { background-position: -120px -80px; }
+.emoji-arrow_left { background-position: -120px -100px; }
+.emoji-arrow_lower_left { background-position: 0 -120px; }
+.emoji-arrow_lower_right { background-position: -20px -120px; }
+.emoji-arrow_right { background-position: -40px -120px; }
+.emoji-arrow_right_hook { background-position: -60px -120px; }
+.emoji-arrow_up { background-position: -80px -120px; }
+.emoji-arrow_up_down { background-position: -100px -120px; }
+.emoji-arrow_up_small { background-position: -120px -120px; }
+.emoji-arrow_upper_left { background-position: -140px 0; }
+.emoji-arrow_upper_right { background-position: -140px -20px; }
+.emoji-arrows_clockwise { background-position: -140px -40px; }
+.emoji-arrows_counterclockwise { background-position: -140px -60px; }
+.emoji-art { background-position: -140px -80px; }
+.emoji-articulated_lorry { background-position: -140px -100px; }
+.emoji-asterisk { background-position: -140px -120px; }
+.emoji-astonished { background-position: 0 -140px; }
+.emoji-athletic_shoe { background-position: -20px -140px; }
+.emoji-atm { background-position: -40px -140px; }
+.emoji-atom { background-position: -60px -140px; }
+.emoji-avocado { background-position: -80px -140px; }
+.emoji-b { background-position: -100px -140px; }
+.emoji-baby { background-position: -120px -140px; }
+.emoji-baby_bottle { background-position: -140px -140px; }
+.emoji-baby_chick { background-position: -160px 0; }
+.emoji-baby_symbol { background-position: -160px -20px; }
+.emoji-baby_tone1 { background-position: -160px -40px; }
+.emoji-baby_tone2 { background-position: -160px -60px; }
+.emoji-baby_tone3 { background-position: -160px -80px; }
+.emoji-baby_tone4 { background-position: -160px -100px; }
+.emoji-baby_tone5 { background-position: -160px -120px; }
+.emoji-back { background-position: -160px -140px; }
+.emoji-bacon { background-position: 0 -160px; }
+.emoji-badminton { background-position: -20px -160px; }
+.emoji-baggage_claim { background-position: -40px -160px; }
+.emoji-balloon { background-position: -60px -160px; }
+.emoji-ballot_box { background-position: -80px -160px; }
+.emoji-ballot_box_with_check { background-position: -100px -160px; }
+.emoji-bamboo { background-position: -120px -160px; }
+.emoji-banana { background-position: -140px -160px; }
+.emoji-bangbang { background-position: -160px -160px; }
+.emoji-bank { background-position: -180px 0; }
+.emoji-bar_chart { background-position: -180px -20px; }
+.emoji-barber { background-position: -180px -40px; }
+.emoji-baseball { background-position: -180px -60px; }
+.emoji-basketball { background-position: -180px -80px; }
+.emoji-basketball_player { background-position: -180px -100px; }
+.emoji-basketball_player_tone1 { background-position: -180px -120px; }
+.emoji-basketball_player_tone2 { background-position: -180px -140px; }
+.emoji-basketball_player_tone3 { background-position: -180px -160px; }
+.emoji-basketball_player_tone4 { background-position: 0 -180px; }
+.emoji-basketball_player_tone5 { background-position: -20px -180px; }
+.emoji-bat { background-position: -40px -180px; }
+.emoji-bath { background-position: -60px -180px; }
+.emoji-bath_tone1 { background-position: -80px -180px; }
+.emoji-bath_tone2 { background-position: -100px -180px; }
+.emoji-bath_tone3 { background-position: -120px -180px; }
+.emoji-bath_tone4 { background-position: -140px -180px; }
+.emoji-bath_tone5 { background-position: -160px -180px; }
+.emoji-bathtub { background-position: -180px -180px; }
+.emoji-battery { background-position: -200px 0; }
+.emoji-beach { background-position: -200px -20px; }
+.emoji-beach_umbrella { background-position: -200px -40px; }
+.emoji-bear { background-position: -200px -60px; }
+.emoji-bed { background-position: -200px -80px; }
+.emoji-bee { background-position: -200px -100px; }
+.emoji-beer { background-position: -200px -120px; }
+.emoji-beers { background-position: -200px -140px; }
+.emoji-beetle { background-position: -200px -160px; }
+.emoji-beginner { background-position: -200px -180px; }
+.emoji-bell { background-position: 0 -200px; }
+.emoji-bellhop { background-position: -20px -200px; }
+.emoji-bento { background-position: -40px -200px; }
+.emoji-bicyclist { background-position: -60px -200px; }
+.emoji-bicyclist_tone1 { background-position: -80px -200px; }
+.emoji-bicyclist_tone2 { background-position: -100px -200px; }
+.emoji-bicyclist_tone3 { background-position: -120px -200px; }
+.emoji-bicyclist_tone4 { background-position: -140px -200px; }
+.emoji-bicyclist_tone5 { background-position: -160px -200px; }
+.emoji-bike { background-position: -180px -200px; }
+.emoji-bikini { background-position: -200px -200px; }
+.emoji-biohazard { background-position: -220px 0; }
+.emoji-bird { background-position: -220px -20px; }
+.emoji-birthday { background-position: -220px -40px; }
+.emoji-black_circle { background-position: -220px -60px; }
+.emoji-black_heart { background-position: -220px -80px; }
+.emoji-black_joker { background-position: -220px -100px; }
+.emoji-black_large_square { background-position: -220px -120px; }
+.emoji-black_medium_small_square { background-position: -220px -140px; }
+.emoji-black_medium_square { background-position: -220px -160px; }
+.emoji-black_nib { background-position: -220px -180px; }
+.emoji-black_small_square { background-position: -220px -200px; }
+.emoji-black_square_button { background-position: 0 -220px; }
+.emoji-blossom { background-position: -20px -220px; }
+.emoji-blowfish { background-position: -40px -220px; }
+.emoji-blue_book { background-position: -60px -220px; }
+.emoji-blue_car { background-position: -80px -220px; }
+.emoji-blue_heart { background-position: -100px -220px; }
+.emoji-blush { background-position: -120px -220px; }
+.emoji-boar { background-position: -140px -220px; }
+.emoji-bomb { background-position: -160px -220px; }
+.emoji-book { background-position: -180px -220px; }
+.emoji-bookmark { background-position: -200px -220px; }
+.emoji-bookmark_tabs { background-position: -220px -220px; }
+.emoji-books { background-position: -240px 0; }
+.emoji-boom { background-position: -240px -20px; }
+.emoji-boot { background-position: -240px -40px; }
+.emoji-bouquet { background-position: -240px -60px; }
+.emoji-bow { background-position: -240px -80px; }
+.emoji-bow_and_arrow { background-position: -240px -100px; }
+.emoji-bow_tone1 { background-position: -240px -120px; }
+.emoji-bow_tone2 { background-position: -240px -140px; }
+.emoji-bow_tone3 { background-position: -240px -160px; }
+.emoji-bow_tone4 { background-position: -240px -180px; }
+.emoji-bow_tone5 { background-position: -240px -200px; }
+.emoji-bowling { background-position: -240px -220px; }
+.emoji-boxing_glove { background-position: 0 -240px; }
+.emoji-boy { background-position: -20px -240px; }
+.emoji-boy_tone1 { background-position: -40px -240px; }
+.emoji-boy_tone2 { background-position: -60px -240px; }
+.emoji-boy_tone3 { background-position: -80px -240px; }
+.emoji-boy_tone4 { background-position: -100px -240px; }
+.emoji-boy_tone5 { background-position: -120px -240px; }
+.emoji-bread { background-position: -140px -240px; }
+.emoji-bride_with_veil { background-position: -160px -240px; }
+.emoji-bride_with_veil_tone1 { background-position: -180px -240px; }
+.emoji-bride_with_veil_tone2 { background-position: -200px -240px; }
+.emoji-bride_with_veil_tone3 { background-position: -220px -240px; }
+.emoji-bride_with_veil_tone4 { background-position: -240px -240px; }
+.emoji-bride_with_veil_tone5 { background-position: -260px 0; }
+.emoji-bridge_at_night { background-position: -260px -20px; }
+.emoji-briefcase { background-position: -260px -40px; }
+.emoji-broken_heart { background-position: -260px -60px; }
+.emoji-bug { background-position: -260px -80px; }
+.emoji-bulb { background-position: -260px -100px; }
+.emoji-bullettrain_front { background-position: -260px -120px; }
+.emoji-bullettrain_side { background-position: -260px -140px; }
+.emoji-burrito { background-position: -260px -160px; }
+.emoji-bus { background-position: -260px -180px; }
+.emoji-busstop { background-position: -260px -200px; }
+.emoji-bust_in_silhouette { background-position: -260px -220px; }
+.emoji-busts_in_silhouette { background-position: -260px -240px; }
+.emoji-butterfly { background-position: 0 -260px; }
+.emoji-cactus { background-position: -20px -260px; }
+.emoji-cake { background-position: -40px -260px; }
+.emoji-calendar { background-position: -60px -260px; }
+.emoji-calendar_spiral { background-position: -80px -260px; }
+.emoji-call_me { background-position: -100px -260px; }
+.emoji-call_me_tone1 { background-position: -120px -260px; }
+.emoji-call_me_tone2 { background-position: -140px -260px; }
+.emoji-call_me_tone3 { background-position: -160px -260px; }
+.emoji-call_me_tone4 { background-position: -180px -260px; }
+.emoji-call_me_tone5 { background-position: -200px -260px; }
+.emoji-calling { background-position: -220px -260px; }
+.emoji-camel { background-position: -240px -260px; }
+.emoji-camera { background-position: -260px -260px; }
+.emoji-camera_with_flash { background-position: -280px 0; }
+.emoji-camping { background-position: -280px -20px; }
+.emoji-cancer { background-position: -280px -40px; }
+.emoji-candle { background-position: -280px -60px; }
+.emoji-candy { background-position: -280px -80px; }
+.emoji-canoe { background-position: -280px -100px; }
+.emoji-capital_abcd { background-position: -280px -120px; }
+.emoji-capricorn { background-position: -280px -140px; }
+.emoji-card_box { background-position: -280px -160px; }
+.emoji-card_index { background-position: -280px -180px; }
+.emoji-carousel_horse { background-position: -280px -200px; }
+.emoji-carrot { background-position: -280px -220px; }
+.emoji-cartwheel { background-position: -280px -240px; }
+.emoji-cartwheel_tone1 { background-position: -280px -260px; }
+.emoji-cartwheel_tone2 { background-position: 0 -280px; }
+.emoji-cartwheel_tone3 { background-position: -20px -280px; }
+.emoji-cartwheel_tone4 { background-position: -40px -280px; }
+.emoji-cartwheel_tone5 { background-position: -60px -280px; }
+.emoji-cat { background-position: -80px -280px; }
+.emoji-cat2 { background-position: -100px -280px; }
+.emoji-cd { background-position: -120px -280px; }
+.emoji-chains { background-position: -140px -280px; }
+.emoji-champagne { background-position: -160px -280px; }
+.emoji-champagne_glass { background-position: -180px -280px; }
+.emoji-chart { background-position: -200px -280px; }
+.emoji-chart_with_downwards_trend { background-position: -220px -280px; }
+.emoji-chart_with_upwards_trend { background-position: -240px -280px; }
+.emoji-checkered_flag { background-position: -260px -280px; }
+.emoji-cheese { background-position: -280px -280px; }
+.emoji-cherries { background-position: -300px 0; }
+.emoji-cherry_blossom { background-position: -300px -20px; }
+.emoji-chestnut { background-position: -300px -40px; }
+.emoji-chicken { background-position: -300px -60px; }
+.emoji-children_crossing { background-position: -300px -80px; }
+.emoji-chipmunk { background-position: -300px -100px; }
+.emoji-chocolate_bar { background-position: -300px -120px; }
+.emoji-christmas_tree { background-position: -300px -140px; }
+.emoji-church { background-position: -300px -160px; }
+.emoji-cinema { background-position: -300px -180px; }
+.emoji-circus_tent { background-position: -300px -200px; }
+.emoji-city_dusk { background-position: -300px -220px; }
+.emoji-city_sunset { background-position: -300px -240px; }
+.emoji-cityscape { background-position: -300px -260px; }
+.emoji-cl { background-position: -300px -280px; }
+.emoji-clap { background-position: 0 -300px; }
+.emoji-clap_tone1 { background-position: -20px -300px; }
+.emoji-clap_tone2 { background-position: -40px -300px; }
+.emoji-clap_tone3 { background-position: -60px -300px; }
+.emoji-clap_tone4 { background-position: -80px -300px; }
+.emoji-clap_tone5 { background-position: -100px -300px; }
+.emoji-clapper { background-position: -120px -300px; }
+.emoji-classical_building { background-position: -140px -300px; }
+.emoji-clipboard { background-position: -160px -300px; }
+.emoji-clock { background-position: -180px -300px; }
+.emoji-clock1 { background-position: -200px -300px; }
+.emoji-clock10 { background-position: -220px -300px; }
+.emoji-clock1030 { background-position: -240px -300px; }
+.emoji-clock11 { background-position: -260px -300px; }
+.emoji-clock1130 { background-position: -280px -300px; }
+.emoji-clock12 { background-position: -300px -300px; }
+.emoji-clock1230 { background-position: -320px 0; }
+.emoji-clock130 { background-position: -320px -20px; }
+.emoji-clock2 { background-position: -320px -40px; }
+.emoji-clock230 { background-position: -320px -60px; }
+.emoji-clock3 { background-position: -320px -80px; }
+.emoji-clock330 { background-position: -320px -100px; }
+.emoji-clock4 { background-position: -320px -120px; }
+.emoji-clock430 { background-position: -320px -140px; }
+.emoji-clock5 { background-position: -320px -160px; }
+.emoji-clock530 { background-position: -320px -180px; }
+.emoji-clock6 { background-position: -320px -200px; }
+.emoji-clock630 { background-position: -320px -220px; }
+.emoji-clock7 { background-position: -320px -240px; }
+.emoji-clock730 { background-position: -320px -260px; }
+.emoji-clock8 { background-position: -320px -280px; }
+.emoji-clock830 { background-position: -320px -300px; }
+.emoji-clock9 { background-position: 0 -320px; }
+.emoji-clock930 { background-position: -20px -320px; }
+.emoji-closed_book { background-position: -40px -320px; }
+.emoji-closed_lock_with_key { background-position: -60px -320px; }
+.emoji-closed_umbrella { background-position: -80px -320px; }
+.emoji-cloud { background-position: -100px -320px; }
+.emoji-cloud_lightning { background-position: -120px -320px; }
+.emoji-cloud_rain { background-position: -140px -320px; }
+.emoji-cloud_snow { background-position: -160px -320px; }
+.emoji-cloud_tornado { background-position: -180px -320px; }
+.emoji-clown { background-position: -200px -320px; }
+.emoji-clubs { background-position: -220px -320px; }
+.emoji-cocktail { background-position: -240px -320px; }
+.emoji-coffee { background-position: -260px -320px; }
+.emoji-coffin { background-position: -280px -320px; }
+.emoji-cold_sweat { background-position: -300px -320px; }
+.emoji-comet { background-position: -320px -320px; }
+.emoji-compression { background-position: -340px 0; }
+.emoji-computer { background-position: -340px -20px; }
+.emoji-confetti_ball { background-position: -340px -40px; }
+.emoji-confounded { background-position: -340px -60px; }
+.emoji-confused { background-position: -340px -80px; }
+.emoji-congratulations { background-position: -340px -100px; }
+.emoji-construction { background-position: -340px -120px; }
+.emoji-construction_site { background-position: -340px -140px; }
+.emoji-construction_worker { background-position: -340px -160px; }
+.emoji-construction_worker_tone1 { background-position: -340px -180px; }
+.emoji-construction_worker_tone2 { background-position: -340px -200px; }
+.emoji-construction_worker_tone3 { background-position: -340px -220px; }
+.emoji-construction_worker_tone4 { background-position: -340px -240px; }
+.emoji-construction_worker_tone5 { background-position: -340px -260px; }
+.emoji-control_knobs { background-position: -340px -280px; }
+.emoji-convenience_store { background-position: -340px -300px; }
+.emoji-cookie { background-position: -340px -320px; }
+.emoji-cooking { background-position: 0 -340px; }
+.emoji-cool { background-position: -20px -340px; }
+.emoji-cop { background-position: -40px -340px; }
+.emoji-cop_tone1 { background-position: -60px -340px; }
+.emoji-cop_tone2 { background-position: -80px -340px; }
+.emoji-cop_tone3 { background-position: -100px -340px; }
+.emoji-cop_tone4 { background-position: -120px -340px; }
+.emoji-cop_tone5 { background-position: -140px -340px; }
+.emoji-copyright { background-position: -160px -340px; }
+.emoji-corn { background-position: -180px -340px; }
+.emoji-couch { background-position: -200px -340px; }
+.emoji-couple { background-position: -220px -340px; }
+.emoji-couple_mm { background-position: -240px -340px; }
+.emoji-couple_with_heart { background-position: -260px -340px; }
+.emoji-couple_ww { background-position: -280px -340px; }
+.emoji-couplekiss { background-position: -300px -340px; }
+.emoji-cow { background-position: -320px -340px; }
+.emoji-cow2 { background-position: -340px -340px; }
+.emoji-cowboy { background-position: -360px 0; }
+.emoji-crab { background-position: -360px -20px; }
+.emoji-crayon { background-position: -360px -40px; }
+.emoji-credit_card { background-position: -360px -60px; }
+.emoji-crescent_moon { background-position: -360px -80px; }
+.emoji-cricket { background-position: -360px -100px; }
+.emoji-crocodile { background-position: -360px -120px; }
+.emoji-croissant { background-position: -360px -140px; }
+.emoji-cross { background-position: -360px -160px; }
+.emoji-crossed_flags { background-position: -360px -180px; }
+.emoji-crossed_swords { background-position: -360px -200px; }
+.emoji-crown { background-position: -360px -220px; }
+.emoji-cruise_ship { background-position: -360px -240px; }
+.emoji-cry { background-position: -360px -260px; }
+.emoji-crying_cat_face { background-position: -360px -280px; }
+.emoji-crystal_ball { background-position: -360px -300px; }
+.emoji-cucumber { background-position: -360px -320px; }
+.emoji-cupid { background-position: -360px -340px; }
+.emoji-curly_loop { background-position: 0 -360px; }
+.emoji-currency_exchange { background-position: -20px -360px; }
+.emoji-curry { background-position: -40px -360px; }
+.emoji-custard { background-position: -60px -360px; }
+.emoji-customs { background-position: -80px -360px; }
+.emoji-cyclone { background-position: -100px -360px; }
+.emoji-dagger { background-position: -120px -360px; }
+.emoji-dancer { background-position: -140px -360px; }
+.emoji-dancer_tone1 { background-position: -160px -360px; }
+.emoji-dancer_tone2 { background-position: -180px -360px; }
+.emoji-dancer_tone3 { background-position: -200px -360px; }
+.emoji-dancer_tone4 { background-position: -220px -360px; }
+.emoji-dancer_tone5 { background-position: -240px -360px; }
+.emoji-dancers { background-position: -260px -360px; }
+.emoji-dango { background-position: -280px -360px; }
+.emoji-dark_sunglasses { background-position: -300px -360px; }
+.emoji-dart { background-position: -320px -360px; }
+.emoji-dash { background-position: -340px -360px; }
+.emoji-date { background-position: -360px -360px; }
+.emoji-deciduous_tree { background-position: -380px 0; }
+.emoji-deer { background-position: -380px -20px; }
+.emoji-department_store { background-position: -380px -40px; }
+.emoji-desert { background-position: -380px -60px; }
+.emoji-desktop { background-position: -380px -80px; }
+.emoji-diamond_shape_with_a_dot_inside { background-position: -380px -100px; }
+.emoji-diamonds { background-position: -380px -120px; }
+.emoji-disappointed { background-position: -380px -140px; }
+.emoji-disappointed_relieved { background-position: -380px -160px; }
+.emoji-dividers { background-position: -380px -180px; }
+.emoji-dizzy { background-position: -380px -200px; }
+.emoji-dizzy_face { background-position: -380px -220px; }
+.emoji-do_not_litter { background-position: -380px -240px; }
+.emoji-dog { background-position: -380px -260px; }
+.emoji-dog2 { background-position: -380px -280px; }
+.emoji-dollar { background-position: -380px -300px; }
+.emoji-dolls { background-position: -380px -320px; }
+.emoji-dolphin { background-position: -380px -340px; }
+.emoji-door { background-position: -380px -360px; }
+.emoji-doughnut { background-position: 0 -380px; }
+.emoji-dove { background-position: -20px -380px; }
+.emoji-dragon { background-position: -40px -380px; }
+.emoji-dragon_face { background-position: -60px -380px; }
+.emoji-dress { background-position: -80px -380px; }
+.emoji-dromedary_camel { background-position: -100px -380px; }
+.emoji-drooling_face { background-position: -120px -380px; }
+.emoji-droplet { background-position: -140px -380px; }
+.emoji-drum { background-position: -160px -380px; }
+.emoji-duck { background-position: -180px -380px; }
+.emoji-dvd { background-position: -200px -380px; }
+.emoji-e-mail { background-position: -220px -380px; }
+.emoji-eagle { background-position: -240px -380px; }
+.emoji-ear { background-position: -260px -380px; }
+.emoji-ear_of_rice { background-position: -280px -380px; }
+.emoji-ear_tone1 { background-position: -300px -380px; }
+.emoji-ear_tone2 { background-position: -320px -380px; }
+.emoji-ear_tone3 { background-position: -340px -380px; }
+.emoji-ear_tone4 { background-position: -360px -380px; }
+.emoji-ear_tone5 { background-position: -380px -380px; }
+.emoji-earth_africa { background-position: -400px 0; }
+.emoji-earth_americas { background-position: -400px -20px; }
+.emoji-earth_asia { background-position: -400px -40px; }
+.emoji-egg { background-position: -400px -60px; }
+.emoji-eggplant { background-position: -400px -80px; }
+.emoji-eight { background-position: -400px -100px; }
+.emoji-eight_pointed_black_star { background-position: -400px -120px; }
+.emoji-eight_spoked_asterisk { background-position: -400px -140px; }
+.emoji-eject { background-position: -400px -160px; }
+.emoji-electric_plug { background-position: -400px -180px; }
+.emoji-elephant { background-position: -400px -200px; }
+.emoji-end { background-position: -400px -220px; }
+.emoji-envelope { background-position: -400px -240px; }
+.emoji-envelope_with_arrow { background-position: -400px -260px; }
+.emoji-euro { background-position: -400px -280px; }
+.emoji-european_castle { background-position: -400px -300px; }
+.emoji-european_post_office { background-position: -400px -320px; }
+.emoji-evergreen_tree { background-position: -400px -340px; }
+.emoji-exclamation { background-position: -400px -360px; }
+.emoji-expressionless { background-position: -400px -380px; }
+.emoji-eye { background-position: 0 -400px; }
+.emoji-eye_in_speech_bubble { background-position: -20px -400px; }
+.emoji-eyeglasses { background-position: -40px -400px; }
+.emoji-eyes { background-position: -60px -400px; }
+.emoji-face_palm { background-position: -80px -400px; }
+.emoji-face_palm_tone1 { background-position: -100px -400px; }
+.emoji-face_palm_tone2 { background-position: -120px -400px; }
+.emoji-face_palm_tone3 { background-position: -140px -400px; }
+.emoji-face_palm_tone4 { background-position: -160px -400px; }
+.emoji-face_palm_tone5 { background-position: -180px -400px; }
+.emoji-factory { background-position: -200px -400px; }
+.emoji-fallen_leaf { background-position: -220px -400px; }
+.emoji-family { background-position: -240px -400px; }
+.emoji-family_mmb { background-position: -260px -400px; }
+.emoji-family_mmbb { background-position: -280px -400px; }
+.emoji-family_mmg { background-position: -300px -400px; }
+.emoji-family_mmgb { background-position: -320px -400px; }
+.emoji-family_mmgg { background-position: -340px -400px; }
+.emoji-family_mwbb { background-position: -360px -400px; }
+.emoji-family_mwg { background-position: -380px -400px; }
+.emoji-family_mwgb { background-position: -400px -400px; }
+.emoji-family_mwgg { background-position: -420px 0; }
+.emoji-family_wwb { background-position: -420px -20px; }
+.emoji-family_wwbb { background-position: -420px -40px; }
+.emoji-family_wwg { background-position: -420px -60px; }
+.emoji-family_wwgb { background-position: -420px -80px; }
+.emoji-family_wwgg { background-position: -420px -100px; }
+.emoji-fast_forward { background-position: -420px -120px; }
+.emoji-fax { background-position: -420px -140px; }
+.emoji-fearful { background-position: -420px -160px; }
+.emoji-feet { background-position: -420px -180px; }
+.emoji-fencer { background-position: -420px -200px; }
+.emoji-ferris_wheel { background-position: -420px -220px; }
+.emoji-ferry { background-position: -420px -240px; }
+.emoji-field_hockey { background-position: -420px -260px; }
+.emoji-file_cabinet { background-position: -420px -280px; }
+.emoji-file_folder { background-position: -420px -300px; }
+.emoji-film_frames { background-position: -420px -320px; }
+.emoji-fingers_crossed { background-position: -420px -340px; }
+.emoji-fingers_crossed_tone1 { background-position: -420px -360px; }
+.emoji-fingers_crossed_tone2 { background-position: -420px -380px; }
+.emoji-fingers_crossed_tone3 { background-position: -420px -400px; }
+.emoji-fingers_crossed_tone4 { background-position: 0 -420px; }
+.emoji-fingers_crossed_tone5 { background-position: -20px -420px; }
+.emoji-fire { background-position: -40px -420px; }
+.emoji-fire_engine { background-position: -60px -420px; }
+.emoji-fireworks { background-position: -80px -420px; }
+.emoji-first_place { background-position: -100px -420px; }
+.emoji-first_quarter_moon { background-position: -120px -420px; }
+.emoji-first_quarter_moon_with_face { background-position: -140px -420px; }
+.emoji-fish { background-position: -160px -420px; }
+.emoji-fish_cake { background-position: -180px -420px; }
+.emoji-fishing_pole_and_fish { background-position: -200px -420px; }
+.emoji-fist { background-position: -220px -420px; }
+.emoji-fist_tone1 { background-position: -240px -420px; }
+.emoji-fist_tone2 { background-position: -260px -420px; }
+.emoji-fist_tone3 { background-position: -280px -420px; }
+.emoji-fist_tone4 { background-position: -300px -420px; }
+.emoji-fist_tone5 { background-position: -320px -420px; }
+.emoji-five { background-position: -340px -420px; }
+.emoji-flag_ac { background-position: -360px -420px; }
+.emoji-flag_ad { background-position: -380px -420px; }
+.emoji-flag_ae { background-position: -400px -420px; }
+.emoji-flag_af { background-position: -420px -420px; }
+.emoji-flag_ag { background-position: -440px 0; }
+.emoji-flag_ai { background-position: -440px -20px; }
+.emoji-flag_al { background-position: -440px -40px; }
+.emoji-flag_am { background-position: -440px -60px; }
+.emoji-flag_ao { background-position: -440px -80px; }
+.emoji-flag_aq { background-position: -440px -100px; }
+.emoji-flag_ar { background-position: -440px -120px; }
+.emoji-flag_as { background-position: -440px -140px; }
+.emoji-flag_at { background-position: -440px -160px; }
+.emoji-flag_au { background-position: -440px -180px; }
+.emoji-flag_aw { background-position: -440px -200px; }
+.emoji-flag_ax { background-position: -440px -220px; }
+.emoji-flag_az { background-position: -440px -240px; }
+.emoji-flag_ba { background-position: -440px -260px; }
+.emoji-flag_bb { background-position: -440px -280px; }
+.emoji-flag_bd { background-position: -440px -300px; }
+.emoji-flag_be { background-position: -440px -320px; }
+.emoji-flag_bf { background-position: -440px -340px; }
+.emoji-flag_bg { background-position: -440px -360px; }
+.emoji-flag_bh { background-position: -440px -380px; }
+.emoji-flag_bi { background-position: -440px -400px; }
+.emoji-flag_bj { background-position: -440px -420px; }
+.emoji-flag_bl { background-position: 0 -440px; }
+.emoji-flag_black { background-position: -20px -440px; }
+.emoji-flag_bm { background-position: -40px -440px; }
+.emoji-flag_bn { background-position: -60px -440px; }
+.emoji-flag_bo { background-position: -80px -440px; }
+.emoji-flag_bq { background-position: -100px -440px; }
+.emoji-flag_br { background-position: -120px -440px; }
+.emoji-flag_bs { background-position: -140px -440px; }
+.emoji-flag_bt { background-position: -160px -440px; }
+.emoji-flag_bv { background-position: -180px -440px; }
+.emoji-flag_bw { background-position: -200px -440px; }
+.emoji-flag_by { background-position: -220px -440px; }
+.emoji-flag_bz { background-position: -240px -440px; }
+.emoji-flag_ca { background-position: -260px -440px; }
+.emoji-flag_cc { background-position: -280px -440px; }
+.emoji-flag_cd { background-position: -300px -440px; }
+.emoji-flag_cf { background-position: -320px -440px; }
+.emoji-flag_cg { background-position: -340px -440px; }
+.emoji-flag_ch { background-position: -360px -440px; }
+.emoji-flag_ci { background-position: -380px -440px; }
+.emoji-flag_ck { background-position: -400px -440px; }
+.emoji-flag_cl { background-position: -420px -440px; }
+.emoji-flag_cm { background-position: -440px -440px; }
+.emoji-flag_cn { background-position: -460px 0; }
+.emoji-flag_co { background-position: -460px -20px; }
+.emoji-flag_cp { background-position: -460px -40px; }
+.emoji-flag_cr { background-position: -460px -60px; }
+.emoji-flag_cu { background-position: -460px -80px; }
+.emoji-flag_cv { background-position: -460px -100px; }
+.emoji-flag_cw { background-position: -460px -120px; }
+.emoji-flag_cx { background-position: -460px -140px; }
+.emoji-flag_cy { background-position: -460px -160px; }
+.emoji-flag_cz { background-position: -460px -180px; }
+.emoji-flag_de { background-position: -460px -200px; }
+.emoji-flag_dg { background-position: -460px -220px; }
+.emoji-flag_dj { background-position: -460px -240px; }
+.emoji-flag_dk { background-position: -460px -260px; }
+.emoji-flag_dm { background-position: -460px -280px; }
+.emoji-flag_do { background-position: -460px -300px; }
+.emoji-flag_dz { background-position: -460px -320px; }
+.emoji-flag_ea { background-position: -460px -340px; }
+.emoji-flag_ec { background-position: -460px -360px; }
+.emoji-flag_ee { background-position: -460px -380px; }
+.emoji-flag_eg { background-position: -460px -400px; }
+.emoji-flag_eh { background-position: -460px -420px; }
+.emoji-flag_er { background-position: -460px -440px; }
+.emoji-flag_es { background-position: 0 -460px; }
+.emoji-flag_et { background-position: -20px -460px; }
+.emoji-flag_eu { background-position: -40px -460px; }
+.emoji-flag_fi { background-position: -60px -460px; }
+.emoji-flag_fj { background-position: -80px -460px; }
+.emoji-flag_fk { background-position: -100px -460px; }
+.emoji-flag_fm { background-position: -120px -460px; }
+.emoji-flag_fo { background-position: -140px -460px; }
+.emoji-flag_fr { background-position: -160px -460px; }
+.emoji-flag_ga { background-position: -180px -460px; }
+.emoji-flag_gb { background-position: -200px -460px; }
+.emoji-flag_gd { background-position: -220px -460px; }
+.emoji-flag_ge { background-position: -240px -460px; }
+.emoji-flag_gf { background-position: -260px -460px; }
+.emoji-flag_gg { background-position: -280px -460px; }
+.emoji-flag_gh { background-position: -300px -460px; }
+.emoji-flag_gi { background-position: -320px -460px; }
+.emoji-flag_gl { background-position: -340px -460px; }
+.emoji-flag_gm { background-position: -360px -460px; }
+.emoji-flag_gn { background-position: -380px -460px; }
+.emoji-flag_gp { background-position: -400px -460px; }
+.emoji-flag_gq { background-position: -420px -460px; }
+.emoji-flag_gr { background-position: -440px -460px; }
+.emoji-flag_gs { background-position: -460px -460px; }
+.emoji-flag_gt { background-position: -480px 0; }
+.emoji-flag_gu { background-position: -480px -20px; }
+.emoji-flag_gw { background-position: -480px -40px; }
+.emoji-flag_gy { background-position: -480px -60px; }
+.emoji-flag_hk { background-position: -480px -80px; }
+.emoji-flag_hm { background-position: -480px -100px; }
+.emoji-flag_hn { background-position: -480px -120px; }
+.emoji-flag_hr { background-position: -480px -140px; }
+.emoji-flag_ht { background-position: -480px -160px; }
+.emoji-flag_hu { background-position: -480px -180px; }
+.emoji-flag_ic { background-position: -480px -200px; }
+.emoji-flag_id { background-position: -480px -220px; }
+.emoji-flag_ie { background-position: -480px -240px; }
+.emoji-flag_il { background-position: -480px -260px; }
+.emoji-flag_im { background-position: -480px -280px; }
+.emoji-flag_in { background-position: -480px -300px; }
+.emoji-flag_io { background-position: -480px -320px; }
+.emoji-flag_iq { background-position: -480px -340px; }
+.emoji-flag_ir { background-position: -480px -360px; }
+.emoji-flag_is { background-position: -480px -380px; }
+.emoji-flag_it { background-position: -480px -400px; }
+.emoji-flag_je { background-position: -480px -420px; }
+.emoji-flag_jm { background-position: -480px -440px; }
+.emoji-flag_jo { background-position: -480px -460px; }
+.emoji-flag_jp { background-position: 0 -480px; }
+.emoji-flag_ke { background-position: -20px -480px; }
+.emoji-flag_kg { background-position: -40px -480px; }
+.emoji-flag_kh { background-position: -60px -480px; }
+.emoji-flag_ki { background-position: -80px -480px; }
+.emoji-flag_km { background-position: -100px -480px; }
+.emoji-flag_kn { background-position: -120px -480px; }
+.emoji-flag_kp { background-position: -140px -480px; }
+.emoji-flag_kr { background-position: -160px -480px; }
+.emoji-flag_kw { background-position: -180px -480px; }
+.emoji-flag_ky { background-position: -200px -480px; }
+.emoji-flag_kz { background-position: -220px -480px; }
+.emoji-flag_la { background-position: -240px -480px; }
+.emoji-flag_lb { background-position: -260px -480px; }
+.emoji-flag_lc { background-position: -280px -480px; }
+.emoji-flag_li { background-position: -300px -480px; }
+.emoji-flag_lk { background-position: -320px -480px; }
+.emoji-flag_lr { background-position: -340px -480px; }
+.emoji-flag_ls { background-position: -360px -480px; }
+.emoji-flag_lt { background-position: -380px -480px; }
+.emoji-flag_lu { background-position: -400px -480px; }
+.emoji-flag_lv { background-position: -420px -480px; }
+.emoji-flag_ly { background-position: -440px -480px; }
+.emoji-flag_ma { background-position: -460px -480px; }
+.emoji-flag_mc { background-position: -480px -480px; }
+.emoji-flag_md { background-position: -500px 0; }
+.emoji-flag_me { background-position: -500px -20px; }
+.emoji-flag_mf { background-position: -500px -40px; }
+.emoji-flag_mg { background-position: -500px -60px; }
+.emoji-flag_mh { background-position: -500px -80px; }
+.emoji-flag_mk { background-position: -500px -100px; }
+.emoji-flag_ml { background-position: -500px -120px; }
+.emoji-flag_mm { background-position: -500px -140px; }
+.emoji-flag_mn { background-position: -500px -160px; }
+.emoji-flag_mo { background-position: -500px -180px; }
+.emoji-flag_mp { background-position: -500px -200px; }
+.emoji-flag_mq { background-position: -500px -220px; }
+.emoji-flag_mr { background-position: -500px -240px; }
+.emoji-flag_ms { background-position: -500px -260px; }
+.emoji-flag_mt { background-position: -500px -280px; }
+.emoji-flag_mu { background-position: -500px -300px; }
+.emoji-flag_mv { background-position: -500px -320px; }
+.emoji-flag_mw { background-position: -500px -340px; }
+.emoji-flag_mx { background-position: -500px -360px; }
+.emoji-flag_my { background-position: -500px -380px; }
+.emoji-flag_mz { background-position: -500px -400px; }
+.emoji-flag_na { background-position: -500px -420px; }
+.emoji-flag_nc { background-position: -500px -440px; }
+.emoji-flag_ne { background-position: -500px -460px; }
+.emoji-flag_nf { background-position: -500px -480px; }
+.emoji-flag_ng { background-position: 0 -500px; }
+.emoji-flag_ni { background-position: -20px -500px; }
+.emoji-flag_nl { background-position: -40px -500px; }
+.emoji-flag_no { background-position: -60px -500px; }
+.emoji-flag_np { background-position: -80px -500px; }
+.emoji-flag_nr { background-position: -100px -500px; }
+.emoji-flag_nu { background-position: -120px -500px; }
+.emoji-flag_nz { background-position: -140px -500px; }
+.emoji-flag_om { background-position: -160px -500px; }
+.emoji-flag_pa { background-position: -180px -500px; }
+.emoji-flag_pe { background-position: -200px -500px; }
+.emoji-flag_pf { background-position: -220px -500px; }
+.emoji-flag_pg { background-position: -240px -500px; }
+.emoji-flag_ph { background-position: -260px -500px; }
+.emoji-flag_pk { background-position: -280px -500px; }
+.emoji-flag_pl { background-position: -300px -500px; }
+.emoji-flag_pm { background-position: -320px -500px; }
+.emoji-flag_pn { background-position: -340px -500px; }
+.emoji-flag_pr { background-position: -360px -500px; }
+.emoji-flag_ps { background-position: -380px -500px; }
+.emoji-flag_pt { background-position: -400px -500px; }
+.emoji-flag_pw { background-position: -420px -500px; }
+.emoji-flag_py { background-position: -440px -500px; }
+.emoji-flag_qa { background-position: -460px -500px; }
+.emoji-flag_re { background-position: -480px -500px; }
+.emoji-flag_ro { background-position: -500px -500px; }
+.emoji-flag_rs { background-position: -520px 0; }
+.emoji-flag_ru { background-position: -520px -20px; }
+.emoji-flag_rw { background-position: -520px -40px; }
+.emoji-flag_sa { background-position: -520px -60px; }
+.emoji-flag_sb { background-position: -520px -80px; }
+.emoji-flag_sc { background-position: -520px -100px; }
+.emoji-flag_sd { background-position: -520px -120px; }
+.emoji-flag_se { background-position: -520px -140px; }
+.emoji-flag_sg { background-position: -520px -160px; }
+.emoji-flag_sh { background-position: -520px -180px; }
+.emoji-flag_si { background-position: -520px -200px; }
+.emoji-flag_sj { background-position: -520px -220px; }
+.emoji-flag_sk { background-position: -520px -240px; }
+.emoji-flag_sl { background-position: -520px -260px; }
+.emoji-flag_sm { background-position: -520px -280px; }
+.emoji-flag_sn { background-position: -520px -300px; }
+.emoji-flag_so { background-position: -520px -320px; }
+.emoji-flag_sr { background-position: -520px -340px; }
+.emoji-flag_ss { background-position: -520px -360px; }
+.emoji-flag_st { background-position: -520px -380px; }
+.emoji-flag_sv { background-position: -520px -400px; }
+.emoji-flag_sx { background-position: -520px -420px; }
+.emoji-flag_sy { background-position: -520px -440px; }
+.emoji-flag_sz { background-position: -520px -460px; }
+.emoji-flag_ta { background-position: -520px -480px; }
+.emoji-flag_tc { background-position: -520px -500px; }
+.emoji-flag_td { background-position: 0 -520px; }
+.emoji-flag_tf { background-position: -20px -520px; }
+.emoji-flag_tg { background-position: -40px -520px; }
+.emoji-flag_th { background-position: -60px -520px; }
+.emoji-flag_tj { background-position: -80px -520px; }
+.emoji-flag_tk { background-position: -100px -520px; }
+.emoji-flag_tl { background-position: -120px -520px; }
+.emoji-flag_tm { background-position: -140px -520px; }
+.emoji-flag_tn { background-position: -160px -520px; }
+.emoji-flag_to { background-position: -180px -520px; }
+.emoji-flag_tr { background-position: -200px -520px; }
+.emoji-flag_tt { background-position: -220px -520px; }
+.emoji-flag_tv { background-position: -240px -520px; }
+.emoji-flag_tw { background-position: -260px -520px; }
+.emoji-flag_tz { background-position: -280px -520px; }
+.emoji-flag_ua { background-position: -300px -520px; }
+.emoji-flag_ug { background-position: -320px -520px; }
+.emoji-flag_um { background-position: -340px -520px; }
+.emoji-flag_us { background-position: -360px -520px; }
+.emoji-flag_uy { background-position: -380px -520px; }
+.emoji-flag_uz { background-position: -400px -520px; }
+.emoji-flag_va { background-position: -420px -520px; }
+.emoji-flag_vc { background-position: -440px -520px; }
+.emoji-flag_ve { background-position: -460px -520px; }
+.emoji-flag_vg { background-position: -480px -520px; }
+.emoji-flag_vi { background-position: -500px -520px; }
+.emoji-flag_vn { background-position: -520px -520px; }
+.emoji-flag_vu { background-position: -540px 0; }
+.emoji-flag_wf { background-position: -540px -20px; }
+.emoji-flag_white { background-position: -540px -40px; }
+.emoji-flag_ws { background-position: -540px -60px; }
+.emoji-flag_xk { background-position: -540px -80px; }
+.emoji-flag_ye { background-position: -540px -100px; }
+.emoji-flag_yt { background-position: -540px -120px; }
+.emoji-flag_za { background-position: -540px -140px; }
+.emoji-flag_zm { background-position: -540px -160px; }
+.emoji-flag_zw { background-position: -540px -180px; }
+.emoji-flags { background-position: -540px -200px; }
+.emoji-flashlight { background-position: -540px -220px; }
+.emoji-fleur-de-lis { background-position: -540px -240px; }
+.emoji-floppy_disk { background-position: -540px -260px; }
+.emoji-flower_playing_cards { background-position: -540px -280px; }
+.emoji-flushed { background-position: -540px -300px; }
+.emoji-fog { background-position: -540px -320px; }
+.emoji-foggy { background-position: -540px -340px; }
+.emoji-football { background-position: -540px -360px; }
+.emoji-footprints { background-position: -540px -380px; }
+.emoji-fork_and_knife { background-position: -540px -400px; }
+.emoji-fork_knife_plate { background-position: -540px -420px; }
+.emoji-fountain { background-position: -540px -440px; }
+.emoji-four { background-position: -540px -460px; }
+.emoji-four_leaf_clover { background-position: -540px -480px; }
+.emoji-fox { background-position: -540px -500px; }
+.emoji-frame_photo { background-position: -540px -520px; }
+.emoji-free { background-position: 0 -540px; }
+.emoji-french_bread { background-position: -20px -540px; }
+.emoji-fried_shrimp { background-position: -40px -540px; }
+.emoji-fries { background-position: -60px -540px; }
+.emoji-frog { background-position: -80px -540px; }
+.emoji-frowning { background-position: -100px -540px; }
+.emoji-frowning2 { background-position: -120px -540px; }
+.emoji-fuelpump { background-position: -140px -540px; }
+.emoji-full_moon { background-position: -160px -540px; }
+.emoji-full_moon_with_face { background-position: -180px -540px; }
+.emoji-game_die { background-position: -200px -540px; }
+.emoji-gay_pride_flag { background-position: -220px -540px; }
+.emoji-gear { background-position: -240px -540px; }
+.emoji-gem { background-position: -260px -540px; }
+.emoji-gemini { background-position: -280px -540px; }
+.emoji-ghost { background-position: -300px -540px; }
+.emoji-gift { background-position: -320px -540px; }
+.emoji-gift_heart { background-position: -340px -540px; }
+.emoji-girl { background-position: -360px -540px; }
+.emoji-girl_tone1 { background-position: -380px -540px; }
+.emoji-girl_tone2 { background-position: -400px -540px; }
+.emoji-girl_tone3 { background-position: -420px -540px; }
+.emoji-girl_tone4 { background-position: -440px -540px; }
+.emoji-girl_tone5 { background-position: -460px -540px; }
+.emoji-globe_with_meridians { background-position: -480px -540px; }
+.emoji-goal { background-position: -500px -540px; }
+.emoji-goat { background-position: -520px -540px; }
+.emoji-golf { background-position: -540px -540px; }
+.emoji-golfer { background-position: -560px 0; }
+.emoji-gorilla { background-position: -560px -20px; }
+.emoji-grapes { background-position: -560px -40px; }
+.emoji-green_apple { background-position: -560px -60px; }
+.emoji-green_book { background-position: -560px -80px; }
+.emoji-green_heart { background-position: -560px -100px; }
+.emoji-grey_exclamation { background-position: -560px -120px; }
+.emoji-grey_question { background-position: -560px -140px; }
+.emoji-grimacing { background-position: -560px -160px; }
+.emoji-grin { background-position: -560px -180px; }
+.emoji-grinning { background-position: -560px -200px; }
+.emoji-guardsman { background-position: -560px -220px; }
+.emoji-guardsman_tone1 { background-position: -560px -240px; }
+.emoji-guardsman_tone2 { background-position: -560px -260px; }
+.emoji-guardsman_tone3 { background-position: -560px -280px; }
+.emoji-guardsman_tone4 { background-position: -560px -300px; }
+.emoji-guardsman_tone5 { background-position: -560px -320px; }
+.emoji-guitar { background-position: -560px -340px; }
+.emoji-gun { background-position: -560px -360px; }
+.emoji-haircut { background-position: -560px -380px; }
+.emoji-haircut_tone1 { background-position: -560px -400px; }
+.emoji-haircut_tone2 { background-position: -560px -420px; }
+.emoji-haircut_tone3 { background-position: -560px -440px; }
+.emoji-haircut_tone4 { background-position: -560px -460px; }
+.emoji-haircut_tone5 { background-position: -560px -480px; }
+.emoji-hamburger { background-position: -560px -500px; }
+.emoji-hammer { background-position: -560px -520px; }
+.emoji-hammer_pick { background-position: -560px -540px; }
+.emoji-hamster { background-position: 0 -560px; }
+.emoji-hand_splayed { background-position: -20px -560px; }
+.emoji-hand_splayed_tone1 { background-position: -40px -560px; }
+.emoji-hand_splayed_tone2 { background-position: -60px -560px; }
+.emoji-hand_splayed_tone3 { background-position: -80px -560px; }
+.emoji-hand_splayed_tone4 { background-position: -100px -560px; }
+.emoji-hand_splayed_tone5 { background-position: -120px -560px; }
+.emoji-handbag { background-position: -140px -560px; }
+.emoji-handball { background-position: -160px -560px; }
+.emoji-handball_tone1 { background-position: -180px -560px; }
+.emoji-handball_tone2 { background-position: -200px -560px; }
+.emoji-handball_tone3 { background-position: -220px -560px; }
+.emoji-handball_tone4 { background-position: -240px -560px; }
+.emoji-handball_tone5 { background-position: -260px -560px; }
+.emoji-handshake { background-position: -280px -560px; }
+.emoji-handshake_tone1 { background-position: -300px -560px; }
+.emoji-handshake_tone2 { background-position: -320px -560px; }
+.emoji-handshake_tone3 { background-position: -340px -560px; }
+.emoji-handshake_tone4 { background-position: -360px -560px; }
+.emoji-handshake_tone5 { background-position: -380px -560px; }
+.emoji-hash { background-position: -400px -560px; }
+.emoji-hatched_chick { background-position: -420px -560px; }
+.emoji-hatching_chick { background-position: -440px -560px; }
+.emoji-head_bandage { background-position: -460px -560px; }
+.emoji-headphones { background-position: -480px -560px; }
+.emoji-hear_no_evil { background-position: -500px -560px; }
+.emoji-heart { background-position: -520px -560px; }
+.emoji-heart_decoration { background-position: -540px -560px; }
+.emoji-heart_exclamation { background-position: -560px -560px; }
+.emoji-heart_eyes { background-position: -580px 0; }
+.emoji-heart_eyes_cat { background-position: -580px -20px; }
+.emoji-heartbeat { background-position: -580px -40px; }
+.emoji-heartpulse { background-position: -580px -60px; }
+.emoji-hearts { background-position: -580px -80px; }
+.emoji-heavy_check_mark { background-position: -580px -100px; }
+.emoji-heavy_division_sign { background-position: -580px -120px; }
+.emoji-heavy_dollar_sign { background-position: -580px -140px; }
+.emoji-heavy_minus_sign { background-position: -580px -160px; }
+.emoji-heavy_multiplication_x { background-position: -580px -180px; }
+.emoji-heavy_plus_sign { background-position: -580px -200px; }
+.emoji-helicopter { background-position: -580px -220px; }
+.emoji-helmet_with_cross { background-position: -580px -240px; }
+.emoji-herb { background-position: -580px -260px; }
+.emoji-hibiscus { background-position: -580px -280px; }
+.emoji-high_brightness { background-position: -580px -300px; }
+.emoji-high_heel { background-position: -580px -320px; }
+.emoji-hockey { background-position: -580px -340px; }
+.emoji-hole { background-position: -580px -360px; }
+.emoji-homes { background-position: -580px -380px; }
+.emoji-honey_pot { background-position: -580px -400px; }
+.emoji-horse { background-position: -580px -420px; }
+.emoji-horse_racing { background-position: -580px -440px; }
+.emoji-horse_racing_tone1 { background-position: -580px -460px; }
+.emoji-horse_racing_tone2 { background-position: -580px -480px; }
+.emoji-horse_racing_tone3 { background-position: -580px -500px; }
+.emoji-horse_racing_tone4 { background-position: -580px -520px; }
+.emoji-horse_racing_tone5 { background-position: -580px -540px; }
+.emoji-hospital { background-position: -580px -560px; }
+.emoji-hot_pepper { background-position: 0 -580px; }
+.emoji-hotdog { background-position: -20px -580px; }
+.emoji-hotel { background-position: -40px -580px; }
+.emoji-hotsprings { background-position: -60px -580px; }
+.emoji-hourglass { background-position: -80px -580px; }
+.emoji-hourglass_flowing_sand { background-position: -100px -580px; }
+.emoji-house { background-position: -120px -580px; }
+.emoji-house_abandoned { background-position: -140px -580px; }
+.emoji-house_with_garden { background-position: -160px -580px; }
+.emoji-hugging { background-position: -180px -580px; }
+.emoji-hushed { background-position: -200px -580px; }
+.emoji-ice_cream { background-position: -220px -580px; }
+.emoji-ice_skate { background-position: -240px -580px; }
+.emoji-icecream { background-position: -260px -580px; }
+.emoji-id { background-position: -280px -580px; }
+.emoji-ideograph_advantage { background-position: -300px -580px; }
+.emoji-imp { background-position: -320px -580px; }
+.emoji-inbox_tray { background-position: -340px -580px; }
+.emoji-incoming_envelope { background-position: -360px -580px; }
+.emoji-information_desk_person { background-position: -380px -580px; }
+.emoji-information_desk_person_tone1 { background-position: -400px -580px; }
+.emoji-information_desk_person_tone2 { background-position: -420px -580px; }
+.emoji-information_desk_person_tone3 { background-position: -440px -580px; }
+.emoji-information_desk_person_tone4 { background-position: -460px -580px; }
+.emoji-information_desk_person_tone5 { background-position: -480px -580px; }
+.emoji-information_source { background-position: -500px -580px; }
+.emoji-innocent { background-position: -520px -580px; }
+.emoji-interrobang { background-position: -540px -580px; }
+.emoji-iphone { background-position: -560px -580px; }
+.emoji-island { background-position: -580px -580px; }
+.emoji-izakaya_lantern { background-position: -600px 0; }
+.emoji-jack_o_lantern { background-position: -600px -20px; }
+.emoji-japan { background-position: -600px -40px; }
+.emoji-japanese_castle { background-position: -600px -60px; }
+.emoji-japanese_goblin { background-position: -600px -80px; }
+.emoji-japanese_ogre { background-position: -600px -100px; }
+.emoji-jeans { background-position: -600px -120px; }
+.emoji-joy { background-position: -600px -140px; }
+.emoji-joy_cat { background-position: -600px -160px; }
+.emoji-joystick { background-position: -600px -180px; }
+.emoji-juggling { background-position: -600px -200px; }
+.emoji-juggling_tone1 { background-position: -600px -220px; }
+.emoji-juggling_tone2 { background-position: -600px -240px; }
+.emoji-juggling_tone3 { background-position: -600px -260px; }
+.emoji-juggling_tone4 { background-position: -600px -280px; }
+.emoji-juggling_tone5 { background-position: -600px -300px; }
+.emoji-kaaba { background-position: -600px -320px; }
+.emoji-key { background-position: -600px -340px; }
+.emoji-key2 { background-position: -600px -360px; }
+.emoji-keyboard { background-position: -600px -380px; }
+.emoji-kimono { background-position: -600px -400px; }
+.emoji-kiss { background-position: -600px -420px; }
+.emoji-kiss_mm { background-position: -600px -440px; }
+.emoji-kiss_ww { background-position: -600px -460px; }
+.emoji-kissing { background-position: -600px -480px; }
+.emoji-kissing_cat { background-position: -600px -500px; }
+.emoji-kissing_closed_eyes { background-position: -600px -520px; }
+.emoji-kissing_heart { background-position: -600px -540px; }
+.emoji-kissing_smiling_eyes { background-position: -600px -560px; }
+.emoji-kiwi { background-position: -600px -580px; }
+.emoji-knife { background-position: 0 -600px; }
+.emoji-koala { background-position: -20px -600px; }
+.emoji-koko { background-position: -40px -600px; }
+.emoji-label { background-position: -60px -600px; }
+.emoji-large_blue_circle { background-position: -80px -600px; }
+.emoji-large_blue_diamond { background-position: -100px -600px; }
+.emoji-large_orange_diamond { background-position: -120px -600px; }
+.emoji-last_quarter_moon { background-position: -140px -600px; }
+.emoji-last_quarter_moon_with_face { background-position: -160px -600px; }
+.emoji-laughing { background-position: -180px -600px; }
+.emoji-leaves { background-position: -200px -600px; }
+.emoji-ledger { background-position: -220px -600px; }
+.emoji-left_facing_fist { background-position: -240px -600px; }
+.emoji-left_facing_fist_tone1 { background-position: -260px -600px; }
+.emoji-left_facing_fist_tone2 { background-position: -280px -600px; }
+.emoji-left_facing_fist_tone3 { background-position: -300px -600px; }
+.emoji-left_facing_fist_tone4 { background-position: -320px -600px; }
+.emoji-left_facing_fist_tone5 { background-position: -340px -600px; }
+.emoji-left_luggage { background-position: -360px -600px; }
+.emoji-left_right_arrow { background-position: -380px -600px; }
+.emoji-leftwards_arrow_with_hook { background-position: -400px -600px; }
+.emoji-lemon { background-position: -420px -600px; }
+.emoji-leo { background-position: -440px -600px; }
+.emoji-leopard { background-position: -460px -600px; }
+.emoji-level_slider { background-position: -480px -600px; }
+.emoji-levitate { background-position: -500px -600px; }
+.emoji-libra { background-position: -520px -600px; }
+.emoji-lifter { background-position: -540px -600px; }
+.emoji-lifter_tone1 { background-position: -560px -600px; }
+.emoji-lifter_tone2 { background-position: -580px -600px; }
+.emoji-lifter_tone3 { background-position: -600px -600px; }
+.emoji-lifter_tone4 { background-position: -620px 0; }
+.emoji-lifter_tone5 { background-position: -620px -20px; }
+.emoji-light_rail { background-position: -620px -40px; }
+.emoji-link { background-position: -620px -60px; }
+.emoji-lion_face { background-position: -620px -80px; }
+.emoji-lips { background-position: -620px -100px; }
+.emoji-lipstick { background-position: -620px -120px; }
+.emoji-lizard { background-position: -620px -140px; }
+.emoji-lock { background-position: -620px -160px; }
+.emoji-lock_with_ink_pen { background-position: -620px -180px; }
+.emoji-lollipop { background-position: -620px -200px; }
+.emoji-loop { background-position: -620px -220px; }
+.emoji-loud_sound { background-position: -620px -240px; }
+.emoji-loudspeaker { background-position: -620px -260px; }
+.emoji-love_hotel { background-position: -620px -280px; }
+.emoji-love_letter { background-position: -620px -300px; }
+.emoji-low_brightness { background-position: -620px -320px; }
+.emoji-lying_face { background-position: -620px -340px; }
+.emoji-m { background-position: -620px -360px; }
+.emoji-mag { background-position: -620px -380px; }
+.emoji-mag_right { background-position: -620px -400px; }
+.emoji-mahjong { background-position: -620px -420px; }
+.emoji-mailbox { background-position: -620px -440px; }
+.emoji-mailbox_closed { background-position: -620px -460px; }
+.emoji-mailbox_with_mail { background-position: -620px -480px; }
+.emoji-mailbox_with_no_mail { background-position: -620px -500px; }
+.emoji-man { background-position: -620px -520px; }
+.emoji-man_dancing { background-position: -620px -540px; }
+.emoji-man_dancing_tone1 { background-position: -620px -560px; }
+.emoji-man_dancing_tone2 { background-position: -620px -580px; }
+.emoji-man_dancing_tone3 { background-position: -620px -600px; }
+.emoji-man_dancing_tone4 { background-position: 0 -620px; }
+.emoji-man_dancing_tone5 { background-position: -20px -620px; }
+.emoji-man_in_tuxedo { background-position: -40px -620px; }
+.emoji-man_in_tuxedo_tone1 { background-position: -60px -620px; }
+.emoji-man_in_tuxedo_tone2 { background-position: -80px -620px; }
+.emoji-man_in_tuxedo_tone3 { background-position: -100px -620px; }
+.emoji-man_in_tuxedo_tone4 { background-position: -120px -620px; }
+.emoji-man_in_tuxedo_tone5 { background-position: -140px -620px; }
+.emoji-man_tone1 { background-position: -160px -620px; }
+.emoji-man_tone2 { background-position: -180px -620px; }
+.emoji-man_tone3 { background-position: -200px -620px; }
+.emoji-man_tone4 { background-position: -220px -620px; }
+.emoji-man_tone5 { background-position: -240px -620px; }
+.emoji-man_with_gua_pi_mao { background-position: -260px -620px; }
+.emoji-man_with_gua_pi_mao_tone1 { background-position: -280px -620px; }
+.emoji-man_with_gua_pi_mao_tone2 { background-position: -300px -620px; }
+.emoji-man_with_gua_pi_mao_tone3 { background-position: -320px -620px; }
+.emoji-man_with_gua_pi_mao_tone4 { background-position: -340px -620px; }
+.emoji-man_with_gua_pi_mao_tone5 { background-position: -360px -620px; }
+.emoji-man_with_turban { background-position: -380px -620px; }
+.emoji-man_with_turban_tone1 { background-position: -400px -620px; }
+.emoji-man_with_turban_tone2 { background-position: -420px -620px; }
+.emoji-man_with_turban_tone3 { background-position: -440px -620px; }
+.emoji-man_with_turban_tone4 { background-position: -460px -620px; }
+.emoji-man_with_turban_tone5 { background-position: -480px -620px; }
+.emoji-mans_shoe { background-position: -500px -620px; }
+.emoji-map { background-position: -520px -620px; }
+.emoji-maple_leaf { background-position: -540px -620px; }
+.emoji-martial_arts_uniform { background-position: -560px -620px; }
+.emoji-mask { background-position: -580px -620px; }
+.emoji-massage { background-position: -600px -620px; }
+.emoji-massage_tone1 { background-position: -620px -620px; }
+.emoji-massage_tone2 { background-position: -640px 0; }
+.emoji-massage_tone3 { background-position: -640px -20px; }
+.emoji-massage_tone4 { background-position: -640px -40px; }
+.emoji-massage_tone5 { background-position: -640px -60px; }
+.emoji-meat_on_bone { background-position: -640px -80px; }
+.emoji-medal { background-position: -640px -100px; }
+.emoji-mega { background-position: -640px -120px; }
+.emoji-melon { background-position: -640px -140px; }
+.emoji-menorah { background-position: -640px -160px; }
+.emoji-mens { background-position: -640px -180px; }
+.emoji-metal { background-position: -640px -200px; }
+.emoji-metal_tone1 { background-position: -640px -220px; }
+.emoji-metal_tone2 { background-position: -640px -240px; }
+.emoji-metal_tone3 { background-position: -640px -260px; }
+.emoji-metal_tone4 { background-position: -640px -280px; }
+.emoji-metal_tone5 { background-position: -640px -300px; }
+.emoji-metro { background-position: -640px -320px; }
+.emoji-microphone { background-position: -640px -340px; }
+.emoji-microphone2 { background-position: -640px -360px; }
+.emoji-microscope { background-position: -640px -380px; }
+.emoji-middle_finger { background-position: -640px -400px; }
+.emoji-middle_finger_tone1 { background-position: -640px -420px; }
+.emoji-middle_finger_tone2 { background-position: -640px -440px; }
+.emoji-middle_finger_tone3 { background-position: -640px -460px; }
+.emoji-middle_finger_tone4 { background-position: -640px -480px; }
+.emoji-middle_finger_tone5 { background-position: -640px -500px; }
+.emoji-military_medal { background-position: -640px -520px; }
+.emoji-milk { background-position: -640px -540px; }
+.emoji-milky_way { background-position: -640px -560px; }
+.emoji-minibus { background-position: -640px -580px; }
+.emoji-minidisc { background-position: -640px -600px; }
+.emoji-mobile_phone_off { background-position: -640px -620px; }
+.emoji-money_mouth { background-position: 0 -640px; }
+.emoji-money_with_wings { background-position: -20px -640px; }
+.emoji-moneybag { background-position: -40px -640px; }
+.emoji-monkey { background-position: -60px -640px; }
+.emoji-monkey_face { background-position: -80px -640px; }
+.emoji-monorail { background-position: -100px -640px; }
+.emoji-mortar_board { background-position: -120px -640px; }
+.emoji-mosque { background-position: -140px -640px; }
+.emoji-motor_scooter { background-position: -160px -640px; }
+.emoji-motorboat { background-position: -180px -640px; }
+.emoji-motorcycle { background-position: -200px -640px; }
+.emoji-motorway { background-position: -220px -640px; }
+.emoji-mount_fuji { background-position: -240px -640px; }
+.emoji-mountain { background-position: -260px -640px; }
+.emoji-mountain_bicyclist { background-position: -280px -640px; }
+.emoji-mountain_bicyclist_tone1 { background-position: -300px -640px; }
+.emoji-mountain_bicyclist_tone2 { background-position: -320px -640px; }
+.emoji-mountain_bicyclist_tone3 { background-position: -340px -640px; }
+.emoji-mountain_bicyclist_tone4 { background-position: -360px -640px; }
+.emoji-mountain_bicyclist_tone5 { background-position: -380px -640px; }
+.emoji-mountain_cableway { background-position: -400px -640px; }
+.emoji-mountain_railway { background-position: -420px -640px; }
+.emoji-mountain_snow { background-position: -440px -640px; }
+.emoji-mouse { background-position: -460px -640px; }
+.emoji-mouse2 { background-position: -480px -640px; }
+.emoji-mouse_three_button { background-position: -500px -640px; }
+.emoji-movie_camera { background-position: -520px -640px; }
+.emoji-moyai { background-position: -540px -640px; }
+.emoji-mrs_claus { background-position: -560px -640px; }
+.emoji-mrs_claus_tone1 { background-position: -580px -640px; }
+.emoji-mrs_claus_tone2 { background-position: -600px -640px; }
+.emoji-mrs_claus_tone3 { background-position: -620px -640px; }
+.emoji-mrs_claus_tone4 { background-position: -640px -640px; }
+.emoji-mrs_claus_tone5 { background-position: -660px 0; }
+.emoji-muscle { background-position: -660px -20px; }
+.emoji-muscle_tone1 { background-position: -660px -40px; }
+.emoji-muscle_tone2 { background-position: -660px -60px; }
+.emoji-muscle_tone3 { background-position: -660px -80px; }
+.emoji-muscle_tone4 { background-position: -660px -100px; }
+.emoji-muscle_tone5 { background-position: -660px -120px; }
+.emoji-mushroom { background-position: -660px -140px; }
+.emoji-musical_keyboard { background-position: -660px -160px; }
+.emoji-musical_note { background-position: -660px -180px; }
+.emoji-musical_score { background-position: -660px -200px; }
+.emoji-mute { background-position: -660px -220px; }
+.emoji-nail_care { background-position: -660px -240px; }
+.emoji-nail_care_tone1 { background-position: -660px -260px; }
+.emoji-nail_care_tone2 { background-position: -660px -280px; }
+.emoji-nail_care_tone3 { background-position: -660px -300px; }
+.emoji-nail_care_tone4 { background-position: -660px -320px; }
+.emoji-nail_care_tone5 { background-position: -660px -340px; }
+.emoji-name_badge { background-position: -660px -360px; }
+.emoji-nauseated_face { background-position: -660px -380px; }
+.emoji-necktie { background-position: -660px -400px; }
+.emoji-negative_squared_cross_mark { background-position: -660px -420px; }
+.emoji-nerd { background-position: -660px -440px; }
+.emoji-neutral_face { background-position: -660px -460px; }
+.emoji-new { background-position: -660px -480px; }
+.emoji-new_moon { background-position: -660px -500px; }
+.emoji-new_moon_with_face { background-position: -660px -520px; }
+.emoji-newspaper { background-position: -660px -540px; }
+.emoji-newspaper2 { background-position: -660px -560px; }
+.emoji-ng { background-position: -660px -580px; }
+.emoji-night_with_stars { background-position: -660px -600px; }
+.emoji-nine { background-position: -660px -620px; }
+.emoji-no_bell { background-position: -660px -640px; }
+.emoji-no_bicycles { background-position: 0 -660px; }
+.emoji-no_entry { background-position: -20px -660px; }
+.emoji-no_entry_sign { background-position: -40px -660px; }
+.emoji-no_good { background-position: -60px -660px; }
+.emoji-no_good_tone1 { background-position: -80px -660px; }
+.emoji-no_good_tone2 { background-position: -100px -660px; }
+.emoji-no_good_tone3 { background-position: -120px -660px; }
+.emoji-no_good_tone4 { background-position: -140px -660px; }
+.emoji-no_good_tone5 { background-position: -160px -660px; }
+.emoji-no_mobile_phones { background-position: -180px -660px; }
+.emoji-no_mouth { background-position: -200px -660px; }
+.emoji-no_pedestrians { background-position: -220px -660px; }
+.emoji-no_smoking { background-position: -240px -660px; }
+.emoji-non-potable_water { background-position: -260px -660px; }
+.emoji-nose { background-position: -280px -660px; }
+.emoji-nose_tone1 { background-position: -300px -660px; }
+.emoji-nose_tone2 { background-position: -320px -660px; }
+.emoji-nose_tone3 { background-position: -340px -660px; }
+.emoji-nose_tone4 { background-position: -360px -660px; }
+.emoji-nose_tone5 { background-position: -380px -660px; }
+.emoji-notebook { background-position: -400px -660px; }
+.emoji-notebook_with_decorative_cover { background-position: -420px -660px; }
+.emoji-notepad_spiral { background-position: -440px -660px; }
+.emoji-notes { background-position: -460px -660px; }
+.emoji-nut_and_bolt { background-position: -480px -660px; }
+.emoji-o { background-position: -500px -660px; }
+.emoji-o2 { background-position: -520px -660px; }
+.emoji-ocean { background-position: -540px -660px; }
+.emoji-octagonal_sign { background-position: -560px -660px; }
+.emoji-octopus { background-position: -580px -660px; }
+.emoji-oden { background-position: -600px -660px; }
+.emoji-office { background-position: -620px -660px; }
+.emoji-oil { background-position: -640px -660px; }
+.emoji-ok { background-position: -660px -660px; }
+.emoji-ok_hand { background-position: -680px 0; }
+.emoji-ok_hand_tone1 { background-position: -680px -20px; }
+.emoji-ok_hand_tone2 { background-position: -680px -40px; }
+.emoji-ok_hand_tone3 { background-position: -680px -60px; }
+.emoji-ok_hand_tone4 { background-position: -680px -80px; }
+.emoji-ok_hand_tone5 { background-position: -680px -100px; }
+.emoji-ok_woman { background-position: -680px -120px; }
+.emoji-ok_woman_tone1 { background-position: -680px -140px; }
+.emoji-ok_woman_tone2 { background-position: -680px -160px; }
+.emoji-ok_woman_tone3 { background-position: -680px -180px; }
+.emoji-ok_woman_tone4 { background-position: -680px -200px; }
+.emoji-ok_woman_tone5 { background-position: -680px -220px; }
+.emoji-older_man { background-position: -680px -240px; }
+.emoji-older_man_tone1 { background-position: -680px -260px; }
+.emoji-older_man_tone2 { background-position: -680px -280px; }
+.emoji-older_man_tone3 { background-position: -680px -300px; }
+.emoji-older_man_tone4 { background-position: -680px -320px; }
+.emoji-older_man_tone5 { background-position: -680px -340px; }
+.emoji-older_woman { background-position: -680px -360px; }
+.emoji-older_woman_tone1 { background-position: -680px -380px; }
+.emoji-older_woman_tone2 { background-position: -680px -400px; }
+.emoji-older_woman_tone3 { background-position: -680px -420px; }
+.emoji-older_woman_tone4 { background-position: -680px -440px; }
+.emoji-older_woman_tone5 { background-position: -680px -460px; }
+.emoji-om_symbol { background-position: -680px -480px; }
+.emoji-on { background-position: -680px -500px; }
+.emoji-oncoming_automobile { background-position: -680px -520px; }
+.emoji-oncoming_bus { background-position: -680px -540px; }
+.emoji-oncoming_police_car { background-position: -680px -560px; }
+.emoji-oncoming_taxi { background-position: -680px -580px; }
+.emoji-one { background-position: -680px -600px; }
+.emoji-open_file_folder { background-position: -680px -620px; }
+.emoji-open_hands { background-position: -680px -640px; }
+.emoji-open_hands_tone1 { background-position: -680px -660px; }
+.emoji-open_hands_tone2 { background-position: 0 -680px; }
+.emoji-open_hands_tone3 { background-position: -20px -680px; }
+.emoji-open_hands_tone4 { background-position: -40px -680px; }
+.emoji-open_hands_tone5 { background-position: -60px -680px; }
+.emoji-open_mouth { background-position: -80px -680px; }
+.emoji-ophiuchus { background-position: -100px -680px; }
+.emoji-orange_book { background-position: -120px -680px; }
+.emoji-orthodox_cross { background-position: -140px -680px; }
+.emoji-outbox_tray { background-position: -160px -680px; }
+.emoji-owl { background-position: -180px -680px; }
+.emoji-ox { background-position: -200px -680px; }
+.emoji-package { background-position: -220px -680px; }
+.emoji-page_facing_up { background-position: -240px -680px; }
+.emoji-page_with_curl { background-position: -260px -680px; }
+.emoji-pager { background-position: -280px -680px; }
+.emoji-paintbrush { background-position: -300px -680px; }
+.emoji-palm_tree { background-position: -320px -680px; }
+.emoji-pancakes { background-position: -340px -680px; }
+.emoji-panda_face { background-position: -360px -680px; }
+.emoji-paperclip { background-position: -380px -680px; }
+.emoji-paperclips { background-position: -400px -680px; }
+.emoji-park { background-position: -420px -680px; }
+.emoji-parking { background-position: -440px -680px; }
+.emoji-part_alternation_mark { background-position: -460px -680px; }
+.emoji-partly_sunny { background-position: -480px -680px; }
+.emoji-passport_control { background-position: -500px -680px; }
+.emoji-pause_button { background-position: -520px -680px; }
+.emoji-peace { background-position: -540px -680px; }
+.emoji-peach { background-position: -560px -680px; }
+.emoji-peanuts { background-position: -580px -680px; }
+.emoji-pear { background-position: -600px -680px; }
+.emoji-pen_ballpoint { background-position: -620px -680px; }
+.emoji-pen_fountain { background-position: -640px -680px; }
+.emoji-pencil { background-position: -660px -680px; }
+.emoji-pencil2 { background-position: -680px -680px; }
+.emoji-penguin { background-position: -700px 0; }
+.emoji-pensive { background-position: -700px -20px; }
+.emoji-performing_arts { background-position: -700px -40px; }
+.emoji-persevere { background-position: -700px -60px; }
+.emoji-person_frowning { background-position: -700px -80px; }
+.emoji-person_frowning_tone1 { background-position: -700px -100px; }
+.emoji-person_frowning_tone2 { background-position: -700px -120px; }
+.emoji-person_frowning_tone3 { background-position: -700px -140px; }
+.emoji-person_frowning_tone4 { background-position: -700px -160px; }
+.emoji-person_frowning_tone5 { background-position: -700px -180px; }
+.emoji-person_with_blond_hair { background-position: -700px -200px; }
+.emoji-person_with_blond_hair_tone1 { background-position: -700px -220px; }
+.emoji-person_with_blond_hair_tone2 { background-position: -700px -240px; }
+.emoji-person_with_blond_hair_tone3 { background-position: -700px -260px; }
+.emoji-person_with_blond_hair_tone4 { background-position: -700px -280px; }
+.emoji-person_with_blond_hair_tone5 { background-position: -700px -300px; }
+.emoji-person_with_pouting_face { background-position: -700px -320px; }
+.emoji-person_with_pouting_face_tone1 { background-position: -700px -340px; }
+.emoji-person_with_pouting_face_tone2 { background-position: -700px -360px; }
+.emoji-person_with_pouting_face_tone3 { background-position: -700px -380px; }
+.emoji-person_with_pouting_face_tone4 { background-position: -700px -400px; }
+.emoji-person_with_pouting_face_tone5 { background-position: -700px -420px; }
+.emoji-pick { background-position: -700px -440px; }
+.emoji-pig { background-position: -700px -460px; }
+.emoji-pig2 { background-position: -700px -480px; }
+.emoji-pig_nose { background-position: -700px -500px; }
+.emoji-pill { background-position: -700px -520px; }
+.emoji-pineapple { background-position: -700px -540px; }
+.emoji-ping_pong { background-position: -700px -560px; }
+.emoji-pisces { background-position: -700px -580px; }
+.emoji-pizza { background-position: -700px -600px; }
+.emoji-place_of_worship { background-position: -700px -620px; }
+.emoji-play_pause { background-position: -700px -640px; }
+.emoji-point_down { background-position: -700px -660px; }
+.emoji-point_down_tone1 { background-position: -700px -680px; }
+.emoji-point_down_tone2 { background-position: 0 -700px; }
+.emoji-point_down_tone3 { background-position: -20px -700px; }
+.emoji-point_down_tone4 { background-position: -40px -700px; }
+.emoji-point_down_tone5 { background-position: -60px -700px; }
+.emoji-point_left { background-position: -80px -700px; }
+.emoji-point_left_tone1 { background-position: -100px -700px; }
+.emoji-point_left_tone2 { background-position: -120px -700px; }
+.emoji-point_left_tone3 { background-position: -140px -700px; }
+.emoji-point_left_tone4 { background-position: -160px -700px; }
+.emoji-point_left_tone5 { background-position: -180px -700px; }
+.emoji-point_right { background-position: -200px -700px; }
+.emoji-point_right_tone1 { background-position: -220px -700px; }
+.emoji-point_right_tone2 { background-position: -240px -700px; }
+.emoji-point_right_tone3 { background-position: -260px -700px; }
+.emoji-point_right_tone4 { background-position: -280px -700px; }
+.emoji-point_right_tone5 { background-position: -300px -700px; }
+.emoji-point_up { background-position: -320px -700px; }
+.emoji-point_up_2 { background-position: -340px -700px; }
+.emoji-point_up_2_tone1 { background-position: -360px -700px; }
+.emoji-point_up_2_tone2 { background-position: -380px -700px; }
+.emoji-point_up_2_tone3 { background-position: -400px -700px; }
+.emoji-point_up_2_tone4 { background-position: -420px -700px; }
+.emoji-point_up_2_tone5 { background-position: -440px -700px; }
+.emoji-point_up_tone1 { background-position: -460px -700px; }
+.emoji-point_up_tone2 { background-position: -480px -700px; }
+.emoji-point_up_tone3 { background-position: -500px -700px; }
+.emoji-point_up_tone4 { background-position: -520px -700px; }
+.emoji-point_up_tone5 { background-position: -540px -700px; }
+.emoji-police_car { background-position: -560px -700px; }
+.emoji-poodle { background-position: -580px -700px; }
+.emoji-poop { background-position: -600px -700px; }
+.emoji-popcorn { background-position: -620px -700px; }
+.emoji-post_office { background-position: -640px -700px; }
+.emoji-postal_horn { background-position: -660px -700px; }
+.emoji-postbox { background-position: -680px -700px; }
+.emoji-potable_water { background-position: -700px -700px; }
+.emoji-potato { background-position: -720px 0; }
+.emoji-pouch { background-position: -720px -20px; }
+.emoji-poultry_leg { background-position: -720px -40px; }
+.emoji-pound { background-position: -720px -60px; }
+.emoji-pouting_cat { background-position: -720px -80px; }
+.emoji-pray { background-position: -720px -100px; }
+.emoji-pray_tone1 { background-position: -720px -120px; }
+.emoji-pray_tone2 { background-position: -720px -140px; }
+.emoji-pray_tone3 { background-position: -720px -160px; }
+.emoji-pray_tone4 { background-position: -720px -180px; }
+.emoji-pray_tone5 { background-position: -720px -200px; }
+.emoji-prayer_beads { background-position: -720px -220px; }
+.emoji-pregnant_woman { background-position: -720px -240px; }
+.emoji-pregnant_woman_tone1 { background-position: -720px -260px; }
+.emoji-pregnant_woman_tone2 { background-position: -720px -280px; }
+.emoji-pregnant_woman_tone3 { background-position: -720px -300px; }
+.emoji-pregnant_woman_tone4 { background-position: -720px -320px; }
+.emoji-pregnant_woman_tone5 { background-position: -720px -340px; }
+.emoji-prince { background-position: -720px -360px; }
+.emoji-prince_tone1 { background-position: -720px -380px; }
+.emoji-prince_tone2 { background-position: -720px -400px; }
+.emoji-prince_tone3 { background-position: -720px -420px; }
+.emoji-prince_tone4 { background-position: -720px -440px; }
+.emoji-prince_tone5 { background-position: -720px -460px; }
+.emoji-princess { background-position: -720px -480px; }
+.emoji-princess_tone1 { background-position: -720px -500px; }
+.emoji-princess_tone2 { background-position: -720px -520px; }
+.emoji-princess_tone3 { background-position: -720px -540px; }
+.emoji-princess_tone4 { background-position: -720px -560px; }
+.emoji-princess_tone5 { background-position: -720px -580px; }
+.emoji-printer { background-position: -720px -600px; }
+.emoji-projector { background-position: -720px -620px; }
+.emoji-punch { background-position: -720px -640px; }
+.emoji-punch_tone1 { background-position: -720px -660px; }
+.emoji-punch_tone2 { background-position: -720px -680px; }
+.emoji-punch_tone3 { background-position: -720px -700px; }
+.emoji-punch_tone4 { background-position: 0 -720px; }
+.emoji-punch_tone5 { background-position: -20px -720px; }
+.emoji-purple_heart { background-position: -40px -720px; }
+.emoji-purse { background-position: -60px -720px; }
+.emoji-pushpin { background-position: -80px -720px; }
+.emoji-put_litter_in_its_place { background-position: -100px -720px; }
+.emoji-question { background-position: -120px -720px; }
+.emoji-rabbit { background-position: -140px -720px; }
+.emoji-rabbit2 { background-position: -160px -720px; }
+.emoji-race_car { background-position: -180px -720px; }
+.emoji-racehorse { background-position: -200px -720px; }
+.emoji-radio { background-position: -220px -720px; }
+.emoji-radio_button { background-position: -240px -720px; }
+.emoji-radioactive { background-position: -260px -720px; }
+.emoji-rage { background-position: -280px -720px; }
+.emoji-railway_car { background-position: -300px -720px; }
+.emoji-railway_track { background-position: -320px -720px; }
+.emoji-rainbow { background-position: -340px -720px; }
+.emoji-raised_back_of_hand { background-position: -360px -720px; }
+.emoji-raised_back_of_hand_tone1 { background-position: -380px -720px; }
+.emoji-raised_back_of_hand_tone2 { background-position: -400px -720px; }
+.emoji-raised_back_of_hand_tone3 { background-position: -420px -720px; }
+.emoji-raised_back_of_hand_tone4 { background-position: -440px -720px; }
+.emoji-raised_back_of_hand_tone5 { background-position: -460px -720px; }
+.emoji-raised_hand { background-position: -480px -720px; }
+.emoji-raised_hand_tone1 { background-position: -500px -720px; }
+.emoji-raised_hand_tone2 { background-position: -520px -720px; }
+.emoji-raised_hand_tone3 { background-position: -540px -720px; }
+.emoji-raised_hand_tone4 { background-position: -560px -720px; }
+.emoji-raised_hand_tone5 { background-position: -580px -720px; }
+.emoji-raised_hands { background-position: -600px -720px; }
+.emoji-raised_hands_tone1 { background-position: -620px -720px; }
+.emoji-raised_hands_tone2 { background-position: -640px -720px; }
+.emoji-raised_hands_tone3 { background-position: -660px -720px; }
+.emoji-raised_hands_tone4 { background-position: -680px -720px; }
+.emoji-raised_hands_tone5 { background-position: -700px -720px; }
+.emoji-raising_hand { background-position: -720px -720px; }
+.emoji-raising_hand_tone1 { background-position: -740px 0; }
+.emoji-raising_hand_tone2 { background-position: -740px -20px; }
+.emoji-raising_hand_tone3 { background-position: -740px -40px; }
+.emoji-raising_hand_tone4 { background-position: -740px -60px; }
+.emoji-raising_hand_tone5 { background-position: -740px -80px; }
+.emoji-ram { background-position: -740px -100px; }
+.emoji-ramen { background-position: -740px -120px; }
+.emoji-rat { background-position: -740px -140px; }
+.emoji-record_button { background-position: -740px -160px; }
+.emoji-recycle { background-position: -740px -180px; }
+.emoji-red_car { background-position: -740px -200px; }
+.emoji-red_circle { background-position: -740px -220px; }
+.emoji-registered { background-position: -740px -240px; }
+.emoji-relaxed { background-position: -740px -260px; }
+.emoji-relieved { background-position: -740px -280px; }
+.emoji-reminder_ribbon { background-position: -740px -300px; }
+.emoji-repeat { background-position: -740px -320px; }
+.emoji-repeat_one { background-position: -740px -340px; }
+.emoji-restroom { background-position: -740px -360px; }
+.emoji-revolving_hearts { background-position: -740px -380px; }
+.emoji-rewind { background-position: -740px -400px; }
+.emoji-rhino { background-position: -740px -420px; }
+.emoji-ribbon { background-position: -740px -440px; }
+.emoji-rice { background-position: -740px -460px; }
+.emoji-rice_ball { background-position: -740px -480px; }
+.emoji-rice_cracker { background-position: -740px -500px; }
+.emoji-rice_scene { background-position: -740px -520px; }
+.emoji-right_facing_fist { background-position: -740px -540px; }
+.emoji-right_facing_fist_tone1 { background-position: -740px -560px; }
+.emoji-right_facing_fist_tone2 { background-position: -740px -580px; }
+.emoji-right_facing_fist_tone3 { background-position: -740px -600px; }
+.emoji-right_facing_fist_tone4 { background-position: -740px -620px; }
+.emoji-right_facing_fist_tone5 { background-position: -740px -640px; }
+.emoji-ring { background-position: -740px -660px; }
+.emoji-robot { background-position: -740px -680px; }
+.emoji-rocket { background-position: -740px -700px; }
+.emoji-rofl { background-position: -740px -720px; }
+.emoji-roller_coaster { background-position: 0 -740px; }
+.emoji-rolling_eyes { background-position: -20px -740px; }
+.emoji-rooster { background-position: -40px -740px; }
+.emoji-rose { background-position: -60px -740px; }
+.emoji-rosette { background-position: -80px -740px; }
+.emoji-rotating_light { background-position: -100px -740px; }
+.emoji-round_pushpin { background-position: -120px -740px; }
+.emoji-rowboat { background-position: -140px -740px; }
+.emoji-rowboat_tone1 { background-position: -160px -740px; }
+.emoji-rowboat_tone2 { background-position: -180px -740px; }
+.emoji-rowboat_tone3 { background-position: -200px -740px; }
+.emoji-rowboat_tone4 { background-position: -220px -740px; }
+.emoji-rowboat_tone5 { background-position: -240px -740px; }
+.emoji-rugby_football { background-position: -260px -740px; }
+.emoji-runner { background-position: -280px -740px; }
+.emoji-runner_tone1 { background-position: -300px -740px; }
+.emoji-runner_tone2 { background-position: -320px -740px; }
+.emoji-runner_tone3 { background-position: -340px -740px; }
+.emoji-runner_tone4 { background-position: -360px -740px; }
+.emoji-runner_tone5 { background-position: -380px -740px; }
+.emoji-running_shirt_with_sash { background-position: -400px -740px; }
+.emoji-sa { background-position: -420px -740px; }
+.emoji-sagittarius { background-position: -440px -740px; }
+.emoji-sailboat { background-position: -460px -740px; }
+.emoji-sake { background-position: -480px -740px; }
+.emoji-salad { background-position: -500px -740px; }
+.emoji-sandal { background-position: -520px -740px; }
+.emoji-santa { background-position: -540px -740px; }
+.emoji-santa_tone1 { background-position: -560px -740px; }
+.emoji-santa_tone2 { background-position: -580px -740px; }
+.emoji-santa_tone3 { background-position: -600px -740px; }
+.emoji-santa_tone4 { background-position: -620px -740px; }
+.emoji-santa_tone5 { background-position: -640px -740px; }
+.emoji-satellite { background-position: -660px -740px; }
+.emoji-satellite_orbital { background-position: -680px -740px; }
+.emoji-saxophone { background-position: -700px -740px; }
+.emoji-scales { background-position: -720px -740px; }
+.emoji-school { background-position: -740px -740px; }
+.emoji-school_satchel { background-position: -760px 0; }
+.emoji-scissors { background-position: -760px -20px; }
+.emoji-scooter { background-position: -760px -40px; }
+.emoji-scorpion { background-position: -760px -60px; }
+.emoji-scorpius { background-position: -760px -80px; }
+.emoji-scream { background-position: -760px -100px; }
+.emoji-scream_cat { background-position: -760px -120px; }
+.emoji-scroll { background-position: -760px -140px; }
+.emoji-seat { background-position: -760px -160px; }
+.emoji-second_place { background-position: -760px -180px; }
+.emoji-secret { background-position: -760px -200px; }
+.emoji-see_no_evil { background-position: -760px -220px; }
+.emoji-seedling { background-position: -760px -240px; }
+.emoji-selfie { background-position: -760px -260px; }
+.emoji-selfie_tone1 { background-position: -760px -280px; }
+.emoji-selfie_tone2 { background-position: -760px -300px; }
+.emoji-selfie_tone3 { background-position: -760px -320px; }
+.emoji-selfie_tone4 { background-position: -760px -340px; }
+.emoji-selfie_tone5 { background-position: -760px -360px; }
+.emoji-seven { background-position: -760px -380px; }
+.emoji-shallow_pan_of_food { background-position: -760px -400px; }
+.emoji-shamrock { background-position: -760px -420px; }
+.emoji-shark { background-position: -760px -440px; }
+.emoji-shaved_ice { background-position: -760px -460px; }
+.emoji-sheep { background-position: -760px -480px; }
+.emoji-shell { background-position: -760px -500px; }
+.emoji-shield { background-position: -760px -520px; }
+.emoji-shinto_shrine { background-position: -760px -540px; }
+.emoji-ship { background-position: -760px -560px; }
+.emoji-shirt { background-position: -760px -580px; }
+.emoji-shopping_bags { background-position: -760px -600px; }
+.emoji-shopping_cart { background-position: -760px -620px; }
+.emoji-shower { background-position: -760px -640px; }
+.emoji-shrimp { background-position: -760px -660px; }
+.emoji-shrug { background-position: -760px -680px; }
+.emoji-shrug_tone1 { background-position: -760px -700px; }
+.emoji-shrug_tone2 { background-position: -760px -720px; }
+.emoji-shrug_tone3 { background-position: -760px -740px; }
+.emoji-shrug_tone4 { background-position: 0 -760px; }
+.emoji-shrug_tone5 { background-position: -20px -760px; }
+.emoji-signal_strength { background-position: -40px -760px; }
+.emoji-six { background-position: -60px -760px; }
+.emoji-six_pointed_star { background-position: -80px -760px; }
+.emoji-ski { background-position: -100px -760px; }
+.emoji-skier { background-position: -120px -760px; }
+.emoji-skull { background-position: -140px -760px; }
+.emoji-skull_crossbones { background-position: -160px -760px; }
+.emoji-sleeping { background-position: -180px -760px; }
+.emoji-sleeping_accommodation { background-position: -200px -760px; }
+.emoji-sleepy { background-position: -220px -760px; }
+.emoji-slight_frown { background-position: -240px -760px; }
+.emoji-slight_smile { background-position: -260px -760px; }
+.emoji-slot_machine { background-position: -280px -760px; }
+.emoji-small_blue_diamond { background-position: -300px -760px; }
+.emoji-small_orange_diamond { background-position: -320px -760px; }
+.emoji-small_red_triangle { background-position: -340px -760px; }
+.emoji-small_red_triangle_down { background-position: -360px -760px; }
+.emoji-smile { background-position: -380px -760px; }
+.emoji-smile_cat { background-position: -400px -760px; }
+.emoji-smiley { background-position: -420px -760px; }
+.emoji-smiley_cat { background-position: -440px -760px; }
+.emoji-smiling_imp { background-position: -460px -760px; }
+.emoji-smirk { background-position: -480px -760px; }
+.emoji-smirk_cat { background-position: -500px -760px; }
+.emoji-smoking { background-position: -520px -760px; }
+.emoji-snail { background-position: -540px -760px; }
+.emoji-snake { background-position: -560px -760px; }
+.emoji-sneezing_face { background-position: -580px -760px; }
+.emoji-snowboarder { background-position: -600px -760px; }
+.emoji-snowflake { background-position: -620px -760px; }
+.emoji-snowman { background-position: -640px -760px; }
+.emoji-snowman2 { background-position: -660px -760px; }
+.emoji-sob { background-position: -680px -760px; }
+.emoji-soccer { background-position: -700px -760px; }
+.emoji-soon { background-position: -720px -760px; }
+.emoji-sos { background-position: -740px -760px; }
+.emoji-sound { background-position: -760px -760px; }
+.emoji-space_invader { background-position: -780px 0; }
+.emoji-spades { background-position: -780px -20px; }
+.emoji-spaghetti { background-position: -780px -40px; }
+.emoji-sparkle { background-position: -780px -60px; }
+.emoji-sparkler { background-position: -780px -80px; }
+.emoji-sparkles { background-position: -780px -100px; }
+.emoji-sparkling_heart { background-position: -780px -120px; }
+.emoji-speak_no_evil { background-position: -780px -140px; }
+.emoji-speaker { background-position: -780px -160px; }
+.emoji-speaking_head { background-position: -780px -180px; }
+.emoji-speech_balloon { background-position: -780px -200px; }
+.emoji-speech_left { background-position: -780px -220px; }
+.emoji-speedboat { background-position: -780px -240px; }
+.emoji-spider { background-position: -780px -260px; }
+.emoji-spider_web { background-position: -780px -280px; }
+.emoji-spoon { background-position: -780px -300px; }
+.emoji-spy { background-position: -780px -320px; }
+.emoji-spy_tone1 { background-position: -780px -340px; }
+.emoji-spy_tone2 { background-position: -780px -360px; }
+.emoji-spy_tone3 { background-position: -780px -380px; }
+.emoji-spy_tone4 { background-position: -780px -400px; }
+.emoji-spy_tone5 { background-position: -780px -420px; }
+.emoji-squid { background-position: -780px -440px; }
+.emoji-stadium { background-position: -780px -460px; }
+.emoji-star { background-position: -780px -480px; }
+.emoji-star2 { background-position: -780px -500px; }
+.emoji-star_and_crescent { background-position: -780px -520px; }
+.emoji-star_of_david { background-position: -780px -540px; }
+.emoji-stars { background-position: -780px -560px; }
+.emoji-station { background-position: -780px -580px; }
+.emoji-statue_of_liberty { background-position: -780px -600px; }
+.emoji-steam_locomotive { background-position: -780px -620px; }
+.emoji-stew { background-position: -780px -640px; }
+.emoji-stop_button { background-position: -780px -660px; }
+.emoji-stopwatch { background-position: -780px -680px; }
+.emoji-straight_ruler { background-position: -780px -700px; }
+.emoji-strawberry { background-position: -780px -720px; }
+.emoji-stuck_out_tongue { background-position: -780px -740px; }
+.emoji-stuck_out_tongue_closed_eyes { background-position: -780px -760px; }
+.emoji-stuck_out_tongue_winking_eye { background-position: 0 -780px; }
+.emoji-stuffed_flatbread { background-position: -20px -780px; }
+.emoji-sun_with_face { background-position: -40px -780px; }
+.emoji-sunflower { background-position: -60px -780px; }
+.emoji-sunglasses { background-position: -80px -780px; }
+.emoji-sunny { background-position: -100px -780px; }
+.emoji-sunrise { background-position: -120px -780px; }
+.emoji-sunrise_over_mountains { background-position: -140px -780px; }
+.emoji-surfer { background-position: -160px -780px; }
+.emoji-surfer_tone1 { background-position: -180px -780px; }
+.emoji-surfer_tone2 { background-position: -200px -780px; }
+.emoji-surfer_tone3 { background-position: -220px -780px; }
+.emoji-surfer_tone4 { background-position: -240px -780px; }
+.emoji-surfer_tone5 { background-position: -260px -780px; }
+.emoji-sushi { background-position: -280px -780px; }
+.emoji-suspension_railway { background-position: -300px -780px; }
+.emoji-sweat { background-position: -320px -780px; }
+.emoji-sweat_drops { background-position: -340px -780px; }
+.emoji-sweat_smile { background-position: -360px -780px; }
+.emoji-sweet_potato { background-position: -380px -780px; }
+.emoji-swimmer { background-position: -400px -780px; }
+.emoji-swimmer_tone1 { background-position: -420px -780px; }
+.emoji-swimmer_tone2 { background-position: -440px -780px; }
+.emoji-swimmer_tone3 { background-position: -460px -780px; }
+.emoji-swimmer_tone4 { background-position: -480px -780px; }
+.emoji-swimmer_tone5 { background-position: -500px -780px; }
+.emoji-symbols { background-position: -520px -780px; }
+.emoji-synagogue { background-position: -540px -780px; }
+.emoji-syringe { background-position: -560px -780px; }
+.emoji-taco { background-position: -580px -780px; }
+.emoji-tada { background-position: -600px -780px; }
+.emoji-tanabata_tree { background-position: -620px -780px; }
+.emoji-tangerine { background-position: -640px -780px; }
+.emoji-taurus { background-position: -660px -780px; }
+.emoji-taxi { background-position: -680px -780px; }
+.emoji-tea { background-position: -700px -780px; }
+.emoji-telephone { background-position: -720px -780px; }
+.emoji-telephone_receiver { background-position: -740px -780px; }
+.emoji-telescope { background-position: -760px -780px; }
+.emoji-ten { background-position: -780px -780px; }
+.emoji-tennis { background-position: -800px 0; }
+.emoji-tent { background-position: -800px -20px; }
+.emoji-thermometer { background-position: -800px -40px; }
+.emoji-thermometer_face { background-position: -800px -60px; }
+.emoji-thinking { background-position: -800px -80px; }
+.emoji-third_place { background-position: -800px -100px; }
+.emoji-thought_balloon { background-position: -800px -120px; }
+.emoji-three { background-position: -800px -140px; }
+.emoji-thumbsdown { background-position: -800px -160px; }
+.emoji-thumbsdown_tone1 { background-position: -800px -180px; }
+.emoji-thumbsdown_tone2 { background-position: -800px -200px; }
+.emoji-thumbsdown_tone3 { background-position: -800px -220px; }
+.emoji-thumbsdown_tone4 { background-position: -800px -240px; }
+.emoji-thumbsdown_tone5 { background-position: -800px -260px; }
+.emoji-thumbsup { background-position: -800px -280px; }
+.emoji-thumbsup_tone1 { background-position: -800px -300px; }
+.emoji-thumbsup_tone2 { background-position: -800px -320px; }
+.emoji-thumbsup_tone3 { background-position: -800px -340px; }
+.emoji-thumbsup_tone4 { background-position: -800px -360px; }
+.emoji-thumbsup_tone5 { background-position: -800px -380px; }
+.emoji-thunder_cloud_rain { background-position: -800px -400px; }
+.emoji-ticket { background-position: -800px -420px; }
+.emoji-tickets { background-position: -800px -440px; }
+.emoji-tiger { background-position: -800px -460px; }
+.emoji-tiger2 { background-position: -800px -480px; }
+.emoji-timer { background-position: -800px -500px; }
+.emoji-tired_face { background-position: -800px -520px; }
+.emoji-tm { background-position: -800px -540px; }
+.emoji-toilet { background-position: -800px -560px; }
+.emoji-tokyo_tower { background-position: -800px -580px; }
+.emoji-tomato { background-position: -800px -600px; }
+.emoji-tone1 { background-position: -800px -620px; }
+.emoji-tone2 { background-position: -800px -640px; }
+.emoji-tone3 { background-position: -800px -660px; }
+.emoji-tone4 { background-position: -800px -680px; }
+.emoji-tone5 { background-position: -800px -700px; }
+.emoji-tongue { background-position: -800px -720px; }
+.emoji-tools { background-position: -800px -740px; }
+.emoji-top { background-position: -800px -760px; }
+.emoji-tophat { background-position: -800px -780px; }
+.emoji-track_next { background-position: 0 -800px; }
+.emoji-track_previous { background-position: -20px -800px; }
+.emoji-trackball { background-position: -40px -800px; }
+.emoji-tractor { background-position: -60px -800px; }
+.emoji-traffic_light { background-position: -80px -800px; }
+.emoji-train { background-position: -100px -800px; }
+.emoji-train2 { background-position: -120px -800px; }
+.emoji-tram { background-position: -140px -800px; }
+.emoji-triangular_flag_on_post { background-position: -160px -800px; }
+.emoji-triangular_ruler { background-position: -180px -800px; }
+.emoji-trident { background-position: -200px -800px; }
+.emoji-triumph { background-position: -220px -800px; }
+.emoji-trolleybus { background-position: -240px -800px; }
+.emoji-trophy { background-position: -260px -800px; }
+.emoji-tropical_drink { background-position: -280px -800px; }
+.emoji-tropical_fish { background-position: -300px -800px; }
+.emoji-truck { background-position: -320px -800px; }
+.emoji-trumpet { background-position: -340px -800px; }
+.emoji-tulip { background-position: -360px -800px; }
+.emoji-tumbler_glass { background-position: -380px -800px; }
+.emoji-turkey { background-position: -400px -800px; }
+.emoji-turtle { background-position: -420px -800px; }
+.emoji-tv { background-position: -440px -800px; }
+.emoji-twisted_rightwards_arrows { background-position: -460px -800px; }
+.emoji-two { background-position: -480px -800px; }
+.emoji-two_hearts { background-position: -500px -800px; }
+.emoji-two_men_holding_hands { background-position: -520px -800px; }
+.emoji-two_women_holding_hands { background-position: -540px -800px; }
+.emoji-u5272 { background-position: -560px -800px; }
+.emoji-u5408 { background-position: -580px -800px; }
+.emoji-u55b6 { background-position: -600px -800px; }
+.emoji-u6307 { background-position: -620px -800px; }
+.emoji-u6708 { background-position: -640px -800px; }
+.emoji-u6709 { background-position: -660px -800px; }
+.emoji-u6e80 { background-position: -680px -800px; }
+.emoji-u7121 { background-position: -700px -800px; }
+.emoji-u7533 { background-position: -720px -800px; }
+.emoji-u7981 { background-position: -740px -800px; }
+.emoji-u7a7a { background-position: -760px -800px; }
+.emoji-umbrella { background-position: -780px -800px; }
+.emoji-umbrella2 { background-position: -800px -800px; }
+.emoji-unamused { background-position: -820px 0; }
+.emoji-underage { background-position: -820px -20px; }
+.emoji-unicorn { background-position: -820px -40px; }
+.emoji-unlock { background-position: -820px -60px; }
+.emoji-up { background-position: -820px -80px; }
+.emoji-upside_down { background-position: -820px -100px; }
+.emoji-urn { background-position: -820px -120px; }
+.emoji-v { background-position: -820px -140px; }
+.emoji-v_tone1 { background-position: -820px -160px; }
+.emoji-v_tone2 { background-position: -820px -180px; }
+.emoji-v_tone3 { background-position: -820px -200px; }
+.emoji-v_tone4 { background-position: -820px -220px; }
+.emoji-v_tone5 { background-position: -820px -240px; }
+.emoji-vertical_traffic_light { background-position: -820px -260px; }
+.emoji-vhs { background-position: -820px -280px; }
+.emoji-vibration_mode { background-position: -820px -300px; }
+.emoji-video_camera { background-position: -820px -320px; }
+.emoji-video_game { background-position: -820px -340px; }
+.emoji-violin { background-position: -820px -360px; }
+.emoji-virgo { background-position: -820px -380px; }
+.emoji-volcano { background-position: -820px -400px; }
+.emoji-volleyball { background-position: -820px -420px; }
+.emoji-vs { background-position: -820px -440px; }
+.emoji-vulcan { background-position: -820px -460px; }
+.emoji-vulcan_tone1 { background-position: -820px -480px; }
+.emoji-vulcan_tone2 { background-position: -820px -500px; }
+.emoji-vulcan_tone3 { background-position: -820px -520px; }
+.emoji-vulcan_tone4 { background-position: -820px -540px; }
+.emoji-vulcan_tone5 { background-position: -820px -560px; }
+.emoji-walking { background-position: -820px -580px; }
+.emoji-walking_tone1 { background-position: -820px -600px; }
+.emoji-walking_tone2 { background-position: -820px -620px; }
+.emoji-walking_tone3 { background-position: -820px -640px; }
+.emoji-walking_tone4 { background-position: -820px -660px; }
+.emoji-walking_tone5 { background-position: -820px -680px; }
+.emoji-waning_crescent_moon { background-position: -820px -700px; }
+.emoji-waning_gibbous_moon { background-position: -820px -720px; }
+.emoji-warning { background-position: -820px -740px; }
+.emoji-wastebasket { background-position: -820px -760px; }
+.emoji-watch { background-position: -820px -780px; }
+.emoji-water_buffalo { background-position: -820px -800px; }
+.emoji-water_polo { background-position: 0 -820px; }
+.emoji-water_polo_tone1 { background-position: -20px -820px; }
+.emoji-water_polo_tone2 { background-position: -40px -820px; }
+.emoji-water_polo_tone3 { background-position: -60px -820px; }
+.emoji-water_polo_tone4 { background-position: -80px -820px; }
+.emoji-water_polo_tone5 { background-position: -100px -820px; }
+.emoji-watermelon { background-position: -120px -820px; }
+.emoji-wave { background-position: -140px -820px; }
+.emoji-wave_tone1 { background-position: -160px -820px; }
+.emoji-wave_tone2 { background-position: -180px -820px; }
+.emoji-wave_tone3 { background-position: -200px -820px; }
+.emoji-wave_tone4 { background-position: -220px -820px; }
+.emoji-wave_tone5 { background-position: -240px -820px; }
+.emoji-wavy_dash { background-position: -260px -820px; }
+.emoji-waxing_crescent_moon { background-position: -280px -820px; }
+.emoji-waxing_gibbous_moon { background-position: -300px -820px; }
+.emoji-wc { background-position: -320px -820px; }
+.emoji-weary { background-position: -340px -820px; }
+.emoji-wedding { background-position: -360px -820px; }
+.emoji-whale { background-position: -380px -820px; }
+.emoji-whale2 { background-position: -400px -820px; }
+.emoji-wheel_of_dharma { background-position: -420px -820px; }
+.emoji-wheelchair { background-position: -440px -820px; }
+.emoji-white_check_mark { background-position: -460px -820px; }
+.emoji-white_circle { background-position: -480px -820px; }
+.emoji-white_flower { background-position: -500px -820px; }
+.emoji-white_large_square { background-position: -520px -820px; }
+.emoji-white_medium_small_square { background-position: -540px -820px; }
+.emoji-white_medium_square { background-position: -560px -820px; }
+.emoji-white_small_square { background-position: -580px -820px; }
+.emoji-white_square_button { background-position: -600px -820px; }
+.emoji-white_sun_cloud { background-position: -620px -820px; }
+.emoji-white_sun_rain_cloud { background-position: -640px -820px; }
+.emoji-white_sun_small_cloud { background-position: -660px -820px; }
+.emoji-wilted_rose { background-position: -680px -820px; }
+.emoji-wind_blowing_face { background-position: -700px -820px; }
+.emoji-wind_chime { background-position: -720px -820px; }
+.emoji-wine_glass { background-position: -740px -820px; }
+.emoji-wink { background-position: -760px -820px; }
+.emoji-wolf { background-position: -780px -820px; }
+.emoji-woman { background-position: -800px -820px; }
+.emoji-woman_tone1 { background-position: -820px -820px; }
+.emoji-woman_tone2 { background-position: -840px 0; }
+.emoji-woman_tone3 { background-position: -840px -20px; }
+.emoji-woman_tone4 { background-position: -840px -40px; }
+.emoji-woman_tone5 { background-position: -840px -60px; }
+.emoji-womans_clothes { background-position: -840px -80px; }
+.emoji-womans_hat { background-position: -840px -100px; }
+.emoji-womens { background-position: -840px -120px; }
+.emoji-worried { background-position: -840px -140px; }
+.emoji-wrench { background-position: -840px -160px; }
+.emoji-wrestlers { background-position: -840px -180px; }
+.emoji-wrestlers_tone1 { background-position: -840px -200px; }
+.emoji-wrestlers_tone2 { background-position: -840px -220px; }
+.emoji-wrestlers_tone3 { background-position: -840px -240px; }
+.emoji-wrestlers_tone4 { background-position: -840px -260px; }
+.emoji-wrestlers_tone5 { background-position: -840px -280px; }
+.emoji-writing_hand { background-position: -840px -300px; }
+.emoji-writing_hand_tone1 { background-position: -840px -320px; }
+.emoji-writing_hand_tone2 { background-position: -840px -340px; }
+.emoji-writing_hand_tone3 { background-position: -840px -360px; }
+.emoji-writing_hand_tone4 { background-position: -840px -380px; }
+.emoji-writing_hand_tone5 { background-position: -840px -400px; }
+.emoji-x { background-position: -840px -420px; }
+.emoji-yellow_heart { background-position: -840px -440px; }
+.emoji-yen { background-position: -840px -460px; }
+.emoji-yin_yang { background-position: -840px -480px; }
+.emoji-yum { background-position: -840px -500px; }
+.emoji-zap { background-position: -840px -520px; }
+.emoji-zero { background-position: -840px -540px; }
+.emoji-zipper_mouth { background-position: -840px -560px; }
+.emoji-100 { background-position: -840px -580px; }
+
+.emoji-icon {
+ background-image: image-url('emoji.png');
+ background-repeat: no-repeat;
+ color: transparent;
+ text-indent: -99em;
+ height: 20px;
+ width: 20px;
+
+ @media only screen and (-webkit-min-device-pixel-ratio: 2),
+ only screen and (min--moz-device-pixel-ratio: 2),
+ only screen and (-o-min-device-pixel-ratio: 2/1),
+ only screen and (min-device-pixel-ratio: 2),
+ only screen and (min-resolution: 192dpi),
+ only screen and (min-resolution: 2dppx) {
+ background-image: image-url('emoji@2x.png');
+ background-size: 860px 840px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index 2d6bc17d4ff..527e7d57c5c 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -1,4 +1,5 @@
gl-emoji {
+ font-style: normal;
display: inline-flex;
vertical-align: middle;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss
new file mode 100644
index 00000000000..4f26cd015e4
--- /dev/null
+++ b/app/assets/stylesheets/framework/feature_highlight.scss
@@ -0,0 +1,103 @@
+.feature-highlight {
+ position: relative;
+ margin-left: $gl-padding;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: 8px;
+ height: 8px;
+ background-color: $blue-500;
+ border-radius: 50%;
+ box-shadow: 0 0 0 rgba($blue-500, 0.4);
+ animation: pulse-highlight 2s infinite;
+ }
+
+ &:hover::before,
+ &.disable-animation::before {
+ animation: none;
+ }
+
+ &[disabled]::before {
+ display: none;
+ }
+}
+
+.is-showing-fly-out {
+ .feature-highlight {
+ display: none;
+ }
+}
+
+.feature-highlight-popover-content {
+ display: none;
+
+ hr {
+ margin: $gl-padding * 0.5 0;
+ }
+
+ .btn-link {
+ svg {
+ @include btn-svg;
+
+ path {
+ fill: currentColor;
+ }
+ }
+ }
+
+ .feature-highlight-illustration {
+ width: 100%;
+ height: 100px;
+ padding-top: 12px;
+ padding-bottom: 12px;
+
+ background-color: $indigo-50;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ border-bottom: 1px solid darken($gray-normal, 8%);
+ }
+}
+
+.popover .feature-highlight-popover-content {
+ display: block;
+}
+
+.feature-highlight-popover {
+ width: 240px;
+ padding: 0;
+ border: 1px solid $dropdown-border-color;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &.right > .arrow {
+ border-right-color: $dropdown-border-color;
+ }
+
+ .popover-content {
+ padding: 0;
+ }
+}
+
+.feature-highlight-popover-sub-content {
+ padding: 9px 14px;
+}
+
+@include keyframes(pulse-highlight) {
+ 0% {
+ box-shadow: 0 0 0 0 rgba($blue-200, 0.4);
+ }
+
+ 70% {
+ box-shadow: 0 0 0 10px transparent;
+ }
+
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 245117b2559..d835d49d8b2 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -10,11 +10,15 @@
border: 0;
}
+ &.file-holder-bottom-radius {
+ border-radius: 0 0 $border-radius-small $border-radius-small;
+ }
+
&.readme-holder {
margin: $gl-padding 0;
&.limited-width-container .file-content {
- max-width: $limited-layout-width-sm;
+ max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
}
@@ -92,13 +96,13 @@
@for $i from 0 through 5 {
.legend-box-#{$i} {
- background-color: mix($blame-cyan, $blame-blue, $i / 5.0 * 100%);
+ background-color: mix($blame-cyan, $blame-blue, $i / 5 * 100%);
}
}
@for $i from 1 through 4 {
.legend-box-#{$i + 5} {
- background-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%);
+ background-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%);
}
}
}
@@ -119,7 +123,11 @@
}
&.wiki {
- padding: 30px $gl-padding;
+ padding: $gl-padding;
+
+ @media (min-width: $screen-md-min) {
+ padding: $gl-padding * 2;
+ }
}
&.blob-no-preview {
@@ -133,7 +141,7 @@
*/
&.blame {
table {
- border: none;
+ border: 0;
margin: 0;
}
@@ -141,55 +149,65 @@
border-bottom: 1px solid $blame-border;
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
}
}
td {
- border-top: none;
- border-bottom: none;
+ border-top: 0;
+ border-bottom: 0;
&:first-child {
- border-left: none;
+ border-left: 0;
}
&:last-child {
- border-right: none;
+ border-right: 0;
}
- }
- td.blame-commit {
- padding: 5px 10px;
- min-width: 400px;
- background: $gray-light;
- border-left: 3px solid;
+ &.blame-commit {
+ padding: 5px 10px;
+ min-width: 400px;
+ max-width: 400px;
+ background: $gray-light;
+ border-left: 3px solid;
+
+ .commit-row-title {
+ display: flex;
+ }
+
+ .item-title {
+ flex: 1;
+ margin-right: 0.5em;
+ }
+ }
+
+ &.line-numbers {
+ float: none;
+ border-left: 1px solid $blame-line-numbers-border;
+
+ i {
+ float: none;
+ margin-right: 0;
+ }
+ }
+
+ &.lines {
+ padding: 0;
+ }
}
@for $i from 0 through 5 {
td.blame-commit-age-#{$i} {
- border-left-color: mix($blame-cyan, $blame-blue, $i / 5.0 * 100%);
+ border-left-color: mix($blame-cyan, $blame-blue, $i / 5 * 100%);
}
}
@for $i from 1 through 4 {
td.blame-commit-age-#{$i + 5} {
- border-left-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%);
+ border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%);
}
}
-
- td.line-numbers {
- float: none;
- border-left: 1px solid $blame-line-numbers-border;
-
- i {
- float: none;
- margin-right: 0;
- }
- }
-
- td.lines {
- padding: 0;
- }
}
&.logs {
@@ -345,6 +363,18 @@ span.idiff {
float: none;
}
}
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+
+ .file-actions {
+ white-space: normal;
+
+ .btn-group {
+ padding-top: 5px;
+ }
+ }
+ }
}
.is-stl-loading {
@@ -365,3 +395,8 @@ span.idiff {
.file-fork-suggestion-note {
margin-right: 1.5em;
}
+
+.label-lfs {
+ color: $common-gray-light;
+ border: 1px solid $common-gray-light;
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 767cf5ffea5..621a4adc0cb 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -63,13 +63,20 @@
display: flex;
flex: 1;
-webkit-flex: 1;
- padding-left: 30px;
+ padding-left: 12px;
position: relative;
margin-bottom: 0;
}
.input-token {
max-width: 200px;
+ padding: 0;
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ color: inherit;
+ }
}
.input-token:only-child,
@@ -202,8 +209,16 @@
box-shadow: 0 0 4px $search-input-focus-shadow-color;
}
- &.focus .fa-filter {
- color: $common-gray-dark;
+ gl-emoji {
+ display: inline-block;
+ font-family: inherit;
+ font-size: inherit;
+ vertical-align: inherit;
+
+ img {
+ height: 18px;
+ width: 18px;
+ }
}
.form-control {
@@ -220,13 +235,6 @@
}
}
- .fa-filter {
- position: absolute;
- top: 10px;
- left: 10px;
- color: $gray-darkest;
- }
-
.fa-times {
right: 10px;
color: $gray-darkest;
@@ -235,7 +243,7 @@
.clear-search {
width: 35px;
background-color: $white-light;
- border: none;
+ border: 0;
outline: none;
z-index: 1;
@@ -248,17 +256,11 @@
.filtered-search-box-input-container {
flex: 1;
position: relative;
- // Fix PhantomJS not supporting `flex: 1;` properly.
- // This is important because it can change the expected `e.target` when clicking things in tests.
- // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
- // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
- // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
- width: 100%;
min-width: 0;
}
.filtered-search-input-dropdown-menu {
- max-height: 215px;
+ max-height: $dropdown-max-height;
max-width: 280px;
overflow: auto;
@@ -291,16 +293,11 @@
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;
}
@@ -327,21 +324,12 @@
.filtered-search-history-dropdown-content {
max-height: none;
-}
-.filtered-search-history-dropdown-item,
-.filtered-search-history-clear-button {
- @include dropdown-link;
-
- overflow: hidden;
- width: 100%;
- margin: 0.5em 0;
-
- background-color: transparent;
- border: 0;
- text-align: left;
- white-space: nowrap;
- text-overflow: ellipsis;
+ .filtered-search-history-dropdown-item,
+ .filtered-search-history-clear-button {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
}
.filtered-search-history-dropdown-token {
@@ -351,8 +339,8 @@
margin-right: 0.3em;
}
- & > .value {
- font-weight: 600;
+ > .value {
+ font-weight: $gl-font-weight-bold;
}
}
@@ -365,10 +353,6 @@
}
}
-.dropdown-menu .filter-dropdown-item {
- padding: 0;
-}
-
@media (max-width: $screen-xs-max) {
.issues-details-filters {
padding: 0 0 10px;
@@ -380,7 +364,8 @@
@media (max-width: $screen-xs) {
.filter-dropdown-container {
.dropdown-toggle,
- .dropdown {
+ .dropdown,
+ .dropdown-menu {
width: 100%;
}
@@ -396,28 +381,13 @@
}
}
-%filter-dropdown-item-btn-hover {
- background-color: $dropdown-hover-color;
- color: $white-light;
- text-decoration: none;
-
- .avatar {
- border-color: $white-light;
- }
-}
-
-.filter-dropdown-item {
+.droplab-dropdown .dropdown-menu .filter-dropdown-item {
.btn {
- border: none;
- width: 100%;
- text-align: left;
- padding: 8px 16px;
text-overflow: ellipsis;
- overflow: hidden;
- border-radius: 0;
.fa {
width: 15px;
+ line-height: $line-height-base;
}
.dropdown-label-box {
@@ -426,41 +396,30 @@
border-width: 1px;
width: 17px;
height: 17px;
- }
-
- &:hover,
- &:focus {
- @extend %filter-dropdown-item-btn-hover;
+ top: 0;
}
}
.dropdown-light-content {
font-size: 14px;
- font-weight: 400;
+ font-weight: $gl-font-weight-normal;
}
.dropdown-user {
- display: -webkit-flex;
display: flex;
}
.dropdown-user-details {
- display: -webkit-flex;
display: flex;
- -webkit-flex-direction: column;
flex-direction: column;
- &> span {
+ > span {
white-space: normal;
word-break: break-all;
}
}
}
-.filter-dropdown-item.droplab-item-active .btn {
- @extend %filter-dropdown-item-btn-hover;
-}
-
.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 38d884bc7eb..cb2f71b0033 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -12,6 +12,12 @@
margin: 0;
}
+ .flash-warning {
+ @extend .alert;
+ @extend .alert-warning;
+ margin: 0;
+ }
+
.flash-alert {
@extend .alert;
@extend .alert-danger;
@@ -25,7 +31,7 @@
a.flash-action {
margin-left: 5px;
text-decoration: none;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
border-bottom: 1px solid;
&:hover {
@@ -34,8 +40,15 @@
}
}
+ .flash-success {
+ @extend .alert;
+ @extend .alert-success;
+ margin: 0;
+ }
+
.flash-notice,
- .flash-alert {
+ .flash-alert,
+ .flash-success {
border-radius: $border-radius-default;
.container-fluid,
@@ -48,7 +61,8 @@
margin-bottom: 0;
.flash-notice,
- .flash-alert {
+ .flash-alert,
+ .flash-success {
border-radius: 0;
}
}
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 61e3897f369..2c30311b1c1 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -32,7 +32,7 @@ label {
}
&.label-light {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
@@ -73,7 +73,7 @@ label {
margin-right: 0;
.control-label {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
padding-top: 4px;
}
@@ -157,7 +157,7 @@ label {
.form-group .control-label,
.form-group .control-label-full-width {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.form-control::-webkit-input-placeholder {
@@ -167,7 +167,7 @@ label {
.input-group {
.select2-container {
display: table-cell;
- width: 200px !important;
+ max-width: 180px;
}
.input-group-addon {
@@ -182,6 +182,7 @@ label {
.help-block {
margin-bottom: 0;
+ margin-top: #{$grid-size / 2};
}
.gl-field-error {
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index dbdd5a4464b..e378e84ca1b 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -6,3 +6,41 @@
.gfm-commit_range {
@extend .commit-sha;
}
+
+.gfm-project_member {
+ padding: 0 2px;
+ border-radius: #{$border-radius-default / 2};
+ background-color: $user-mention-bg;
+
+ &:hover {
+ background-color: $user-mention-bg-hover;
+ }
+}
+
+.gfm-color_chip {
+ display: inline-block;
+ margin: 0 0 2px 4px;
+ vertical-align: middle;
+ border-radius: 3px;
+
+ $chip-size: 0.9em;
+ $bg-size: $chip-size / 0.9;
+ $bg-pos: $bg-size / 2;
+
+ width: $chip-size;
+ height: $chip-size;
+ background: $white-light;
+ background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%),
+ linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%);
+ background-size: $bg-size $bg-size;
+ background-position: 0 0, $bg-pos $bg-pos;
+
+ > span {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ margin-bottom: 2px;
+ border-radius: 3px;
+ border: 1px solid $black-transparent;
+ }
+}
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
new file mode 100644
index 00000000000..db36e27fa74
--- /dev/null
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -0,0 +1,274 @@
+/**
+ * Styles the GitLab application with a specific color theme
+ */
+
+@mixin gitlab-theme($color-100, $color-200, $color-500, $color-700, $color-800, $color-900, $color-alternate) {
+ // Header
+
+ .navbar-gitlab {
+ background-color: $color-900;
+
+ .navbar-collapse {
+ color: $color-200;
+ }
+
+ .container-fluid {
+ .navbar-toggle {
+ border-left: 1px solid lighten($color-700, 10%);
+ }
+ }
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ background-color: rgba($color-200, .2);
+ }
+
+ &.active > a,
+ &.dropdown.open > a {
+ color: $color-900;
+ background-color: $color-alternate;
+ }
+
+ &.line-separator {
+ border-left: 1px solid rgba($color-200, .2);
+ }
+ }
+ }
+
+ .navbar-sub-nav {
+ color: $color-200;
+ }
+
+ .nav {
+ > li {
+ color: $color-200;
+
+ > a {
+ &.header-user-dropdown-toggle {
+ .header-user-avatar {
+ border-color: $color-200;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ @media (min-width: $screen-sm-min) {
+ background-color: rgba($color-200, .2);
+ }
+
+ svg {
+ fill: currentColor;
+ }
+ }
+ }
+
+ &.active > a,
+ &.dropdown.open > a {
+ color: $color-900;
+ background-color: $color-alternate;
+
+ &:hover {
+ svg {
+ fill: $color-900;
+ }
+ }
+ }
+
+ .impersonated-user,
+ .impersonated-user:hover {
+ svg {
+ fill: $color-900;
+ }
+ }
+ }
+ }
+ }
+
+ .navbar .title {
+ > a {
+ &:hover,
+ &:focus {
+ background-color: rgba($color-200, .2);
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: rgba($color-200, .2);
+
+ &:hover {
+ background-color: rgba($color-200, .3);
+ }
+ }
+
+ .location-badge {
+ color: $color-100;
+ background-color: rgba($color-200, .1);
+ border-right: 1px solid $color-800;
+ }
+
+ .search-input::placeholder {
+ color: rgba($color-200, .8);
+ }
+
+ .search-input-wrap {
+ .search-icon,
+ .clear-icon {
+ fill: rgba($color-200, .8);
+ }
+ }
+
+ &.search-active {
+ form {
+ background-color: $white-light;
+ }
+
+ .location-badge {
+ color: $gl-text-color;
+ }
+
+ .search-input-wrap {
+ .search-icon {
+ fill: rgba($color-200, .8);
+ }
+ }
+ }
+ }
+
+ .btn-sign-in {
+ background-color: $color-100;
+ color: $color-900;
+ }
+
+
+ // Sidebar
+ .nav-sidebar li.active {
+ box-shadow: inset 4px 0 0 $color-700;
+
+ > a {
+ color: $color-800;
+ }
+
+ svg {
+ fill: $color-800;
+ }
+ }
+
+ .sidebar-top-level-items > li.active .badge {
+ color: $color-800;
+ }
+
+ .nav-links li.active a {
+ border-bottom-color: $color-500;
+
+ .badge {
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+}
+
+
+body {
+ &.ui_indigo {
+ @include gitlab-theme($indigo-100, $indigo-200, $indigo-500, $indigo-700, $indigo-800, $indigo-900, $white-light);
+ }
+
+ &.ui_dark {
+ @include gitlab-theme($theme-gray-100, $theme-gray-200, $theme-gray-500, $theme-gray-700, $theme-gray-800, $theme-gray-900, $white-light);
+ }
+
+ &.ui_blue {
+ @include gitlab-theme($theme-blue-100, $theme-blue-200, $theme-blue-500, $theme-blue-700, $theme-blue-800, $theme-blue-900, $white-light);
+ }
+
+ &.ui_green {
+ @include gitlab-theme($theme-green-100, $theme-green-200, $theme-green-500, $theme-green-700, $theme-green-800, $theme-green-900, $white-light);
+ }
+
+ &.ui_light {
+ @include gitlab-theme($theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-700, $theme-gray-700, $theme-gray-100, $theme-gray-700);
+
+ .navbar-gitlab {
+ background-color: $theme-gray-100;
+ box-shadow: 0 1px 0 0 $border-color;
+
+ .logo-text svg {
+ fill: $theme-gray-900;
+ }
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ color: $theme-gray-900;
+ }
+
+ &.active > a,
+ &.active > a:hover {
+ color: $white-light;
+ }
+ }
+ }
+
+ .container-fluid {
+ .navbar-toggle,
+ .navbar-toggle:hover {
+ color: $theme-gray-700;
+ border-left: 1px solid $theme-gray-200;
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: $white-light;
+ box-shadow: inset 0 0 0 1px $border-color;
+
+ &:hover {
+ background-color: $white-light;
+ box-shadow: inset 0 0 0 1px $blue-200;
+
+ .location-badge {
+ box-shadow: inset 0 0 0 1px $blue-200;
+ }
+ }
+ }
+
+ .search-input-wrap {
+ .search-icon {
+ fill: $theme-gray-200;
+ }
+
+ .search-input {
+ color: $gl-text-color;
+ }
+ }
+
+ .location-badge {
+ color: $theme-gray-700;
+ box-shadow: inset 0 0 0 1px $border-color;
+ background-color: $nav-badge-bg;
+ border-right: 0;
+ }
+ }
+
+ .nav-sidebar li.active {
+ > a {
+ color: $theme-gray-900;
+ }
+
+ svg {
+ fill: $theme-gray-900;
+ }
+ }
+
+ .sidebar-top-level-items > li.active .badge {
+ color: $theme-gray-900;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 5bd6c095109..0136af76a13 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,281 +1,312 @@
-/*
- * Application Header
- *
- */
-
-header {
- transition: padding $sidebar-transition-duration;
+.navbar-gitlab {
+ padding: 0 16px;
+ z-index: 1000;
+ margin-bottom: 0;
+ min-height: $header-height;
+ border: 0;
+ border-bottom: 1px solid $border-color;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ border-radius: 0;
+
+ .logo-text {
+ line-height: initial;
+
+ svg {
+ width: 55px;
+ height: 14px;
+ margin: 0;
+ fill: $white-light;
+ }
+ }
- &.navbar-empty {
- height: $header-height;
- background: $white-light;
- border-bottom: 1px solid $white-normal;
+ .close-icon {
+ display: none;
+ }
- .center-logo {
- margin: 8px 0;
- text-align: center;
+ .menu-expanded {
+ .more-icon {
+ display: none;
+ }
- .tanuki-logo,
- img {
- height: 36px;
- }
+ .close-icon {
+ display: block;
}
}
- &.navbar-gitlab {
- padding: 0 16px;
- z-index: 400;
- margin-bottom: 0;
+ .header-content {
+ display: -webkit-flex;
+ display: flex;
+ justify-content: space-between;
+ position: relative;
min-height: $header-height;
- background-color: $gray-light;
- border: none;
- border-bottom: 1px solid $border-color;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- color: $gl-text-color-secondary;
- border-radius: 0;
-
- @media (max-width: $screen-xs-min) {
- padding: 0 16px;
- }
+ padding-left: 0;
- &.with-horizontal-nav {
- border-bottom: 0;
-
- .navbar-border {
- height: 1px;
- position: absolute;
- right: 0;
- left: 0;
- bottom: -1px;
- background-color: $border-color;
- opacity: 0;
- }
+ .title-container {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-align-items: stretch;
+ align-items: stretch;
+ -webkit-flex: 1 1 auto;
+ flex: 1 1 auto;
+ padding-top: 0;
+ overflow: visible;
}
- .container-fluid {
- width: 100% !important;
- filter: none;
- padding: 0;
+ .title {
+ padding-right: 0;
+ color: currentColor;
+ display: -webkit-flex;
+ display: flex;
+ position: relative;
+ margin: 0;
+ font-size: 18px;
+ vertical-align: top;
+ white-space: nowrap;
- .nav > li > a {
- color: currentColor;
- font-size: 18px;
- padding: 0;
- margin: (($header-height - 28) / 2) 3px;
- margin-left: 8px;
+ img {
height: 28px;
- min-width: 32px;
- line-height: 28px;
- text-align: center;
- &.header-user-dropdown-toggle {
- margin-left: 14px;
-
- &:hover,
- &:focus,
- &:active {
- .header-user-avatar {
- border-color: rgba($avatar-border, .2);
- }
- }
+ + .logo-text {
+ margin-left: 8px;
}
+ }
- &:hover,
- &:focus,
- &:active {
- background-color: transparent;
- color: $gl-text-color;
-
- svg {
- fill: $gl-text-color;
- }
- }
+ &.wrap {
+ white-space: normal;
+ }
- .fa-caret-down {
- font-size: 14px;
- }
+ &.initializing {
+ opacity: 0;
+ }
- .fa-chevron-down {
- position: relative;
- top: -3px;
- font-size: 10px;
+ a {
+ display: -webkit-flex;
+ display: flex;
+ align-items: center;
+ padding: 2px 8px;
+ margin: 5px 2px 5px -8px;
+ border-radius: $border-radius-default;
+
+ .tanuki-logo {
+ @media (min-width: $screen-sm-min) {
+ margin-right: 8px;
+ }
}
+ }
- svg {
- position: relative;
- top: 2px;
- height: 17px;
- // hack to get SVG to line up with FA icons
- width: 23px;
- fill: currentColor;
- }
+ .project-item-select {
+ right: auto;
+ left: 0;
}
+ }
- .navbar-toggle {
- color: $nav-toggle-gray;
- margin: 5px 0;
- border-radius: 0;
- right: -10px;
- padding: 6px 10px;
+ .dropdown.open {
+ > a {
+ border-bottom-color: $white-light;
+ }
+ }
- &:hover {
- background-color: $white-normal;
+ &.menu-expanded {
+ @media (max-width: $screen-xs-max) {
+ .title-container {
+ display: none;
}
- &.active {
- color: $gl-text-color-secondary;
+ .navbar-collapse {
+ display: block;
}
}
}
}
- .global-dropdown {
- position: absolute;
- left: -10px;
-
- .badge {
- font-size: 11px;
- }
-
- li {
- &.active a {
- font-weight: bold;
- }
- }
+ li.dropdown-bold-header {
+ color: $gl-text-color-secondary;
+ font-size: 12px;
+ padding: 0 16px;
}
- .global-dropdown-toggle {
- margin: 7px 0;
- font-size: 18px;
- padding: 6px 10px;
- border: none;
- background-color: $gray-light;
+ .navbar-collapse {
+ flex: 0 0 auto;
+ border-top: 0;
+ padding: 0;
- &:hover {
- background-color: $white-normal;
+ @media (max-width: $screen-xs-max) {
+ flex: 1 1 auto;
}
- &:focus {
- outline: none;
- background-color: $white-normal;
+ .nav {
+ > li:not(.hidden-xs) a {
+ @media (max-width: $screen-xs-max) {
+ margin-left: 0;
+ min-width: 100%;
+ }
+ }
}
}
- .header-content {
- display: flex;
- justify-content: space-between;
- position: relative;
- min-height: $header-height;
- padding-left: 30px;
+ .container-fluid {
+ padding: 0;
- .dropdown-menu {
- margin-top: -5px;
+ .user-counter {
+ svg {
+ margin-right: 3px;
+ }
}
- .header-logo {
- display: inline-block;
- margin: 0 12px 0 2px;
- position: relative;
- top: 10px;
- transition-duration: .3s;
+ .navbar-toggle {
+ right: -10px;
+ border-radius: 0;
+ min-width: 45px;
+ padding: 0;
+ margin-right: -7px;
+ font-size: 14px;
+ text-align: center;
+ color: currentColor;
- svg,
- img {
- height: 28px;
+ &:hover,
+ &:focus,
+ &.active {
+ color: currentColor;
+ background-color: transparent;
}
- &:hover {
- cursor: pointer;
+ .more-icon,
+ .close-icon {
+ fill: $white-light;
+ margin: auto;
}
}
- .group-name-toggle {
- margin: 3px 5px;
- }
+ .navbar-nav {
+ @media (max-width: $screen-xs-max) {
+ display: -webkit-flex;
+ display: flex;
+ padding-right: 10px;
+ }
- .group-title {
- &.is-hidden {
- .hidable:not(:last-of-type) {
- display: none;
+ li {
+ .badge {
+ box-shadow: none;
+ font-weight: $gl-font-weight-bold;
}
}
}
- .title-container {
- display: flex;
- align-items: flex-start;
- flex: 1 1 auto;
- padding-top: 14px;
- overflow: hidden;
- }
+ .nav > li {
+ &.header-user {
+ @media (max-width: $screen-xs-max) {
+ padding-left: 10px;
+ }
+ }
- .title {
- position: relative;
- padding-right: 20px;
- margin: 0;
- font-size: 18px;
- line-height: 22px;
- display: inline-block;
- font-weight: normal;
- color: $gl-text-color;
- vertical-align: top;
- white-space: nowrap;
+ > a {
+ will-change: color;
+ margin: 4px 2px;
+ padding: 6px 8px;
+ height: 32px;
- &.wrap {
- white-space: normal;
- }
+ @media (max-width: $screen-xs-max) {
+ padding: 0;
+ }
- &.initializing {
- opacity: 0;
- }
+ &.header-user-dropdown-toggle {
+ margin-left: 2px;
- a {
- color: currentColor;
+ .header-user-avatar {
+ margin-right: 0;
+ }
+ }
- &:hover {
- text-decoration: underline;
- color: $gl-header-nav-hover-color;
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ outline: 0;
+ opacity: 1;
+ color: $white-light;
+
+ &.header-user-dropdown-toggle .header-user-avatar {
+ border-color: $white-light;
+ }
}
}
- .dropdown-toggle-caret {
- color: $gl-text-color;
- border: transparent;
- background: transparent;
- position: absolute;
- top: 2px;
- right: 3px;
- width: 12px;
- line-height: 19px;
- padding: 0;
- font-size: 10px;
- text-align: center;
- cursor: pointer;
-
- &:hover {
- color: $gl-header-nav-hover-color;
- }
+ .header-new-dropdown-toggle {
+ margin-right: 0;
}
- .project-item-select {
- right: auto;
- left: 0;
+ .impersonated-user,
+ .impersonated-user:hover {
+ margin-right: 1px;
+ background-color: $white-light;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
}
- }
- .navbar-collapse {
- flex: 0 0 auto;
- border-top: none;
- padding: 0;
+ .impersonation-btn,
+ .impersonation-btn:hover {
+ background-color: $white-light;
+ margin-left: 0;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
- @media (max-width: $screen-xs-max) {
- flex: 1 1 auto;
+ i {
+ color: $orange-500;
+ font-size: 20px;
+ }
}
}
}
+}
+
+.navbar-sub-nav,
+.navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ text-decoration: none;
+ outline: 0;
+ color: $white-light;
+ }
+
+ > a {
+ display: -webkit-flex;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 8px;
+ margin: 4px 2px;
+ font-size: 12px;
+ color: currentColor;
+ border-radius: $border-radius-default;
+ height: 32px;
+ font-weight: $gl-font-weight-bold;
+ }
+
+ &.line-separator {
+ margin: 8px;
+ }
+ }
+}
+
+.navbar-sub-nav {
+ display: -webkit-flex;
+ display: flex;
+ margin: 0 0 0 6px;
+
+ .projects-dropdown-menu {
+ padding: 0;
+ overflow-y: initial;
+ max-height: initial;
+ }
+
+ .dropdown-chevron {
+ position: relative;
+ top: -1px;
+ font-size: 10px;
+ }
.project-item-select-holder {
display: inline;
@@ -286,18 +317,151 @@ header {
}
}
+.caret-down {
+ height: 11px;
+ width: 11px;
+ margin-left: 4px;
+ fill: currentColor;
+}
+
+.header-user .dropdown-menu-nav,
+.header-new .dropdown-menu-nav {
+ margin-top: $dropdown-vertical-offset;
+}
+
+.breadcrumbs {
+ display: -webkit-flex;
+ display: flex;
+ min-height: $breadcrumb-min-height;
+ color: $gl-text-color;
+}
+
+.breadcrumbs-container {
+ display: -webkit-flex;
+ display: flex;
+ width: 100%;
+ position: relative;
+ padding-top: $gl-padding / 2;
+ padding-bottom: $gl-padding / 2;
+ align-items: center;
+ border-bottom: 1px solid $border-color;
+}
+
+.breadcrumbs-links {
+ -webkit-flex: 1;
+ flex: 1;
+ min-width: 0;
+ align-self: center;
+ color: $gl-text-color-secondary;
+
+ .avatar-tile {
+ margin-right: 4px;
+ border: 1px solid $border-color;
+ border-radius: 50%;
+ vertical-align: sub;
+ }
+
+ .text-expander {
+ margin-left: 0;
+ margin-right: 2px;
+
+ > i {
+ position: relative;
+ top: 1px;
+ }
+ }
+}
+
+.breadcrumbs-list {
+ display: -webkit-flex;
+ display: flex;
+ margin-bottom: 0;
+ line-height: 16px;
+
+ @media (max-width: $screen-xs-max) {
+ flex-wrap: wrap;
+ }
+
+ > li {
+ display: flex;
+ align-items: center;
+ position: relative;
+ padding: 2px 0;
+
+ &:not(:last-child) {
+ padding-right: 20px;
+
+ &:not(.dropdown) {
+ overflow: hidden;
+ }
+ }
+
+ > a {
+ font-size: 12px;
+ color: currentColor;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 0 1 auto;
+ }
+ }
+}
+
+.breadcrumb-item-text {
+ text-decoration: inherit;
+
+ @media (max-width: $screen-xs-max) {
+ @include str-truncated(128px);
+ }
+}
+
+.breadcrumbs-list-angle {
+ position: absolute;
+ right: 7px;
+ top: 50%;
+ color: $gl-text-color-tertiary;
+ transform: translateY(-50%);
+}
+
+.breadcrumbs-extra {
+ display: -webkit-flex;
+ display: flex;
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
+.breadcrumbs-sub-title {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 16px;
+
+ a {
+ color: $gl-text-color;
+ }
+}
+
+.btn-sign-in {
+ margin-top: 3px;
+ font-weight: $gl-font-weight-bold;
+
+ &:hover {
+ background-color: $white-light;
+ }
+}
+
.navbar-nav {
li {
.badge {
position: inherit;
- top: -3px;
- font-weight: normal;
- margin-left: -12px;
+ font-weight: $gl-font-weight-normal;
+ margin-left: -6px;
font-size: 11px;
color: $white-light;
- padding: 1px 5px 2px;
+ padding: 0 5px;
+ line-height: 12px;
border-radius: 7px;
- box-shadow: 0 1px 0 rgba($gl-header-color, .2);
+ box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
&.issues-count {
background-color: $green-500;
@@ -315,11 +479,10 @@ header {
}
@media (max-width: $screen-xs-max) {
- header .container-fluid {
+ .navbar-gitlab .container-fluid {
font-size: 18px;
.navbar-nav {
- display: table;
table-layout: fixed;
width: 100%;
margin: 0;
@@ -327,10 +490,11 @@ header {
}
.navbar-collapse {
- padding-left: 5px;
+ margin-left: -8px;
+ margin-right: -10px;
.nav > li:not(.hidden-xs) {
- display: table-cell!important;
+ display: table-cell !important;
width: 25%;
a {
@@ -352,12 +516,12 @@ header {
.header-user {
.dropdown-menu-nav {
width: auto;
- min-width: 140px;
- margin-top: -5px;
+ min-width: 160px;
+ margin-top: 4px;
color: $gl-text-color;
left: auto;
- .current-user {
+ li.current-user {
padding: 5px 18px;
.user-name {
@@ -373,3 +537,23 @@ header {
border-radius: 50%;
border: 1px solid $avatar-border;
}
+
+.with-performance-bar .navbar-gitlab {
+ top: $performance-bar-height;
+}
+
+.navbar-empty {
+ height: $header-height;
+ background: $white-light;
+ border-bottom: 1px solid $white-normal;
+
+ .center-logo {
+ margin: 8px 0;
+ text-align: center;
+
+ .tanuki-logo,
+ img {
+ height: 36px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 6d27d7568cf..813a1711ea2 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -1,5 +1,5 @@
.file-content.code {
- border: none;
+ border: 0;
box-shadow: none;
margin: 0;
padding: 0;
@@ -7,7 +7,7 @@
pre {
padding: 10px 0;
- border: none;
+ border: 0;
border-radius: 0;
font-family: $monospace_font;
font-size: $code_font_size;
@@ -47,7 +47,7 @@
font-family: $monospace_font;
display: block;
font-size: $code_font_size !important;
- line-height: 19px;
+ min-height: 19px;
white-space: nowrap;
i {
@@ -61,7 +61,7 @@
&:focus {
outline: none;
- & i {
+ i {
visibility: visible;
}
}
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index ef864e8f6a9..30314f3d6cb 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -1,15 +1,11 @@
.ci-status-icon-success,
.ci-status-icon-passed {
- color: $green-500;
-
svg {
fill: $green-500;
}
}
.ci-status-icon-failed {
- color: $gl-danger;
-
svg {
fill: $gl-danger;
}
@@ -18,43 +14,33 @@
.ci-status-icon-pending,
.ci-status-icon-failed_with_warnings,
.ci-status-icon-success_with_warnings {
- color: $orange-500;
-
svg {
fill: $orange-500;
}
}
.ci-status-icon-running {
- color: $blue-400;
-
svg {
fill: $blue-400;
}
}
.ci-status-icon-canceled,
-.ci-status-icon-disabled,
-.ci-status-icon-not-found {
- color: $gl-text-color;
-
+.ci-status-icon-disabled {
svg {
fill: $gl-text-color;
}
}
.ci-status-icon-created,
-.ci-status-icon-skipped {
- color: $gray-darkest;
-
+.ci-status-icon-skipped,
+.ci-status-icon-notfound {
svg {
fill: $gray-darkest;
}
}
.ci-status-icon-manual {
- color: $gl-text-color;
-
svg {
fill: $gl-text-color;
}
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index 09a569ad415..df1cafc9f8e 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -9,3 +9,65 @@
padding: 10px;
margin-bottom: 10px;
}
+
+.svg-content {
+ text-align: center;
+ padding: $gl-padding;
+
+ svg,
+ img {
+ max-width: 425px;
+ width: 100%;
+ }
+
+ $image-widths: 80 250 306 394 430;
+ @each $width in $image-widths {
+ &.svg-#{$width} {
+ img,
+ svg {
+ width: #{$width + 'px'};
+ }
+ }
+ }
+}
+
+@mixin svg-size($size) {
+ width: $size;
+ height: $size;
+}
+
+svg {
+ fill: currentColor;
+
+ &.s8 {
+ @include svg-size(8px);
+ }
+
+ &.s12 {
+ @include svg-size(12px);
+ }
+
+ &.s16 {
+ @include svg-size(16px);
+ }
+
+ &.s18 {
+ @include svg-size(18px);
+ }
+
+ &.s24 {
+ @include svg-size(24px);
+ }
+
+ &.s32 {
+ @include svg-size(32px);
+ }
+
+ &.s48 {
+ @include svg-size(48px);
+ }
+
+ &.s72 {
+ @include svg-size(72px);
+ }
+}
diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss
index 1537b0744cc..d8c57a0e2d9 100644
--- a/app/assets/stylesheets/framework/issue_box.scss
+++ b/app/assets/stylesheets/framework/issue_box.scss
@@ -24,11 +24,13 @@
font-size: $gl-font-size;
line-height: 25px;
- &.status-box-closed {
+ &.status-box-closed,
+ &.status-box-mr-closed {
background-color: $gl-danger;
}
- &.status-box-merged {
+ &.status-box-issue-closed,
+ &.status-box-mr-merged {
background-color: $gl-primary;
}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 4a9d41b4fda..d107422e517 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -1,10 +1,16 @@
html {
overflow-y: scroll;
- &.touch .tooltip { display: none !important; }
+ &.touch .tooltip {
+ display: none !important;
+ }
}
body {
+ // Improves readability for dyslexic users; supported only in Chrome/Safari so far
+ // scss-lint:disable PropertySpelling
+ text-decoration-skip: ink;
+ // scss-lint:enable PropertySpelling
&.navless {
background-color: $white-light !important;
}
@@ -24,20 +30,17 @@ body {
}
.content-wrapper {
+ margin-top: $header-height;
padding-bottom: 100px;
-
- &:not(.page-with-layout-nav) {
- margin-top: $header-height;
- }
}
.container {
padding-top: 0;
z-index: 5;
-}
-.container .content {
- margin: 0;
+ .content {
+ margin: 0;
+ }
}
.navless-container {
@@ -86,37 +89,35 @@ body {
transition: background-color 0.15s, border-color 0.15s;
background-color: $orange-500;
border-color: $orange-500;
- }
- .alert-warning + .alert-warning {
- background-color: $orange-600;
- border-color: $orange-600;
- }
+ &:only-of-type {
+ background-color: $orange-500;
+ border-color: $orange-500;
+ }
- .alert-warning + .alert-warning + .alert-warning {
- background-color: $orange-700;
- border-color: $orange-700;
- }
+ + .alert-warning {
+ background-color: $orange-600;
+ border-color: $orange-600;
- .alert-warning + .alert-warning + .alert-warning + .alert-warning {
- background-color: $orange-800;
- border-color: $orange-800;
- }
+ + .alert-warning {
+ background-color: $orange-700;
+ border-color: $orange-700;
- .alert-warning:only-of-type {
- background-color: $orange-500;
- border-color: $orange-500;
+ + .alert-warning {
+ background-color: $orange-800;
+ border-color: $orange-800;
+ }
+ }
+ }
}
}
+.with-performance-bar .layout-page {
+ margin-top: $header-height + $performance-bar-height;
+}
-/* The following prevents side effects related to iOS Safari's implementation of -webkit-overflow-scrolling: touch,
-which is applied to the body by jquery.nicescroling plugin to force hardware acceleration for momentum scrolling. Side
-effects are commonly related to inconsisent z-index behavior (e.g. tooltips). By applying the following to direct children
-of the body element here, we negate cascading side effects but allow momentum scrolling to be applied to the body */
-
-.navbar,
-.page-gutter,
-.page-with-sidebar {
- -webkit-overflow-scrolling: auto;
+.vertical-center {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 38727e15c6f..7e829826eba 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -12,6 +12,7 @@
padding: 10px 15px;
min-height: 20px;
border-bottom: 1px solid $list-border;
+ word-wrap: break-word;
&::after {
content: " ";
@@ -42,7 +43,7 @@
}
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
&.bottom {
background: $gray-light;
@@ -92,7 +93,7 @@ ul.unstyled-list {
}
ul.unstyled-list > li {
- border-bottom: none;
+ border-bottom: 0;
}
// Generic content list
@@ -113,7 +114,7 @@ ul.content-list {
}
.title {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
a {
@@ -125,10 +126,8 @@ ul.content-list {
}
.description {
- p {
- @include str-truncated;
- margin-bottom: 0;
- }
+ @include str-truncated;
+ color: $gl-text-color-secondary;
}
.controls {
@@ -176,7 +175,7 @@ ul.content-list {
// When dragging a list item
&.ui-sortable-helper {
- border-bottom: none;
+ border-bottom: 0;
}
&.list-placeholder {
@@ -210,7 +209,7 @@ ul.content-list {
}
.row-title {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.row-second-line {
@@ -227,6 +226,10 @@ ul.content-list {
.label-default {
color: $gl-text-color-secondary;
}
+
+ .avatar-cell {
+ align-self: flex-start;
+ }
}
.panel > .content-list > li {
@@ -236,6 +239,8 @@ ul.content-list {
ul.controls {
float: right;
list-style: none;
+ display: flex;
+ align-items: center;
.btn {
padding: 10px 14px;
@@ -250,8 +255,6 @@ ul.controls {
}
.author_link {
- display: inline-block;
-
.avatar-inline {
margin-left: 0;
margin-right: 0;
@@ -259,6 +262,12 @@ ul.controls {
}
}
}
+
+ .issuable-pipeline-broken a,
+ .issuable-pipeline-status a,
+ .author_link {
+ display: flex;
+ }
}
ul.indent-list {
@@ -267,7 +276,58 @@ ul.indent-list {
// Specific styles for tree list
+@keyframes spin-avatar {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.groups-list-tree-container {
+ .has-no-search-results {
+ text-align: center;
+ padding: $gl-padding;
+ font-style: italic;
+ color: $well-light-text-color;
+ }
+
+ > .group-list-tree > .group-row.has-children:first-child {
+ border-top: 0;
+ }
+}
+
.group-list-tree {
+ .avatar-container.content-loading {
+ position: relative;
+
+ > a,
+ > a .avatar {
+ height: 100%;
+ border-radius: 50%;
+ }
+
+ > a {
+ padding: 2px;
+
+ .avatar {
+ border: 2px solid $white-normal;
+
+ &.identicon {
+ line-height: 15px;
+ }
+ }
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ background-color: transparent;
+ border: 2px outset $kdb-border;
+ border-radius: 50%;
+ animation: spin-avatar 3s infinite linear;
+ }
+ }
+
.folder-toggle-wrap {
float: left;
line-height: $list-text-height;
@@ -279,20 +339,25 @@ ul.indent-list {
}
.folder-caret,
- .folder-icon {
+ .item-type-icon {
display: inline-block;
}
.folder-caret {
width: 15px;
+
+ svg {
+ margin-bottom: 2px;
+ }
}
- .folder-icon {
+ .item-type-icon {
+ margin-top: 2px;
width: 20px;
}
- > .group-row:not(.has-subgroups) {
- .folder-caret .fa {
+ > .group-row:not(.has-children) {
+ .folder-caret {
opacity: 0;
}
}
@@ -337,12 +402,29 @@ ul.indent-list {
top: 30px;
bottom: 0;
}
+
+ &.being-removed {
+ opacity: 0.5;
+ }
}
}
.group-row {
padding: 0;
- border: none;
+
+ &.has-children {
+ border-top: 0;
+ }
+
+ &:first-child {
+ border-top: 1px solid $white-normal;
+ }
+
+ &:last-of-type {
+ .group-row-contents:not(:hover) {
+ border-bottom: 1px solid transparent;
+ }
+ }
}
.group-row-contents {
@@ -355,6 +437,82 @@ ul.indent-list {
background-color: $row-hover;
cursor: pointer;
}
+
+ .avatar-container > a {
+ width: 100%;
+ text-decoration: none;
+ }
+
+ &.has-more-items {
+ display: block;
+ padding: 20px 10px;
+ }
+
+ .stats {
+ position: relative;
+ line-height: 46px;
+
+ > span {
+ display: inline-flex;
+ align-items: center;
+ height: 16px;
+ min-width: 30px;
+ }
+
+ > span:last-child {
+ margin-right: 0;
+ }
+
+ .stat-value {
+ margin: 2px 0 0 5px;
+ }
+ }
+
+ .controls {
+ margin-left: 5px;
+
+ > .btn {
+ margin-right: $btn-xs-side-margin;
+ }
+ }
+ }
+
+ .project-row-contents .stats {
+ line-height: inherit;
+
+ > span:first-child {
+ margin-left: 25px;
+ }
+
+ .item-visibility {
+ margin-right: 0;
+ }
+
+ .last-updated {
+ position: absolute;
+ right: 12px;
+ min-width: 250px;
+ text-align: right;
+ color: $gl-text-color-secondary;
+ }
+ }
+}
+
+.namespace-title {
+ .tooltip-inner {
+ max-width: 350px;
+ }
+}
+
+ul.group-list-tree {
+ li.group-row {
+ > .group-row-contents .title {
+ line-height: $list-text-height;
+ }
+
+ &.has-description > .group-row-contents .title {
+ line-height: inherit;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index b21bcc22a87..938f5f49c09 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -57,6 +57,7 @@
.md-header {
.nav-links {
a {
+ width: 100%;
padding-top: 0;
line-height: 19px;
@@ -72,6 +73,35 @@
}
}
+.md-header-tab {
+ @media (max-width: $screen-xs-max) {
+ flex: 1;
+ width: 100%;
+ border-bottom: 1px solid $border-color;
+ text-align: center;
+ }
+}
+
+.nav-links {
+ li.md-header-toolbar {
+ margin-left: auto;
+ display: none;
+
+ &.active {
+ display: block;
+
+ @media (max-width: $screen-xs-max) {
+ flex: none;
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ padding-top: $gl-padding-top;
+ padding-bottom: $gl-padding-top;
+ }
+ }
+ }
+}
+
.referenced-users {
color: $gl-text-color;
padding-top: 10px;
@@ -126,27 +156,35 @@
}
}
-.toolbar-group {
- float: left;
- margin-right: -5px;
- margin-left: $gl-padding;
-
- &:first-child {
- margin-left: 0;
- }
-}
-
.toolbar-btn {
float: left;
- padding: 0 5px;
- color: $gl-text-color-secondary;
+ padding: 0 7px;
background: transparent;
border: 0;
outline: 0;
+ svg {
+ width: 14px;
+ height: 14px;
+ margin-top: 3px;
+ fill: $gl-text-color-secondary;
+ }
+
&:hover,
&:focus {
- color: $gl-link-color;
+ svg {
+ fill: $gl-link-color;
+ }
+ }
+}
+
+.toolbar-fullscreen-btn {
+ margin-left: $gl-padding;
+ margin-right: -5px;
+
+ @media (max-width: $screen-xs-max) {
+ margin-left: 0;
+ margin-right: 0;
}
}
@@ -154,6 +192,17 @@
overflow-y: auto;
overflow-x: hidden;
+ .name,
+ small.aliases,
+ small.params {
+ float: left;
+ }
+
+ small.aliases,
+ small.params {
+ padding: 2px 5px;
+ }
+
small.description {
float: right;
padding: 3px 5px;
@@ -165,17 +214,51 @@
.cur {
.avatar {
- border: 1px solid $white-light;
@include disableAllAnimation;
+ border: 1px solid $white-light;
}
}
ul > li {
+ @include clearfix;
white-space: nowrap;
}
+
+ // TODO: fallback to global style
+ .atwho-view-ul {
+ padding: 8px 1px;
+
+ li {
+ padding: 8px 16px;
+ border: 0;
+
+ &.cur {
+ background-color: $gray-darker;
+ color: $gl-text-color;
+
+ small {
+ color: inherit;
+ }
+ }
+
+ div.avatar {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+
+ .center {
+ line-height: 14px;
+ }
+ }
+
+ strong {
+ color: $gl-text-color;
+ }
+ }
+ }
}
-@media(max-width: $screen-xs-max) {
+@media (max-width: $screen-xs-max) {
.atwho-view-ul {
width: 350px;
}
diff --git a/app/assets/stylesheets/framework/media_object.scss b/app/assets/stylesheets/framework/media_object.scss
new file mode 100644
index 00000000000..89c561479cc
--- /dev/null
+++ b/app/assets/stylesheets/framework/media_object.scss
@@ -0,0 +1,12 @@
+.media {
+ display: flex;
+ align-items: flex-start;
+}
+
+.media-body {
+ flex: 1;
+}
+
+.media-body-wrap {
+ flex-grow: 1;
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 3a98332e46c..e12b5aab381 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -36,14 +36,14 @@
margin: 0;
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
}
&.active {
background: $gray-light;
a {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
@@ -118,3 +118,85 @@
@content;
}
}
+
+/*
+ * Mixin for status badges, as used for pipelines and commit signatures
+ */
+@mixin status-color($color-light, $color-main, $color-dark) {
+ color: $color-main;
+ border-color: $color-main;
+
+ &:not(span):hover {
+ background-color: $color-light;
+ color: $color-dark;
+ border-color: $color-dark;
+ }
+}
+
+@mixin green-status-color {
+ @include status-color($green-100, $green-500, $green-700);
+}
+
+@mixin fade($gradient-direction, $gradient-color) {
+ visibility: hidden;
+ opacity: 0;
+ z-index: 2;
+ position: absolute;
+ bottom: 12px;
+ width: 43px;
+ height: 30px;
+ transition-duration: .3s;
+ -webkit-transform: translateZ(0);
+ background: linear-gradient(to $gradient-direction, $gradient-color 45%, rgba($gradient-color, 0.4));
+
+ &.scrolling {
+ visibility: visible;
+ opacity: 1;
+ transition-duration: .3s;
+ }
+
+ .fa {
+ position: relative;
+ top: 5px;
+ font-size: 18px;
+ }
+}
+
+@mixin scrolling-links() {
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+ display: flex;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+@mixin triangle($color, $border-color, $size, $border-size) {
+ &::before,
+ &::after {
+ bottom: 100%;
+ left: 50%;
+ border: solid transparent;
+ content: '';
+ height: 0;
+ width: 0;
+ position: absolute;
+ pointer-events: none;
+ }
+
+ &::before {
+ border-color: transparent;
+ border-bottom-color: $border-color;
+ border-width: ($size + $border-size);
+ margin-left: -($size + $border-size);
+ }
+
+ &::after {
+ border-color: transparent;
+ border-bottom-color: $color;
+ border-width: $size;
+ margin-left: -$size;
+ }
+}
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 600a1f53b58..8604e753c18 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -63,10 +63,6 @@
}
}
- .project-stats {
- display: none;
- }
-
.group-buttons {
display: none;
}
@@ -111,21 +107,4 @@
aside:not(.right-sidebar) {
display: none;
}
-
- .show-aside {
- display: block !important;
- }
-}
-
-.show-aside {
- display: none;
- position: fixed;
- right: 0;
- top: 30%;
- padding: 5px 15px;
- background: $show-aside-bg;
- font-size: 20px;
- color: $show-aside-color;
- z-index: 100;
- box-shadow: 0 1px 2px $show-aside-shadow;
}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 7098203321d..48b981dd31f 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -1,14 +1,58 @@
+.modal-header {
+ background-color: $modal-body-bg;
+ padding: #{3 * $grid-size} #{2 * $grid-size};
+
+ .page-title,
+ .modal-title {
+ .color-label {
+ font-size: $gl-font-size;
+ padding: $gl-vert-padding $label-padding-modal;
+ }
+ }
+
+ .page-title {
+ margin-top: 0;
+ }
+}
+
.modal-body {
+ background-color: $modal-body-bg;
+ line-height: $line-height-base;
+ min-height: $modal-body-height;
position: relative;
- padding: 15px;
+ padding: #{3 * $grid-size} #{2 * $grid-size};
+ text-align: left;
.form-actions {
- margin: -$gl-padding + 1;
- margin-top: 15px;
+ margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
}
.text-danger {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
+ }
+}
+
+.modal-footer {
+ display: flex;
+ flex-direction: row;
+
+ .btn + .btn {
+ margin-left: $grid-size;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-direction: column;
+
+ .btn + .btn {
+ margin-left: 0;
+ margin-top: $grid-size;
+ }
+ }
+
+ @media (min-width: $screen-sm-min) {
+ .btn:first-of-type {
+ margin-left: auto;
+ }
}
}
@@ -16,8 +60,35 @@ body.modal-open {
overflow: hidden;
}
-@media (min-width: $screen-md-min) {
- .modal-dialog {
- width: 860px;
+.modal-no-backdrop {
+ @extend .modal-dialog;
+
+ .modal-content {
+ box-shadow: none;
+ }
+}
+
+@media (min-width: $screen-lg-min) {
+ .modal-full {
+ width: 98%;
+ }
+}
+
+.modal {
+ background-color: $black-transparent;
+ z-index: 2100;
+
+ @media (min-width: $screen-md-min) {
+ .modal-dialog {
+ margin: 30px auto;
+ }
+ }
+}
+
+.recaptcha-modal .recaptcha-form {
+ display: inline-block;
+
+ .recaptcha {
+ margin: 0;
}
}
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss
deleted file mode 100644
index 28b2a7cfacd..00000000000
--- a/app/assets/stylesheets/framework/nav.scss
+++ /dev/null
@@ -1,528 +0,0 @@
-@mixin fade($gradient-direction, $gradient-color) {
- visibility: hidden;
- opacity: 0;
- z-index: 2;
- position: absolute;
- bottom: 12px;
- width: 43px;
- height: 30px;
- transition-duration: .3s;
- -webkit-transform: translateZ(0);
- background: linear-gradient(to $gradient-direction, $gradient-color 45%, rgba($gradient-color, 0.4));
-
- &.scrolling {
- visibility: visible;
- opacity: 1;
- transition-duration: .3s;
- }
-
- .fa {
- position: relative;
- top: 5px;
- font-size: 18px;
- }
-}
-
-@mixin scrolling-links() {
- overflow-x: auto;
- overflow-y: hidden;
- -webkit-overflow-scrolling: touch;
- display: flex;
-
- &::-webkit-scrollbar {
- display: none;
- }
-}
-
-.nav-links {
- display: flex;
- padding: 0;
- margin: 0;
- list-style: none;
- height: auto;
- border-bottom: 1px solid $border-color;
-
- li {
- display: flex;
-
- a {
- padding: $gl-btn-padding;
- padding-bottom: 11px;
- font-size: 14px;
- line-height: 28px;
- color: $gl-text-color-secondary;
- border-bottom: 2px solid transparent;
- white-space: nowrap;
-
- &:hover,
- &:active,
- &:focus {
- text-decoration: none;
- border-bottom: 2px solid $gray-darkest;
- color: $black;
-
- .badge {
- color: $black;
- }
- }
- }
-
- &.active a {
- border-bottom: 2px solid $link-underline-blue;
- color: $black;
- font-weight: 600;
-
- .badge {
- color: $black;
- }
- }
- }
-
- &.sub-nav {
- text-align: center;
- background-color: $gray-normal;
-
- .container-fluid {
- background-color: $gray-normal;
- margin-bottom: 0;
- display: flex;
- }
-
- li {
- &.active a {
- border-bottom: none;
- color: $link-underline-blue;
- }
-
- a {
- margin: 0;
- padding: 11px 10px 9px;
-
- &:hover,
- &:active,
- &:focus {
- border-color: transparent;
- }
- }
- }
- }
-}
-
-.top-area {
- @include clearfix;
- border-bottom: 1px solid $border-color;
-
- .nav-text {
- padding-top: 16px;
- padding-bottom: 11px;
- display: inline-block;
- line-height: 28px;
- white-space: normal;
-
- /* Small devices (phones, tablets, 768px and lower) */
- @media (max-width: $screen-xs-max) {
- width: 100%;
- }
- }
-
- .nav-search {
- display: inline-block;
- width: 100%;
- padding: 11px 0;
-
- /* Small devices (phones, tablets, 768px and lower) */
- @media (min-width: $screen-sm-min) {
- width: 50%;
- }
- }
-
- .nav-links {
- margin-bottom: 0;
- border-bottom: none;
- float: left;
-
- &.wide {
- width: 100%;
- display: block;
- }
-
- &.scrolling-tabs {
- float: left;
- }
-
- li a {
- padding: 16px 15px 11px;
- }
-
- /* Small devices (phones, tablets, 768px and lower) */
- @media (max-width: $screen-xs-max) {
- width: 100%;
- }
- }
-
- .nav-controls {
- display: inline-block;
- float: right;
- text-align: right;
- padding: 11px 0;
- margin-bottom: 0;
-
- > .btn,
- > .btn-container,
- > .dropdown,
- > input,
- > form {
- margin-right: $gl-padding-top;
- display: inline-block;
- vertical-align: top;
-
- &:last-child {
- margin-right: 0;
- float: right;
- }
- }
-
- > .btn-grouped {
- float: none;
- }
-
- .icon-label {
- display: none;
- }
-
- .btn,
- .dropdown,
- .dropdown-toggle,
- input,
- form {
- height: 35px;
- }
-
- input {
- display: inline-block;
- position: relative;
-
- /* Medium devices (desktops, 992px and up) */
- @media (min-width: $screen-md-min) { width: 200px; }
-
- /* Large devices (large desktops, 1200px and up) */
- @media (min-width: $screen-lg-min) { width: 250px; }
-
- &.input-short {
- /* Medium devices (desktops, 992px and up) */
- @media (min-width: $screen-md-min) { width: 170px; }
-
- /* Large devices (large desktops, 1200px and up) */
- @media (min-width: $screen-lg-min) { width: 210px; }
- }
- }
-
- @media (max-width: $screen-xs-max) {
- padding-bottom: 0;
- width: 100%;
-
- .btn,
- form,
- .dropdown,
- .dropdown-toggle,
- .dropdown-menu-toggle,
- .form-control {
- margin: 0 0 10px;
- display: block;
- width: 100%;
- }
-
- form {
- display: block;
- height: auto;
- margin-bottom: 14px;
-
- input {
- width: 100%;
- margin: 0 0 10px;
- }
- }
-
- .input-short {
- width: 100%;
- }
-
- .icon-label {
- display: inline-block;
- }
-
- // Applies on /dashboard/issues
- .project-item-select-holder {
- display: block;
- margin: 0;
- }
- }
- }
-
- &.adjust {
- .nav-text,
- .nav-controls {
- width: auto;
-
- @media (max-width: $screen-xs-max) {
- width: 100%;
- }
- }
- }
-
- &.multi-line {
- .nav-text {
- line-height: 20px;
- }
-
- .nav-controls {
- padding: 17px 0;
- }
- }
-
- pre {
- width: 100%;
- }
-}
-
-.layout-nav {
- width: 100%;
- background: $gray-light;
- border-bottom: 1px solid $border-color;
- transition: padding $sidebar-transition-duration;
- text-align: center;
- margin-top: $header-height;
-
- .container-fluid {
- position: relative;
-
- .nav-control {
- @media (max-width: $screen-sm-max) {
- margin-right: 2px;
- }
- }
- }
-
- .controls {
- float: right;
- padding: 7px 0 0;
-
- i {
- color: $layout-link-gray;
- }
-
- .fa-rss,
- .fa-cog {
- font-size: 16px;
- }
-
- .fa-caret-down {
- margin-left: 5px;
- color: $gl-text-color-secondary;
- }
-
- .dropdown {
- position: absolute;
- top: 7px;
- right: 15px;
- z-index: 2;
-
- li.active {
- font-weight: bold;
- }
- }
- }
-
- .nav-links {
- border-bottom: none;
- height: 51px;
-
- @media (min-width: $screen-sm-min) {
- justify-content: center;
- }
-
- li {
- a {
- padding-top: 10px;
- }
- }
- }
-}
-
-.scrolling-tabs-container {
- position: relative;
-
- .merge-request-tabs-container & {
- overflow: hidden;
- }
-
- .nav-links {
- @include scrolling-links();
- }
-
- .fade-right {
- @include fade(left, $gray-light);
- right: -5px;
-
- .fa {
- right: -7px;
- }
- }
-
- .fade-left {
- @include fade(right, $gray-light);
- left: -5px;
- text-align: center;
-
- .fa {
- left: -7px;
- }
- }
-
- &.sub-nav-scroll {
-
- .fade-right {
- @include fade(left, $gray-normal);
- right: 0;
-
- .fa {
- right: -23px;
- }
- }
-
- .fade-left {
- @include fade(right, $gray-normal);
- left: 0;
-
- .fa {
- left: 10px;
- }
- }
- }
-}
-
-.nav-block {
- position: relative;
-
- .nav-links {
- @include scrolling-links();
-
- .fade-right {
- @include fade(left, $white-light);
- right: -5px;
-
- .fa {
- right: -7px;
- }
- }
-
- .fade-left {
- @include fade(right, $white-light);
- left: -5px;
-
- .fa {
- left: -7px;
- }
- }
- }
-}
-
-.page-with-layout-nav {
- .right-sidebar {
- top: ($header-height + 1) * 2;
- }
-
- &.page-with-sub-nav {
- .right-sidebar {
- top: ($header-height + 1) * 3;
-
- &.affix {
- top: $header-height;
- }
- }
- }
-}
-
-.nav-block {
- &.activities {
- border-bottom: 1px solid $border-color;
-
- .nav-links {
- border-bottom: none;
- }
- }
-}
-
-@media (max-width: $screen-xs-max) {
- .top-area {
- flex-flow: row wrap;
-
- .nav-controls {
- $controls-margin: $btn-xs-side-margin - 2px;
- flex: 0 0 100%;
-
- &.controls-flex {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- justify-content: center;
- padding: 0 0 $gl-padding-top;
- }
-
- .controls-item,
- .controls-item-full,
- .controls-item:last-child {
- flex: 1 1 35%;
- display: block;
- width: 100%;
- margin: $controls-margin;
-
- .btn,
- .dropdown {
- margin: 0;
- }
- }
-
- .controls-item-full {
- flex: 1 1 100%;
- }
- }
- }
-}
-
-.inner-page-scroll-tabs {
- position: relative;
-
- .fade-right {
- @include fade(left, $white-light);
- right: 0;
- text-align: right;
-
- .fa {
- right: 5px;
- }
- }
-
- .fade-left {
- @include fade(right, $white-light);
- left: 0;
- text-align: left;
-
- .fa {
- left: 5px;
- }
- }
-
- .fade-right,
- .fade-left {
- top: 16px;
- bottom: auto;
- }
-
- &.is-smaller {
- .fade-right,
- .fade-left {
- top: 11px;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page-header.scss
deleted file mode 100644
index f1ecd050a0a..00000000000
--- a/app/assets/stylesheets/framework/page-header.scss
+++ /dev/null
@@ -1,66 +0,0 @@
-.page-content-header {
- line-height: 34px;
- padding: 10px 0;
- margin-bottom: 0;
-
- @media (min-width: $screen-sm-min) {
- display: flex;
- align-items: center;
-
- .header-main-content {
- flex: 1;
- }
- }
-
- .header-action-buttons {
- i {
- color: $gl-text-color-secondary;
- font-size: 13px;
- margin-right: 3px;
- }
-
- @media (max-width: $screen-xs-max) {
- .btn {
- width: 100%;
- margin-top: 10px;
- }
-
- .dropdown {
- width: 100%;
- }
- }
- }
-
- .avatar {
- @extend .avatar-inline;
- margin-left: 0;
-
- @media (min-width: $screen-sm-min) {
- margin-left: 4px;
- }
- }
-
- .commit-committer-link,
- .commit-author-link {
- color: $gl-text-color;
- font-weight: bold;
- }
-
- .commit-info {
- &.branches {
- margin-left: 8px;
- }
- }
-
- .ci-status-link {
- svg {
- position: relative;
- top: 2px;
- margin: 0 2px 0 3px;
- }
- }
-
- .ci-status {
- margin-right: 10px;
- }
-}
diff --git a/app/assets/stylesheets/framework/page_header.scss b/app/assets/stylesheets/framework/page_header.scss
new file mode 100644
index 00000000000..0c879f40930
--- /dev/null
+++ b/app/assets/stylesheets/framework/page_header.scss
@@ -0,0 +1,66 @@
+.page-content-header {
+ line-height: 34px;
+ padding: 10px 0;
+ margin-bottom: 0;
+
+ @media (min-width: $screen-sm-min) {
+ display: flex;
+ align-items: center;
+
+ .header-main-content {
+ flex: 1;
+ }
+ }
+
+ .header-action-buttons {
+ i {
+ color: $gl-text-color-secondary;
+ font-size: 13px;
+ margin-right: 3px;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .btn {
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .dropdown {
+ width: 100%;
+ }
+ }
+ }
+
+ .avatar {
+ @extend .avatar-inline;
+ margin-left: 0;
+
+ @media (min-width: $screen-sm-min) {
+ margin-left: 4px;
+ }
+ }
+
+ .commit-committer-link,
+ .commit-author-link {
+ color: $gl-text-color;
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .commit-info {
+ &.branches {
+ margin-left: 8px;
+ }
+ }
+
+ .ci-status-link {
+ svg {
+ position: relative;
+ top: 2px;
+ margin: 0 2px 0 3px;
+ }
+ }
+
+ .ci-status {
+ margin-right: 10px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/popup.scss b/app/assets/stylesheets/framework/popup.scss
new file mode 100644
index 00000000000..5c76205095f
--- /dev/null
+++ b/app/assets/stylesheets/framework/popup.scss
@@ -0,0 +1,15 @@
+.popup {
+ @include triangle(
+ $gray-lighter,
+ $gray-darker,
+ $popup-triangle-size,
+ $popup-triangle-border-size
+ );
+
+ padding: $gl-padding;
+ background-color: $gray-lighter;
+ border: 1px solid $gray-darker;
+ border-radius: $border-radius-default;
+ box-shadow: 0 5px 8px $popup-box-shadow-color;
+ position: relative;
+}
diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss
deleted file mode 100644
index d2c90908baa..00000000000
--- a/app/assets/stylesheets/framework/responsive-tables.scss
+++ /dev/null
@@ -1,137 +0,0 @@
-@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;
- min-height: 62px;
-
- &:not(:first-of-type) {
- border-top: 1px solid $white-normal;
- }
- }
- }
-
- &.section-wrap {
- white-space: normal;
-
- @media (max-width: $screen-sm-max) {
- flex-wrap: wrap;
- }
- }
- }
-}
-
-
-.table-button-footer {
- @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;
-
- .table-action-buttons {
- padding: 10px 5px;
- display: flex;
-
- .btn {
- border-radius: 3px;
- }
-
- > .btn-group,
- > .external-url,
- > .btn {
- flex: 1 1 28px;
- margin: 0 5px;
- }
-
- .dropdown-new {
- width: 100%;
- }
-
- .dropdown-menu {
- min-width: initial;
- }
- }
- }
-}
-
-.table-row-header {
- font-size: 13px;
-
- @media (max-width: $screen-sm-max) {
- display: none;
- }
-}
-
-.table-mobile-header {
- color: $gl-text-color-secondary;
- text-align: left;
- @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/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
new file mode 100644
index 00000000000..7829d722560
--- /dev/null
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -0,0 +1,165 @@
+@mixin flex-max-width($max) {
+ flex: 0 0 #{$max + '%'};
+ max-width: #{$max + '%'};
+}
+
+.gl-responsive-table-row-layout {
+ width: 100%;
+
+ @media (min-width: $screen-md-min) {
+ display: flex;
+ align-items: center;
+
+ & > &:not(:first-child) {
+ margin-top: $gl-padding;
+ }
+ }
+}
+
+.gl-responsive-table-row {
+ @extend .gl-responsive-table-row-layout;
+ margin-top: 10px;
+ border: 1px solid $border-color;
+
+ @media (min-width: $screen-md-min) {
+ margin: 0;
+ padding: $gl-padding 0;
+ border: 0;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $white-normal;
+ }
+ }
+}
+
+.gl-responsive-table-row-col-span {
+ flex-wrap: wrap;
+}
+
+.table-section {
+ white-space: nowrap;
+
+ $section-widths: 10 15 20 25 30 40 100;
+ @each $width in $section-widths {
+ &.section-#{$width} {
+ flex: 0 0 #{$width + '%'};
+
+ @media (min-width: $screen-md-min) {
+ max-width: #{$width + '%'};
+ }
+ }
+ }
+
+ @media (max-width: $screen-sm-max) {
+ display: flex;
+ align-self: stretch;
+ padding: 10px;
+ align-items: center;
+ min-height: 62px;
+
+ &:not(:first-child) {
+ border-top: 1px solid $white-normal;
+ }
+ }
+
+ &.section-wrap {
+ white-space: normal;
+
+ @media (max-width: $screen-sm-max) {
+ flex-wrap: wrap;
+ }
+ }
+
+ &.section-align-top {
+ align-self: flex-start;
+ }
+}
+
+.table-button-footer {
+ @media (min-width: $screen-md-min) {
+ text-align: right;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ display: block;
+ align-self: stretch;
+ min-height: 0;
+ background-color: $gray-normal;
+ border-top: 1px solid $border-color;
+
+ .table-action-buttons {
+ display: flex;
+
+ .btn {
+ border-radius: 3px;
+ }
+
+ > .btn-group,
+ > .external-url,
+ > .btn {
+ flex: 1 1 28px;
+
+ &:not(:first-child) {
+ margin-left: 5px;
+ }
+
+ &:not(:last-child) {
+ margin-right: 5px;
+ }
+ }
+
+ .dropdown-new {
+ width: 100%;
+ }
+
+ .dropdown-menu {
+ min-width: initial;
+ }
+ }
+ }
+}
+
+.table-row-header {
+ font-size: 13px;
+
+ @media (max-width: $screen-sm-max) {
+ display: none;
+ }
+}
+
+.table-mobile-header {
+ @include flex-max-width(40);
+ color: $gl-text-color-secondary;
+ text-align: left;
+
+ @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/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
new file mode 100644
index 00000000000..17c31d6b184
--- /dev/null
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -0,0 +1,379 @@
+// For tabbed navigation links, scrolling tabs, etc. For all top/main navigation,
+// please check nav.scss
+.nav-links {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ height: auto;
+ border-bottom: 1px solid $border-color;
+
+ li {
+ display: flex;
+
+ a {
+ padding: $gl-btn-padding;
+ padding-bottom: 11px;
+ font-size: 14px;
+ line-height: 28px;
+ color: $gl-text-color-secondary;
+ border-bottom: 2px solid transparent;
+ white-space: nowrap;
+
+ &:hover,
+ &:active,
+ &:focus {
+ text-decoration: none;
+ color: $black;
+ border-bottom: 2px solid $gray-darkest;
+
+ .badge {
+ color: $black;
+ }
+ }
+ }
+
+ &.active a {
+ color: $black;
+ font-weight: $gl-font-weight-bold;
+
+ .badge {
+ color: $black;
+ }
+ }
+ }
+}
+
+.top-area {
+ @include clearfix;
+ border-bottom: 1px solid $border-color;
+
+ .nav-text {
+ padding-top: 16px;
+ padding-bottom: 11px;
+ display: inline-block;
+ line-height: 28px;
+ white-space: normal;
+
+ /* Small devices (phones, tablets, 768px and lower) */
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+ }
+ }
+
+ .nav-links {
+ margin-bottom: 0;
+ border-bottom: 0;
+ float: left;
+
+ &.wide {
+ width: 100%;
+ display: block;
+ }
+
+ &.scrolling-tabs {
+ float: left;
+ }
+
+ li a {
+ padding: 16px 15px 11px;
+ }
+
+ /* Small devices (phones, tablets, 768px and lower) */
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+
+ &.mobile-separator {
+ border-bottom: 1px solid $border-color;
+ }
+ }
+ }
+
+ .nav-controls {
+ display: inline-block;
+ float: right;
+ text-align: right;
+ padding: 11px 0;
+ margin-bottom: 0;
+
+ > .btn,
+ > .btn-container,
+ > .dropdown,
+ > input,
+ > form {
+ margin-right: $gl-padding-top;
+ display: inline-block;
+ vertical-align: top;
+
+ &:last-child {
+ margin-right: 0;
+ float: right;
+ }
+ }
+
+ > .btn-grouped {
+ float: none;
+ }
+
+ .icon-label {
+ display: none;
+ }
+
+ input {
+ display: inline-block;
+ position: relative;
+
+ /* Medium devices (desktops, 992px and up) */
+ @media (min-width: $screen-md-min) { width: 200px; }
+
+ /* Large devices (large desktops, 1200px and up) */
+ @media (min-width: $screen-lg-min) { width: 250px; }
+
+ &.input-short {
+ /* Medium devices (desktops, 992px and up) */
+ @media (min-width: $screen-md-min) { width: 170px; }
+
+ /* Large devices (large desktops, 1200px and up) */
+ @media (min-width: $screen-lg-min) { width: 210px; }
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ padding-bottom: 0;
+ width: 100%;
+
+ .btn,
+ form,
+ .dropdown,
+ .dropdown-toggle,
+ .dropdown-menu-toggle,
+ .form-control {
+ margin: 0 0 10px;
+ display: block;
+ width: 100%;
+ }
+
+ form {
+ display: block;
+ height: auto;
+ margin-bottom: 14px;
+
+ input {
+ width: 100%;
+ margin: 0 0 10px;
+ }
+ }
+
+ .input-short {
+ width: 100%;
+ }
+
+ .icon-label {
+ display: inline-block;
+ }
+
+ .project-item-select-holder {
+ margin: 0;
+ width: 100%;
+ }
+ }
+ }
+
+ &.adjust {
+ .nav-text,
+ .nav-controls {
+ width: auto;
+
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+ }
+ }
+ }
+
+ &.multi-line {
+ .nav-text {
+ line-height: 20px;
+ }
+
+ .nav-controls {
+ padding: 17px 0;
+ }
+ }
+
+ pre {
+ width: 100%;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-flow: row wrap;
+
+ .nav-controls {
+ $controls-margin: $btn-xs-side-margin - 2px;
+ flex: 0 0 100%;
+
+ &.controls-flex {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0 $gl-padding-top;
+ }
+
+ .controls-item,
+ .controls-item-full,
+ .controls-item:last-child {
+ flex: 1 1 35%;
+ display: block;
+ width: 100%;
+ margin: $controls-margin;
+
+ .btn,
+ .dropdown {
+ margin: 0;
+ }
+ }
+
+ .controls-item-full {
+ flex: 1 1 100%;
+ }
+ }
+ }
+}
+
+.scrolling-tabs-container {
+ position: relative;
+
+ .merge-request-tabs-container & {
+ overflow: hidden;
+ }
+
+ .nav-links {
+ @include scrolling-links();
+ }
+
+ .fade-right {
+ @include fade(left, $gray-light);
+ right: -5px;
+
+ .fa {
+ right: -7px;
+ }
+ }
+
+ .fade-left {
+ @include fade(right, $gray-light);
+ left: -5px;
+ text-align: center;
+
+ .fa {
+ left: -7px;
+ }
+ }
+}
+
+.inner-page-scroll-tabs {
+ position: relative;
+
+ .fade-right {
+ @include fade(left, $white-light);
+ right: 0;
+ text-align: right;
+
+ .fa {
+ right: 5px;
+ }
+ }
+
+ .fade-left {
+ @include fade(right, $white-light);
+ left: 0;
+ text-align: left;
+
+ .fa {
+ left: 5px;
+ }
+ }
+
+ .fade-right,
+ .fade-left {
+ top: 16px;
+ bottom: auto;
+ }
+
+ &.is-smaller {
+ .fade-right,
+ .fade-left {
+ top: 11px;
+ }
+ }
+}
+
+.nav-block {
+ position: relative;
+
+ .nav-links {
+ @include scrolling-links();
+
+ .fade-right {
+ @include fade(left, $white-light);
+ right: -5px;
+
+ .fa {
+ right: -7px;
+ }
+ }
+
+ .fade-left {
+ @include fade(right, $white-light);
+ left: -5px;
+
+ .fa {
+ left: -7px;
+ }
+ }
+ }
+
+ &.activities {
+ border-bottom: 1px solid $border-color;
+
+ .nav-links {
+ border-bottom: 0;
+ }
+ }
+}
+
+.project-item-select-holder.btn-group {
+ display: flex;
+ overflow: hidden;
+ float: right;
+
+ .new-project-item-link {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .new-project-item-select-button {
+ width: 32px;
+ }
+}
+
+.empty-state .project-item-select-holder.btn-group {
+ float: none;
+ display: inline-block;
+
+ .btn {
+ // overrides styles applied to plain `.empty-state .btn`
+ margin: 10px 0;
+ max-width: 300px;
+ width: auto;
+
+ @media(max-width: $screen-xs-max) {
+ max-width: 250px;
+ }
+ }
+}
+
+.new-project-item-select-button .fa-caret-down {
+ margin-left: 2px;
+}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 40e654f4838..b40dcf93969 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -8,7 +8,7 @@
.select2-choice {
background: $white-light;
border-color: $input-border;
- height: 35px;
+ height: 34px;
padding: $gl-vert-padding $gl-input-padding;
font-size: $gl-font-size;
line-height: 1.42857143;
@@ -17,7 +17,7 @@
.select2-arrow {
background-image: none;
background-color: transparent;
- border: none;
+ border: 0;
padding-top: 12px;
padding-right: 20px;
font-size: 10px;
@@ -48,36 +48,29 @@
}
&:hover {
- background-color: $white-normal;
- border-color: $border-white-normal;
+ border-color: $gray-darkest;
color: $gl-text-color;
}
}
}
-.select2-drop {
- box-shadow: $select2-drop-shadow1 0 0 1px 0, $select2-drop-shadow2 0 2px 18px 0;
- border-radius: $border-radius-default;
- border: none;
+.select2-drop,
+.select2-drop.select2-drop-above {
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+ border-radius: $border-radius-base;
+ border: 1px solid $dropdown-border-color;
min-width: 175px;
+ color: $gl-text-color;
+ z-index: 999;
}
-.select2-results .select2-result-label,
-.select2-more-results {
- padding: 10px 15px;
-}
-
-.select2-drop {
- color: $gl-grayish-blue;
-}
-
-.select2-highlighted {
- background: $gl-link-color !important;
+.select2-drop-mask {
+ z-index: 998;
}
-.select2-results li.select2-result-with-children > .select2-result-label {
- font-weight: 600;
- color: $gl-text-color;
+.select2-drop.select2-drop-above.select2-drop-active {
+ border-top: 1px solid $dropdown-border-color;
+ margin-top: -6px;
}
.select2-container-active {
@@ -87,13 +80,11 @@
}
}
-.select2-dropdown-open {
+.select2-dropdown-open,
+.select2-dropdown-open.select2-drop-above {
.select2-choice {
- border-color: $border-white-normal;
+ border-color: $gray-darkest;
outline: 0;
- background-image: none;
- background-color: $white-dark;
- box-shadow: $gl-btn-active-gradient;
}
}
@@ -131,74 +122,89 @@
}
}
}
-
- &.select2-container-active .select2-choices,
- &.select2-dropdown-open .select2-choices {
- border-color: $border-white-normal;
- box-shadow: $gl-btn-active-gradient;
- }
}
.select2-drop-active {
- margin-top: 6px;
+ margin-top: $dropdown-vertical-offset;
font-size: 14px;
- &.select2-drop-above {
- margin-bottom: 8px;
- }
-
.select2-results {
max-height: 350px;
-
- .select2-highlighted {
- background: $gl-primary;
- }
}
}
.select2-search {
- padding: 15px 15px 5px;
+ padding: $grid-size;
.select2-drop-auto-width & {
- padding: 15px 15px 5px;
+ padding: $grid-size;
}
-}
-.select2-search input {
- padding: 2px 25px 2px 5px;
- background: $white-light image-url('select2.png');
- background-repeat: no-repeat;
- background-position: right 0 bottom 6px;
- border: 1px solid $input-border;
- border-radius: $border-radius-default;
- transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-
- &:focus {
- border-color: $input-border-focus;
- }
-}
+ input {
+ padding: $grid-size;
+ background: $white-light image-url('select2.png');
+ background-clip: content-box;
+ background-origin: content-box;
+ background-repeat: no-repeat;
+ background-position: right 0 bottom 0 !important;
+ border: 1px solid $input-border;
+ border-radius: $border-radius-default;
+ line-height: 16px;
+ transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-.select2-search input.select2-active {
- background-color: $white-light;
- background-image: image-url('select2-spinner.gif') !important;
- background-repeat: no-repeat;
- background-position: right 5px center !important;
- background-size: 16px 16px !important;
-}
+ &:focus {
+ border-color: $input-border-focus;
+ }
-.select2-results .select2-no-results,
-.select2-results .select2-searching,
-.select2-results .select2-ajax-error,
-.select2-results .select2-selection-limit {
- background: $gray-light;
- display: list-item;
- padding: 10px 15px;
-}
+ &.select2-active {
+ background-color: $white-light;
+ background-image: image-url('select2-spinner.gif') !important;
+ background-origin: content-box;
+ background-repeat: no-repeat;
+ background-position: right 6px center !important;
+ background-size: 16px 16px !important;
+ }
+ }
+ + .select2-results {
+ padding-top: 0;
+ }
+}
.select2-results {
margin: 0;
- padding: 10px 0;
+ padding: #{$gl-padding / 2} 0;
+
+ .select2-no-results,
+ .select2-searching,
+ .select2-ajax-error,
+ .select2-selection-limit {
+ background: transparent;
+ padding: #{$gl-padding / 2} $gl-padding;
+ }
+
+ .select2-result-label,
+ .select2-more-results {
+ padding: #{$gl-padding / 2} $gl-padding;
+ }
+
+ .select2-highlighted {
+ background: transparent;
+ color: $gl-text-color;
+
+ .select2-result-label {
+ background: $dropdown-item-hover-bg;
+ }
+ }
+
+ .select2-result {
+ padding: 0 1px;
+ }
+
+ li.select2-result-with-children > .select2-result-label {
+ font-weight: $gl-font-weight-bold;
+ color: $gl-text-color;
+ }
}
.ajax-users-select {
@@ -227,7 +233,7 @@
}
.group-name {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
.group-path {
@@ -252,15 +258,23 @@
.namespace-result {
.namespace-kind {
color: $namespace-kind-color;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.namespace-path {
margin-left: 10px;
- font-weight: bolder;
+ font-weight: $gl-font-weight-bold;
}
}
.ajax-users-dropdown {
min-width: 250px !important;
}
+
+.select2-result-selectable,
+.select2-result-unselectable {
+ .select2-match {
+ font-weight: $gl-font-weight-bold;
+ text-decoration: none;
+ }
+}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 5cf9330b8f8..3dd4a613789 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -3,13 +3,12 @@
transition: padding $sidebar-transition-duration;
.container-fluid {
- background: $white-light;
padding: 0 $gl-padding;
&.container-blank {
background: none;
padding: 0;
- border: none;
+ border: 0;
}
}
}
@@ -43,11 +42,18 @@
}
.sidebar-collapsed-icon {
- cursor: pointer;
-
.btn {
background-color: $gray-light;
}
+
+ &:not(.disabled) {
+ cursor: pointer;
+ }
+
+ svg {
+ width: $gl-padding;
+ height: $gl-padding;
+ }
}
}
@@ -55,6 +61,10 @@
padding-right: 0;
z-index: 300;
+ .btn-sidebar-action {
+ display: inline-flex;
+ }
+
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
@@ -78,21 +88,16 @@
.right-sidebar {
border-left: 1px solid $border-color;
+ height: calc(100% - #{$header-height});
+}
- &.affix {
- position: fixed;
- top: $header-height;
- }
-
- &:not(.affix-top) {
- min-height: 100%;
- }
+.with-performance-bar .right-sidebar.affix {
+ top: $header-height + $performance-bar-height;
}
@mixin maintain-sidebar-dimensions {
display: block;
width: $gutter-width;
- padding: 10px 20px;
}
.issues-bulk-update.right-sidebar {
@@ -104,6 +109,15 @@
&.right-sidebar-expanded {
@include maintain-sidebar-dimensions;
width: $gutter-width;
+
+ .issuable-sidebar-header {
+ // matches `.top-area .nav-controls` for issuable index pages
+ padding: 11px 0;
+ }
+
+ .block:last-of-type {
+ border: 0;
+ }
}
&.right-sidebar-collapsed {
@@ -123,3 +137,19 @@
width: calc(100% + 35px);
}
}
+
+.pikaday-container {
+ .pika-single {
+ margin-top: 2px;
+ width: 250px;
+ }
+
+ .dropdown-menu-toggle {
+ line-height: 20px;
+ }
+}
+
+.sidebar-collapsed-icon .sidebar-collapsed-value {
+ font-size: 12px;
+}
+
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 5f7e1b17cc7..30c15c231d5 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -30,7 +30,7 @@
.snippet-title {
font-size: 24px;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.snippet-edited-ago {
diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss
new file mode 100644
index 00000000000..528ba53a48b
--- /dev/null
+++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss
@@ -0,0 +1,54 @@
+.stacked-progress-bar {
+ display: flex;
+ height: 16px;
+ border-radius: 10px;
+ overflow: hidden;
+ background-color: $theme-gray-100;
+
+ .status-unavailable,
+ .status-green,
+ .status-neutral,
+ .status-red, {
+ height: 100%;
+ min-width: 30px;
+ padding: 0 5px;
+ font-size: $tooltip-font-size;
+ font-weight: normal;
+ color: $white-light;
+ line-height: 16px;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+
+ .status-unavailable {
+ padding: 0 10px;
+ color: $theme-gray-700;
+ }
+
+ .status-green {
+ background-color: $green-500;
+
+ &:hover {
+ background-color: $green-600;
+ }
+ }
+
+ .status-neutral {
+ background-color: $theme-gray-200;
+ color: $gl-gray-dark;
+
+ &:hover {
+ background-color: $theme-gray-300;
+ }
+ }
+
+ .status-red {
+ background-color: $red-500;
+
+ &:hover {
+ background-color: $red-600;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 6d9fa74a030..5bde96caf42 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -32,8 +32,8 @@ table {
th {
background-color: $gray-light;
- font-weight: normal;
- border-bottom: none;
+ font-weight: $gl-font-weight-normal;
+ border-bottom: 0;
&.wide {
width: 55%;
diff --git a/app/assets/stylesheets/framework/tabs.scss b/app/assets/stylesheets/framework/tabs.scss
new file mode 100644
index 00000000000..c8ba14b7066
--- /dev/null
+++ b/app/assets/stylesheets/framework/tabs.scss
@@ -0,0 +1,35 @@
+.gitlab-tabs {
+ background: $gray-light;
+ border: 1px solid $border-color;
+
+ li {
+ width: 50%;
+
+ &:not(:last-child) {
+ border-right: 1px solid $border-color;
+ }
+
+ &.active {
+ background: $white-light;
+ }
+
+ a {
+ width: 100%;
+ text-align: center;
+ }
+ }
+}
+
+.gitlab-tab-content {
+ border: 1px solid $border-color;
+ border-top: 0;
+ margin-bottom: $gl-padding;
+
+ .tab-pane {
+ padding: $gl-padding;
+
+ &.no-padding {
+ padding: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 3d68a50f91f..373f35e71d8 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -17,15 +17,19 @@
.diff-file {
border: 1px solid $border-color;
- border-bottom: none;
margin: 0;
}
+
+ &.text-file .diff-file {
+ border-bottom: 0;
+ }
}
.timeline-entry {
border-color: $white-normal;
color: $gl-text-color;
border-bottom: 1px solid $border-white-light;
+ background: $white-light;
.timeline-entry-inner {
position: relative;
@@ -62,5 +66,5 @@
.discussion .timeline-entry {
margin: 0;
- border-right: none;
+ border-right: 0;
}
diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss
new file mode 100644
index 00000000000..0cd83df218f
--- /dev/null
+++ b/app/assets/stylesheets/framework/toggle.scss
@@ -0,0 +1,142 @@
+/**
+* Toggle button
+*
+* @usage
+* ### Active and Inactive text should be provided as data attributes:
+* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled">
+* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* </button>
+
+* ### Checked should have `is-checked` class
+* <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled">
+* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* </button>
+
+* ### Disabled should have `is-disabled` class
+* <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
+* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* </button>
+
+* ### Loading should have `is-loading` and an icon with `loading-icon` class
+* <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled">
+* <i class="fa fa-spinner fa-spin loading-icon"></i>
+* </button>
+*/
+.project-feature-toggle {
+ position: relative;
+ border: 0;
+ outline: 0;
+ display: block;
+ width: 50px;
+ height: 24px;
+ cursor: pointer;
+ user-select: none;
+ background: $feature-toggle-color-disabled;
+ border-radius: 12px;
+ padding: 3px;
+ transition: all .4s ease;
+
+ &::selection,
+ &::before::selection,
+ &::after::selection {
+ background: none;
+ }
+
+ .toggle-icon {
+ position: relative;
+ display: block;
+ left: 0;
+ border-radius: 9px;
+ background: $feature-toggle-color;
+ transition: all .2s ease;
+
+ &,
+ .toggle-icon-svg {
+ width: 18px;
+ height: 18px;
+ }
+
+ .toggle-icon-svg {
+ fill: $feature-toggle-color-disabled;
+ }
+
+ .toggle-status-checked {
+ display: none;
+ }
+
+ .toggle-status-unchecked {
+ display: inline;
+ }
+ }
+
+ .loading-icon {
+ display: none;
+ font-size: 12px;
+ color: $white-light;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+
+ &.is-loading {
+ .toggle-icon {
+ display: none;
+ }
+
+ .loading-icon {
+ display: block;
+
+ &::before {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+ }
+ }
+
+ &.is-checked {
+ background: $feature-toggle-color-enabled;
+
+ .toggle-icon {
+ left: calc(100% - 18px);
+
+ .toggle-icon-svg {
+ fill: $feature-toggle-color-enabled;
+ }
+
+ .toggle-status-checked {
+ display: inline;
+ }
+
+ .toggle-status-unchecked {
+ display: none;
+ }
+ }
+ }
+
+ &.is-disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ @media (max-width: $screen-xs-min) {
+ width: 50px;
+
+ &::before,
+ &.is-checked::before {
+ display: none;
+ }
+ }
+
+ @keyframes animate-enabled {
+ 0%, 35% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+
+ @keyframes animate-disabled {
+ 0%, 35% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+}
diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss
new file mode 100644
index 00000000000..98f28987a82
--- /dev/null
+++ b/app/assets/stylesheets/framework/tooltips.scss
@@ -0,0 +1,7 @@
+.tooltip-inner {
+ font-size: $tooltip-font-size;
+ border-radius: $border-radius-default;
+ line-height: 16px;
+ font-weight: $gl-font-weight-normal;
+ padding: 8px;
+}
diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss
index e54cc2866a7..1c6e2bf3074 100644
--- a/app/assets/stylesheets/framework/tw_bootstrap.scss
+++ b/app/assets/stylesheets/framework/tw_bootstrap.scss
@@ -103,7 +103,7 @@ summary {
padding: 4px 5px;
font-size: 12px;
font-style: normal;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
display: inline-block;
&.label-gray {
@@ -165,7 +165,7 @@ summary {
.panel-heading {
padding: 6px 15px;
font-size: 13px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
a {
color: $panel-heading-link-color;
@@ -195,33 +195,6 @@ summary {
}
}
-// Typography =================================================================
-
-.text-primary,
-.text-primary:hover {
- color: $brand-primary;
-}
-
-.text-success,
-.text-success:hover {
- color: $brand-success;
-}
-
-.text-danger,
-.text-danger:hover {
- color: $brand-danger;
-}
-
-.text-warning,
-.text-warning:hover {
- color: $brand-warning;
-}
-
-.text-info,
-.text-info:hover {
- color: $brand-info;
-}
-
// Prevent datetimes on tooltips to break into two lines
.local-timeago {
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
index b666223b120..d04e555769b 100644
--- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
+++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
@@ -137,7 +137,7 @@ $well-border: #eee;
//##
$code-color: $red-600;
-$code-bg: lighten($red-50, 2%);
+$code-bg: lighten($red-100, 2%);
$kbd-color: $white-light;
$kbd-bg: #333;
@@ -162,3 +162,38 @@ $pre-color: $gl-text-color !default;
$pre-border-color: $border-color;
$table-bg-accent: $gray-light;
+
+$zindex-popover: 900;
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+$modal-inner-padding: $gl-padding;
+
+//** Padding applied to the modal title
+$modal-title-padding: $gl-padding;
+//** Modal title line-height
+// $modal-title-line-height: $line-height-base
+
+//** Background color of modal content area
+$modal-content-bg: $gray-light;
+$modal-body-bg: $white-light;
+//** Modal content border color
+// $modal-content-border-color: rgba(0,0,0,.2)
+//** Modal content border color **for IE8**
+// $modal-content-fallback-border-color: #999
+
+//** Modal backdrop background color
+// $modal-backdrop-bg: #000
+//** Modal backdrop opacity
+// $modal-backdrop-opacity: .5
+//** Modal header border color
+// $modal-header-border-color: #e5e5e5
+//** Modal footer border color
+// $modal-footer-border-color: $modal-header-border-color
+
+$modal-lg: 860px;
+$modal-md: 540px;
+// $modal-sm: 300px
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 785b09e622f..294c59f037f 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -2,15 +2,32 @@
color: $gl-text-color;
word-wrap: break-word;
+ [dir="auto"] {
+ text-align: initial;
+ }
+
a {
color: $md-link-color;
}
- img {
- max-width: 100%;
+ img:not(.emoji) {
margin: 0 0 8px;
}
+ img.lazy {
+ min-width: 200px;
+ min-height: 100px;
+ background-color: $gray-lightest;
+ }
+
+ img.js-lazy-loaded,
+ img.emoji {
+ min-width: inherit;
+ min-height: inherit;
+ background-color: inherit;
+ max-width: 100%;
+ }
+
p a:not(.no-attachment-icon) img {
// Remove bottom padding because
// <p> already has $gl-padding bottom
@@ -57,7 +74,7 @@
h1 {
font-size: 1.75em;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
margin: 24px 0 16px;
padding-bottom: 0.3em;
border-bottom: 1px solid $white-dark;
@@ -70,7 +87,7 @@
h2 {
font-size: 1.5em;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
margin: 24px 0 16px;
padding-bottom: 0.3em;
border-bottom: 1px solid $white-dark;
@@ -112,9 +129,12 @@
blockquote p {
color: $gl-grayish-blue !important;
- margin: 0;
font-size: inherit;
line-height: 1.5;
+
+ &:last-child {
+ margin: 0;
+ }
}
p {
@@ -147,7 +167,7 @@
&.plain-readme {
background: none;
- border: none;
+ border: 0;
padding: 0;
margin: 0;
font-size: 14px;
@@ -158,6 +178,10 @@
font-weight: inherit;
}
+ dd {
+ margin-left: $gl-padding;
+ }
+
ul,
ol {
padding: 0;
@@ -260,7 +284,7 @@ body {
margin-top: $gl-padding;
line-height: 1.3;
font-size: 1.25em;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
&:last-child {
margin-bottom: 0;
@@ -271,8 +295,8 @@ body {
margin-top: 0;
line-height: 1.3;
font-size: 1.25em;
- font-weight: 600;
- margin: 12px 7px;
+ font-weight: $gl-font-weight-bold;
+ margin: 12px 0;
}
h1,
@@ -282,11 +306,11 @@ h4,
h5,
h6 {
color: $gl-text-color;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.light-header {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
/** CODE **/
@@ -309,6 +333,10 @@ a > code {
font-family: $monospace_font;
}
+.weight-normal {
+ font-weight: $gl-font-weight-normal;
+}
+
.commit-sha,
.ref-name {
@extend .monospace;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 630f557602c..a81904d5338 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -1,11 +1,15 @@
/*
* Layout
*/
+$grid-size: 8px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
$gutter_inner_width: 250px;
-$sidebar-transition-duration: .15s;
+$sidebar-transition-duration: 0.3s;
$sidebar-breakpoint: 1024px;
+$default-transition-duration: 0.15s;
+$contextual-sidebar-width: 220px;
+$contextual-sidebar-collapsed-width: 50px;
/*
* Color schema
@@ -13,6 +17,7 @@ $sidebar-breakpoint: 1024px;
$darken-normal-factor: 7%;
$darken-dark-factor: 10%;
$darken-border-factor: 5%;
+$darken-border-dashed-factor: 25%;
$white-light: #fff;
$white-normal: #f0f0f0;
@@ -26,46 +31,45 @@ $gray-dark: darken($gray-light, $darken-dark-factor);
$gray-darker: #eee;
$gray-darkest: #c4c4c4;
-$green-25: #f6fcf8;
-$green-50: #e4f5eb;
-$green-100: #bae6cc;
-$green-200: #8dd5aa;
-$green-300: #5fc488;
-$green-400: #3cb76f;
+$green-50: #f1fdf6;
+$green-100: #dcf5e7;
+$green-200: #b3e6c8;
+$green-300: #75d09b;
+$green-400: #37b96d;
$green-500: #1aaa55;
$green-600: #168f48;
$green-700: #12753a;
$green-800: #0e5a2d;
$green-900: #0a4020;
+$green-950: #072b15;
-$blue-25: #f6fafd;
-$blue-50: #e4eff9;
-$blue-100: #bcd7f1;
-$blue-200: #8fbce8;
-$blue-300: #62a1df;
-$blue-400: #418cd8;
+$blue-50: #f6fafe;
+$blue-100: #e4f0fb;
+$blue-200: #b8d6f4;
+$blue-300: #73afea;
+$blue-400: #2e87e0;
$blue-500: #1f78d1;
$blue-600: #1b69b6;
$blue-700: #17599c;
$blue-800: #134a81;
$blue-900: #0f3b66;
+$blue-950: #0a2744;
-$orange-25: #fffcf8;
-$orange-50: #fff2e1;
-$orange-100: #fedfb3;
-$orange-200: #feca81;
-$orange-300: #fdb44f;
-$orange-400: #fca429;
+$orange-50: #fffaf4;
+$orange-100: #fff1de;
+$orange-200: #fed69f;
+$orange-300: #fdbc60;
+$orange-400: #fca121;
$orange-500: #fc9403;
$orange-600: #de7e00;
$orange-700: #c26700;
-$orange-800: #a35100;
-$orange-900: #853b00;
+$orange-800: #a35200;
+$orange-900: #853c00;
+$orange-950: #592800;
-$red-25: #fef7f6;
-$red-50: #fbe7e4;
-$red-100: #f4c4bc;
-$red-200: #ed9d90;
+$red-50: #fef6f5;
+$red-100: #fbe5e1;
+$red-200: #f2b4a9;
$red-300: #e67664;
$red-400: #e05842;
$red-500: #db3b21;
@@ -73,19 +77,68 @@ $red-600: #c0341d;
$red-700: #a62d19;
$red-800: #8b2615;
$red-900: #711e11;
-
-$purple-700: #4a2192;
-$purple-800: #2c0a5c;
-$purple-900: #380d75;
+$red-950: #4b140b;
+
+// GitLab themes
+
+$indigo-50: #f7f7ff;
+$indigo-100: #ebebfa;
+$indigo-200: #d1d1f0;
+$indigo-300: #a6a6de;
+$indigo-400: #7c7ccc;
+$indigo-500: #6666c4;
+$indigo-600: #5b5bbd;
+$indigo-700: #4b4ba3;
+$indigo-800: #393982;
+$indigo-900: #292961;
+$indigo-950: #1a1a40;
+
+$theme-gray-50: #fafafa;
+$theme-gray-100: #f2f2f2;
+$theme-gray-200: #dfdfdf;
+$theme-gray-300: #cccccc;
+$theme-gray-400: #bababa;
+$theme-gray-500: #a7a7a7;
+$theme-gray-600: #949494;
+$theme-gray-700: #707070;
+$theme-gray-800: #4f4f4f;
+$theme-gray-900: #2e2e2e;
+$theme-gray-950: #1f1f1f;
+
+$theme-blue-50: #f4f8fc;
+$theme-blue-100: #e6edf5;
+$theme-blue-200: #c8d7e6;
+$theme-blue-300: #97b3cf;
+$theme-blue-400: #648cb4;
+$theme-blue-500: #4a79a8;
+$theme-blue-600: #3e6fa0;
+$theme-blue-700: #305c88;
+$theme-blue-800: #25496e;
+$theme-blue-900: #1a3652;
+$theme-blue-950: #0f2235;
+
+$theme-green-50: #f2faf6;
+$theme-green-100: #e4f3ea;
+$theme-green-200: #c0dfcd;
+$theme-green-300: #8ac2a1;
+$theme-green-400: #52a274;
+$theme-green-500: #35935c;
+$theme-green-600: #288a50;
+$theme-green-700: #1c7441;
+$theme-green-800: #145d33;
+$theme-green-900: #0d4524;
+$theme-green-950: #072d16;
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
+$almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor);
$border-white-normal: darken($white-normal, $darken-border-factor);
$border-gray-light: darken($gray-light, $darken-border-factor);
$border-gray-normal: darken($gray-normal, $darken-border-factor);
+$border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
$border-gray-dark: darken($white-normal, $darken-border-factor);
/*
@@ -102,12 +155,17 @@ $well-light-text-color: #5b6169;
* Text
*/
$gl-font-size: 14px;
-$gl-text-color: rgba(0, 0, 0, .85);
-$gl-text-color-secondary: rgba(0, 0, 0, .55);
-$gl-text-color-disabled: rgba(0, 0, 0, .35);
-$gl-text-color-inverted: rgba(255, 255, 255, 1.0);
-$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
+$gl-font-weight-normal: 400;
+$gl-font-weight-bold: 600;
+$gl-text-color: #2e2e2e;
+$gl-text-color-secondary: #707070;
+$gl-text-color-tertiary: #949494;
+$gl-text-color-quaternary: #d6d6d6;
+$gl-text-color-inverted: rgba(255, 255, 255, 1);
+$gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85);
+$gl-text-color-disabled: #919191;
$gl-text-green: $green-600;
+$gl-text-green-hover: $green-700;
$gl-text-red: $red-500;
$gl-text-orange: $orange-600;
$gl-link-color: $blue-600;
@@ -118,7 +176,7 @@ $gl-gray-dark: #313236;
$gl-gray-light: #5c5c5c;
$gl-header-color: #4c4e54;
$gl-header-nav-hover-color: #434343;
-$placeholder-text-color: rgba(0, 0, 0, .42);
+$placeholder-text-color: $gl-text-color-tertiary;
/*
* Lists
@@ -126,12 +184,12 @@ $placeholder-text-color: rgba(0, 0, 0, .42);
$list-font-size: $gl-font-size;
$list-title-color: $gl-text-color;
$list-text-color: $gl-text-color;
-$list-text-disabled-color: $gl-text-color-disabled;
+$list-text-disabled-color: $gl-text-color-tertiary;
$list-border-light: #eee;
$list-border: rgba(0, 0, 0, 0.05);
$list-text-height: 42px;
-$list-warning-row-bg: $orange-50;
-$list-warning-row-border: $orange-100;
+$list-warning-row-bg: $orange-100;
+$list-warning-row-border: $orange-200;
$list-warning-row-color: $orange-700;
/*
@@ -147,28 +205,38 @@ $code_font_size: 12px;
$code_line_height: 1.6;
/*
+ * Tooltips
+ */
+$tooltip-font-size: 12px;
+
+/*
* Padding
*/
$gl-padding: 16px;
-$gl-btn-padding: 10px;
+$gl-padding-8: 8px;
+$gl-padding-4: 4px;
+$gl-col-padding: 15px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
$gl-padding-top: 10px;
$gl-sidebar-padding: 22px;
+$gl-bar-padding: 3px;
/*
* Misc
*/
-$row-hover: $blue-25;
-$row-hover-border: $blue-100;
+$row-hover: $blue-50;
+$row-hover-border: $blue-200;
$progress-color: #c0392b;
-$header-height: 50px;
+$header-height: 40px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$limited-layout-width-sm: 790px;
+$container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
-$border-radius-default: 3px;
+$border-radius-default: 4px;
+$border-radius-small: 2px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
@@ -179,9 +247,6 @@ $btn-sm-side-margin: 7px;
$btn-xs-side-margin: 5px;
$issue-status-expired: $orange-500;
$issuable-sidebar-color: $gl-text-color-secondary;
-$show-aside-bg: #eee;
-$show-aside-color: #777;
-$show-aside-shadow: #ddd;
$group-path-color: #999;
$namespace-kind-color: #aaa;
$panel-heading-link-color: #777;
@@ -193,7 +258,10 @@ $divergence-graph-separator-bg: #ccc;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
$highlight-changes-color: rgb(235, 255, 232);
-
+$performance-bar-height: 35px;
+$flash-height: 52px;
+$context-header-height: 60px;
+$breadcrumb-min-height: 48px;
/*
* Common component specific colors
@@ -203,13 +271,14 @@ $well-pre-bg: #eee;
$well-pre-color: #555;
$loading-color: #555;
$update-author-color: #999;
-$user-mention-color: #2fa0bb;
+$user-mention-bg: rgba($blue-500, 0.044);
+$user-mention-bg-hover: rgba($blue-500, 0.15);
$time-color: #999;
$project-member-show-color: #aaa;
$gl-promo-color: #aaa;
$error-bg: $red-400;
-$warning-message-bg: $orange-50;
-$warning-message-border: $orange-100;
+$warning-message-bg: $orange-100;
+$warning-message-border: $orange-200;
$warning-message-color: $orange-700;
$control-group-descr-color: #666;
$table-permission-x-bg: #d9edf7;
@@ -227,7 +296,7 @@ $tanuki-yellow: #fca326;
*/
$gl-primary: $blue-500;
$gl-success: $green-500;
-$gl-success-focus: rgba($gl-success, .4);
+$gl-success-focus: rgba($gl-success, 0.4);
$gl-info: $blue-500;
$gl-warning: $orange-500;
$gl-danger: $red-500;
@@ -257,32 +326,40 @@ $diff-image-info-color: grey;
$diff-swipe-border: #999;
$diff-view-modes-color: grey;
$diff-view-modes-border: #c1c1c1;
+$diff-jagged-border-gradient-color: darken($white-normal, 8%);
/*
* Fonts
*/
-$monospace_font: 'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
-$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas',
+ 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
+$regular_font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif,
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
/*
* Dropdowns
*/
$dropdown-width: 300px;
+$dropdown-min-height: 40px;
+$dropdown-max-height: 312px;
+$dropdown-vertical-offset: 4px;
$dropdown-link-color: #555;
$dropdown-link-hover-bg: $row-hover;
-$dropdown-empty-row-bg: rgba(#000, .04);
+$dropdown-empty-row-bg: rgba(#000, 0.04);
$dropdown-border-color: $border-color;
-$dropdown-shadow-color: rgba(#000, .1);
-$dropdown-divider-color: rgba(#000, .1);
+$dropdown-shadow-color: rgba(#000, 0.1);
+$dropdown-divider-color: rgba(#000, 0.1);
$dropdown-title-btn-color: #bfbfbf;
$dropdown-input-color: #555;
$dropdown-input-fa-color: #c7c7c7;
$dropdown-input-focus-border: $focus-border-color;
-$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, .4);
-$dropdown-loading-bg: rgba(#fff, .6);
+$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, 0.4);
+$dropdown-loading-bg: rgba(#fff, 0.6);
$dropdown-chevron-size: 10px;
$dropdown-toggle-active-border-color: darken($border-color, 14%);
-
+$dropdown-item-hover-bg: $gray-darker;
+$dropdown-fade-mask-height: 32px;
/*
* Filtered Search
@@ -291,11 +368,22 @@ $filtered-search-term-shadow-color: rgba(0, 0, 0, 0.09);
$dropdown-hover-color: $blue-400;
/*
+* Contextual Sidebar
+*/
+$link-active-background: rgba(0, 0, 0, 0.04);
+$link-hover-background: rgba(0, 0, 0, 0.06);
+$inactive-badge-background: rgba(0, 0, 0, 0.08);
+
+/*
* Buttons
*/
$btn-active-gray: #ececec;
$btn-active-gray-light: e4e7ed;
$btn-white-active: #848484;
+$gl-btn-padding: 10px;
+$gl-btn-line-height: 16px;
+$gl-btn-vert-padding: 8px;
+$gl-btn-horz-padding: 12px;
/*
* Badges
@@ -304,16 +392,22 @@ $badge-bg: rgba(0, 0, 0, 0.07);
$badge-color: $gl-text-color-secondary;
/*
+ * Status icons
+ */
+$status-icon-size: 22px;
+$status-icon-margin: $gl-btn-padding;
+
+/*
* Award emoji
*/
-$award-emoji-menu-shadow: rgba(0,0,0,.175);
+$award-emoji-menu-shadow: rgba(0, 0, 0, 0.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
/*
* Search Box
*/
-$search-input-border-color: rgba($blue-400, .8);
+$search-input-border-color: rgba($blue-400, 0.8);
$search-input-focus-shadow-color: $dropdown-input-focus-shadow;
$search-input-width: 220px;
$location-badge-active-bg: $blue-500;
@@ -323,14 +417,12 @@ $location-icon-color: #e7e9ed;
* Notes
*/
$notes-light-color: $gl-text-color-secondary;
-$notes-role-color: $gl-text-color-secondary;
$note-disabled-comment-color: #b2b2b2;
$note-targe3-outside: #fffff0;
$note-targe3-inside: #ffffd3;
$note-line2-border: #ddd;
$note-icon-gutter-width: 55px;
-
/*
* Zen
*/
@@ -340,7 +432,7 @@ $zen-control-color: #555;
* Calendar
*/
$calendar-hover-bg: #ecf3fe;
-$calendar-border-color: rgba(#000, .1);
+$calendar-border-color: rgba(#000, 0.1);
$calendar-user-contrib-text: #959494;
/*
@@ -363,12 +455,25 @@ $ci-skipped-color: #888;
*/
$issue-boards-font-size: 14px;
$issue-boards-card-shadow: rgba(186, 186, 186, 0.5);
+/*
+ The following heights are used in boards.scss and are used for calculation of the board height.
+ They probably should be derived in a smarter way.
+*/
+$issue-boards-filter-height: 68px;
+$issue-boards-breadcrumbs-height-xs: 63px;
+$issue-board-list-difference-xs: $header-height +
+ $issue-boards-breadcrumbs-height-xs;
+$issue-board-list-difference-sm: $header-height + $breadcrumb-min-height;
+$issue-board-list-difference-md: $issue-board-list-difference-sm +
+ $issue-boards-filter-height;
/*
* Avatar
*/
$avatar_radius: 50%;
-$avatar-border: rgba(0, 0, 0, .1);
+$avatar-border: $gray-normal;
+$avatar-border-hover: $gray-darker;
+$avatar-background: $gray-lightest;
$gl-avatar-size: 40px;
/*
@@ -386,24 +491,24 @@ $builds-trace-bg: #111;
/*
* Callout
*/
-$callout-danger-bg: $red-50;
-$callout-danger-border: $red-100;
+$callout-danger-bg: $red-100;
+$callout-danger-border: $red-200;
$callout-danger-color: $red-700;
-$callout-warning-bg: $orange-50;
-$callout-warning-border: $orange-100;
+$callout-warning-bg: $orange-100;
+$callout-warning-border: $orange-200;
$callout-warning-color: $orange-700;
-$callout-info-bg: $blue-50;
-$callout-info-border: $blue-100;
+$callout-info-bg: $blue-100;
+$callout-info-border: $blue-200;
$callout-info-color: $blue-700;
-$callout-success-bg: $green-50;
-$callout-success-border: $green-100;
+$callout-success-bg: $green-100;
+$callout-success-border: $green-200;
$callout-success-color: $green-700;
/*
* Commit Page
*/
-$commit-max-width-marker-color: rgba(0, 0, 0, 0.0);
-$commit-message-text-area-bg: rgba(0, 0, 0, 0.0);
+$commit-max-width-marker-color: rgba(0, 0, 0, 0);
+$commit-message-text-area-bg: rgba(0, 0, 0, 0);
/*
* Common
@@ -440,6 +545,7 @@ $logs-p-color: #333;
/*
* Forms
*/
+$input-height: 34px;
$input-danger-bg: #f2dede;
$input-danger-border: $red-400;
$input-group-addon-bg: #f7f8fa;
@@ -470,15 +576,20 @@ $jq-ui-default-color: #777;
/*
* Label
*/
+$label-font-size: 12px;
+$label-padding: 7px;
+$label-padding-modal: 10px;
$label-gray-bg: #f8fafc;
$label-inverse-bg: #333;
-$label-remove-border: rgba(0, 0, 0, .1);
+$label-remove-border: rgba(0, 0, 0, 0.1);
$label-border-radius: 100px;
/*
* Animation
*/
$fade-in-duration: 200ms;
+$fade-mask-transition-duration: 0.1s;
+$fade-mask-transition-curve: ease-in-out;
/*
* Lint
@@ -515,6 +626,11 @@ $project-breadcrumb-color: #999;
$project-private-forks-notice-odd: $green-600;
$project-network-controls-color: #888;
+$feature-toggle-color: #fff;
+$feature-toggle-text-color: #fff;
+$feature-toggle-color-disabled: #999;
+$feature-toggle-color-enabled: #4a8bee;
+
/*
* Runners
*/
@@ -540,7 +656,6 @@ $stat-graph-selection-stroke: #333;
$select2-drop-shadow1: rgba(76, 86, 103, 0.247059);
$select2-drop-shadow2: rgba(31, 37, 50, 0.317647);
-
/*
* Todo
*/
@@ -556,7 +671,7 @@ $kdb-color: #555;
$kdb-border: #ccc;
$kdb-border-bottom: #bbb;
$kdb-shadow: #bbb;
-$body-text-shadow: rgba(255,255,255,0.01);
+$body-text-shadow: rgba(255, 255, 255, 0.01);
/*
* UI Dev Kit
@@ -567,9 +682,15 @@ $ui-dev-kit-example-border: #ddd;
/*
Pipeline Graph
*/
-$stage-hover-bg: #eaf3fc;
-$stage-hover-border: #d1e7fc;
-$action-icon-color: #d6d6d6;
+$stage-hover-bg: $gray-darker;
+$ci-action-icon-size: 22px;
+$pipeline-dropdown-line-height: 20px;
+$pipeline-dropdown-status-icon-size: 18px;
+
+/*
+CI variable lists
+*/
+$ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
Filtered Search
@@ -591,3 +712,58 @@ Convdev Index
$color-high-score: $green-400;
$color-average-score: $orange-400;
$color-low-score: $red-400;
+
+/*
+Repo editor
+*/
+$repo-editor-grey: #f6f7f9;
+$repo-editor-grey-darker: #e9ebee;
+$repo-editor-linear-gradient: linear-gradient(
+ to right,
+ $repo-editor-grey 0%,
+ $repo-editor-grey-darker,
+ 20%,
+ $repo-editor-grey 40%,
+ $repo-editor-grey 100%
+);
+
+/*
+Performance Bar
+*/
+$perf-bar-text: #999;
+$perf-bar-production: #222;
+$perf-bar-staging: #291430;
+$perf-bar-development: #4c1210;
+$perf-bar-bucket-bg: #111;
+$perf-bar-bucket-color: #ccc;
+$perf-bar-bucket-box-shadow-from: rgba($white-light, 0.2);
+$perf-bar-bucket-box-shadow-to: rgba($black, 0.25);
+
+/*
+Issuable warning
+*/
+$issuable-warning-size: 24px;
+$issuable-warning-icon-margin: 4px;
+
+/*
+Image Commenting cursor
+*/
+$image-comment-cursor-left-offset: 12;
+$image-comment-cursor-top-offset: 12;
+
+/*
+Popup
+*/
+$popup-triangle-size: 15px;
+$popup-triangle-border-size: 1px;
+$popup-box-shadow-color: rgba(90, 90, 90, 0.05);
+
+/*
+Multi file editor
+*/
+$border-color-settings: #e1e1e1;
+
+/*
+Modals
+*/
+$modal-body-height: 134px;
diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss
new file mode 100644
index 00000000000..e07a177e153
--- /dev/null
+++ b/app/assets/stylesheets/framework/vue_transitions.scss
@@ -0,0 +1,9 @@
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity $sidebar-transition-duration $general-hover-transition-curve;
+}
+
+.fade-enter,
+.fade-leave-to {
+ opacity: 0;
+}
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 1c1392f8f67..2f3a80daa90 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -3,6 +3,7 @@
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+ margin-bottom: $gl-padding;
.well-segment {
padding: $gl-padding;
@@ -15,12 +16,26 @@
.commit-sha,
.commit-info {
margin-left: 4px;
+
+ .fork-svg {
+ margin-right: 4px;
+ }
}
.ref-name {
font-size: 12px;
+
+ &:hover {
+ text-decoration: underline;
+ color: $gl-text-color;
+ }
}
}
+
+ &.admin-well h4 {
+ border-bottom: 1px solid $border-color;
+ padding-bottom: 8px;
+ }
}
.icon-container {
@@ -46,6 +61,37 @@
.label.label-gray {
background-color: $well-expand-item;
}
+
+ .branches {
+ display: inline;
+ }
+
+ .branch-link {
+ margin-bottom: 2px;
+ }
+
+ .limit-box {
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ background-color: $red-100;
+ border-radius: $border-radius-default;
+ text-align: center;
+
+ &:hover {
+ background-color: $red-200;
+ }
+
+ .limit-icon {
+ margin: 0 4px;
+ }
+
+ .limit-message {
+ line-height: 16px;
+ margin-right: 8px;
+ font-size: 12px;
+ }
+ }
}
.light-well {
@@ -53,9 +99,17 @@
padding: 15px;
}
+.dark-well {
+ background-color: $gray-normal;
+
+ .btn {
+ width: 100%;
+ }
+}
+
.well-centered {
h1 {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
text-align: center;
font-size: 48px;
}
diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss
index 0c226ff7598..dbd3144b9b4 100644
--- a/app/assets/stylesheets/framework/zen.scss
+++ b/app/assets/stylesheets/framework/zen.scss
@@ -9,7 +9,7 @@
z-index: 1031;
textarea {
- border: none;
+ border: 0;
box-shadow: none;
border-radius: 0;
color: $black;
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss
index 6e3829d994f..f0ac9b46f91 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/dark.scss
@@ -204,11 +204,11 @@ $dark-il: #de935f;
.cs { color: $dark-cs; } /* Comment.Special */
.gd { color: $dark-gd; } /* Generic.Deleted */
.ge { font-style: italic; } /* Generic.Emph */
- .gh { color: $dark-gh; font-weight: bold; } /* Generic.Heading */
+ .gh { color: $dark-gh; font-weight: $gl-font-weight-bold; } /* Generic.Heading */
.gi { color: $dark-gi; } /* Generic.Inserted */
- .gp { color: $dark-gp; font-weight: bold; } /* Generic.Prompt */
- .gs { font-weight: bold; } /* Generic.Strong */
- .gu { color: $dark-gu; font-weight: bold; } /* Generic.Subheading */
+ .gp { color: $dark-gp; font-weight: $gl-font-weight-bold; } /* Generic.Prompt */
+ .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */
+ .gu { color: $dark-gu; font-weight: $gl-font-weight-bold; } /* Generic.Subheading */
.kc { color: $dark-kc; } /* Keyword.Constant */
.kd { color: $dark-kd; } /* Keyword.Declaration */
.kn { color: $dark-kn; } /* Keyword.Namespace */
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss
index 68eb0c7720f..eba7919ada9 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/monokai.scss
@@ -203,7 +203,7 @@ $monokai-gi: #a6e22e;
.c1 { color: $monokai-c1; } /* Comment.Single */
.cs { color: $monokai-cs; } /* Comment.Special */
.ge { font-style: italic; } /* Generic.Emph */
- .gs { font-weight: bold; } /* Generic.Strong */
+ .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */
.kc { color: $monokai-kc; } /* Keyword.Constant */
.kd { color: $monokai-kd; } /* Keyword.Declaration */
.kn { color: $monokai-kn; } /* Keyword.Namespace */
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss
index 2cc968c32f2..ba53ef0352b 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/solarized_dark.scss
@@ -231,7 +231,7 @@ $solarized-dark-il: #2aa198;
.gi { color: $solarized-dark-gi; } /* Generic.Inserted */
.go { color: $solarized-dark-go; } /* Generic.Output */
.gp { color: $solarized-dark-gp; } /* Generic.Prompt */
- .gs { color: $solarized-dark-gs; font-weight: bold; } /* Generic.Strong */
+ .gs { color: $solarized-dark-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */
.gu { color: $solarized-dark-gu; } /* Generic.Subheading */
.gt { color: $solarized-dark-gt; } /* Generic.Traceback */
.kc { color: $solarized-dark-kc; } /* Keyword.Constant */
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss
index b61b85a2cd1..e9fccf1b58a 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/solarized_light.scss
@@ -239,7 +239,7 @@ $solarized-light-il: #2aa198;
.gi { color: $solarized-light-gi; } /* Generic.Inserted */
.go { color: $solarized-light-go; } /* Generic.Output */
.gp { color: $solarized-light-gp; } /* Generic.Prompt */
- .gs { color: $solarized-light-gs; font-weight: bold; } /* Generic.Strong */
+ .gs { color: $solarized-light-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */
.gu { color: $solarized-light-gu; } /* Generic.Subheading */
.gt { color: $solarized-light-gt; } /* Generic.Traceback */
.kc { color: $solarized-light-kc; } /* Keyword.Constant */
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index 1daa10aef24..c3d8f0c61a2 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -113,7 +113,7 @@ $white-gc-bg: #eaf2f5;
border-color: $line-removed-dark;
a {
- color: scale-color($line-number-old,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
}
}
@@ -122,7 +122,7 @@ $white-gc-bg: #eaf2f5;
border-color: $line-added-dark;
a {
- color: scale-color($line-number-new,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
}
}
@@ -163,7 +163,7 @@ $white-gc-bg: #eaf2f5;
background-color: $line-removed;
&::before {
- color: scale-color($line-number-old,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
}
span.idiff {
@@ -175,7 +175,7 @@ $white-gc-bg: #eaf2f5;
background-color: $line-added;
&::before {
- color: scale-color($line-number-new,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
}
span.idiff {
@@ -211,44 +211,62 @@ $white-gc-bg: #eaf2f5;
.hll { background-color: $white-hll-bg; }
.c { color: $white-c; font-style: italic; }
.err { color: $white-err; background-color: $white-err-bg; }
- .k { font-weight: bold; }
- .o { font-weight: bold; }
+ .k { font-weight: $gl-font-weight-bold; }
+ .o { font-weight: $gl-font-weight-bold; }
.cm { color: $white-cm; font-style: italic; }
- .cp { color: $white-cp; font-weight: bold; }
+ .cp { color: $white-cp; font-weight: $gl-font-weight-bold; }
.c1 { color: $white-c1; font-style: italic; }
- .cs { color: $white-cs; font-weight: bold; font-style: italic; }
- .gd { color: $white-gd; background-color: $white-gd-bg; }
- .gd .x { color: $white-gd-x; background-color: $white-gd-x-bg; }
+ .cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; }
+
+ .gd {
+ color: $white-gd;
+ background-color: $white-gd-bg;
+
+ .x {
+ color: $white-gd-x;
+ background-color: $white-gd-x-bg;
+ }
+ }
+
.ge { font-style: italic; }
.gr { color: $white-gr; }
.gh { color: $white-gh; }
- .gi { color: $white-gi; background-color: $white-gi-bg; }
- .gi .x { color: $white-gi-x; background-color: $white-gi-x-bg; }
+
+ .gi {
+ color: $white-gi;
+ background-color: $white-gi-bg;
+
+ .x {
+ color: $white-gi-x;
+ background-color: $white-gi-x-bg;
+ }
+ }
+
.go { color: $white-go; }
.gp { color: $white-gp; }
- .gs { font-weight: bold; }
- .gu { color: $white-gu; font-weight: bold; }
+ .gs { font-weight: $gl-font-weight-bold; }
+ .gu { color: $white-gu; font-weight: $gl-font-weight-bold; }
.gt { color: $white-gt; }
- .kc { font-weight: bold; }
- .kd { font-weight: bold; }
- .kn { font-weight: bold; }
- .kp { font-weight: bold; }
- .kr { font-weight: bold; }
- .kt { color: $white-kt; font-weight: bold; }
+ .kc { font-weight: $gl-font-weight-bold; }
+ .kd { font-weight: $gl-font-weight-bold; }
+ .kn { font-weight: $gl-font-weight-bold; }
+ .kp { font-weight: $gl-font-weight-bold; }
+ .kr { font-weight: $gl-font-weight-bold; }
+ .kt { color: $white-kt; font-weight: $gl-font-weight-bold; }
.m { color: $white-m; }
.s { color: $white-s; }
.n { color: $white-n; }
.na { color: $white-na; }
.nb { color: $white-nb; }
- .nc { color: $white-nc; font-weight: bold; }
+ .nc { color: $white-nc; font-weight: $gl-font-weight-bold; }
.no { color: $white-no; }
.ni { color: $white-ni; }
- .ne { color: $white-ne; font-weight: bold; }
- .nf { color: $white-nf; font-weight: bold; }
+ .ne { color: $white-ne; font-weight: $gl-font-weight-bold; }
+ .nf { color: $white-nf; font-weight: $gl-font-weight-bold; }
.nn { color: $white-nn; }
.nt { color: $white-nt; }
.nv { color: $white-nv; }
- .ow { font-weight: bold; }
+ .ow { font-weight: $gl-font-weight-bold; }
.w { color: $white-w; }
.mf { color: $white-mf; }
.mh { color: $white-mh; }
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index ea40f449134..658ac26fca9 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -152,44 +152,62 @@ span.highlight_word {
.hll { background-color: $highlighted-hll-bg; }
.c { color: $highlighted-c; font-style: italic; }
.err { color: $highlighted-err; background-color: $highlighted-err-bg; }
-.k { font-weight: bold; }
-.o { font-weight: bold; }
+.k { font-weight: $gl-font-weight-bold; }
+.o { font-weight: $gl-font-weight-bold; }
.cm { color: $highlighted-cm; font-style: italic; }
-.cp { color: $highlighted-cp; font-weight: bold; }
+.cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; }
.c1 { color: $highlighted-c1; font-style: italic; }
-.cs { color: $highlighted-cs; font-weight: bold; font-style: italic; }
-.gd { color: $highlighted-gd; background-color: $highlighted-gd-bg; }
-.gd .x { color: $highlighted-gd; background-color: $highlighted-gd-x-bg; }
+.cs { color: $highlighted-cs; font-weight: $gl-font-weight-bold; font-style: italic; }
+
+.gd {
+ color: $highlighted-gd;
+ background-color: $highlighted-gd-bg;
+
+ .x {
+ color: $highlighted-gd;
+ background-color: $highlighted-gd-x-bg;
+ }
+}
+
.ge { font-style: italic; }
.gr { color: $highlighted-gr; }
.gh { color: $highlighted-gh; }
-.gi { color: $highlighted-gi; background-color: $highlighted-gi-bg; }
-.gi .x { color: $highlighted-gi; background-color: $highlighted-gi-x-bg; }
+
+.gi {
+ color: $highlighted-gi;
+ background-color: $highlighted-gi-bg;
+
+ .x {
+ color: $highlighted-gi;
+ background-color: $highlighted-gi-x-bg;
+ }
+}
+
.go { color: $highlighted-go; }
.gp { color: $highlighted-gp; }
-.gs { font-weight: bold; }
-.gu { color: $highlighted-gu; font-weight: bold; }
+.gs { font-weight: $gl-font-weight-bold; }
+.gu { color: $highlighted-gu; font-weight: $gl-font-weight-bold; }
.gt { color: $highlighted-gt; }
-.kc { font-weight: bold; }
-.kd { font-weight: bold; }
-.kn { font-weight: bold; }
-.kp { font-weight: bold; }
-.kr { font-weight: bold; }
-.kt { color: $highlighted-kt; font-weight: bold; }
+.kc { font-weight: $gl-font-weight-bold; }
+.kd { font-weight: $gl-font-weight-bold; }
+.kn { font-weight: $gl-font-weight-bold; }
+.kp { font-weight: $gl-font-weight-bold; }
+.kr { font-weight: $gl-font-weight-bold; }
+.kt { color: $highlighted-kt; font-weight: $gl-font-weight-bold; }
.m { color: $highlighted-m; }
.s { color: $highlighted-s; }
.n { color: $highlighted-n; }
.na { color: $highlighted-na; }
.nb { color: $highlighted-nb; }
-.nc { color: $highlighted-nc; font-weight: bold; }
+.nc { color: $highlighted-nc; font-weight: $gl-font-weight-bold; }
.no { color: $highlighted-no; }
.ni { color: $highlighted-ni; }
-.ne { color: $highlighted-ne; font-weight: bold; }
-.nf { color: $highlighted-nf; font-weight: bold; }
+.ne { color: $highlighted-ne; font-weight: $gl-font-weight-bold; }
+.nf { color: $highlighted-nf; font-weight: $gl-font-weight-bold; }
.nn { color: $highlighted-nn; }
.nt { color: $highlighted-nt; }
.nv { color: $highlighted-nv; }
-.ow { font-weight: bold; }
+.ow { font-weight: $gl-font-weight-bold; }
.w { color: $highlighted-w; }
.mf { color: $highlighted-mf; }
.mh { color: $highlighted-mh; }
diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss
deleted file mode 100644
index 441bfc479f6..00000000000
--- a/app/assets/stylesheets/new_nav.scss
+++ /dev/null
@@ -1,267 +0,0 @@
-@import "framework/variables";
-@import 'framework/tw_bootstrap_variables';
-@import "bootstrap/variables";
-
-header.navbar-gitlab-new {
- color: $white-light;
- background-color: $purple-900;
- border-bottom: 0;
-
- .header-content {
- padding-left: 0;
-
- .title-container {
- padding-top: 0;
- overflow: visible;
- }
-
- .title {
- display: block;
- height: 100%;
- padding-right: 0;
- color: currentColor;
-
- > a {
- display: flex;
- align-items: center;
- height: 100%;
- padding-top: 3px;
- padding-right: $gl-padding;
- padding-left: $gl-padding;
- margin-left: -$gl-padding;
- border-bottom: 3px solid transparent;
-
- @media (min-width: $screen-sm-min) {
- padding-right: $gl-padding;
- padding-left: $gl-padding;
- }
-
- svg {
- margin-top: -3px;
-
- @media (min-width: $screen-sm-min) {
- margin-right: 10px;
- }
- }
-
- &:hover,
- &:focus {
- color: currentColor;
- text-decoration: none;
- border-bottom-color: $white-light;
- }
- }
- }
-
- .dropdown.open {
- > a {
- border-bottom-color: $white-light;
- }
- }
-
- .dropdown-menu {
- margin-top: 4px;
- min-width: 130px;
-
- @media (max-width: $screen-xs-max) {
- left: auto;
- right: 0;
- }
- }
- }
-
- .navbar-collapse {
- padding-left: 0;
- color: $white-light;
- box-shadow: 0;
-
- @media (max-width: $screen-xs-max) {
- margin-left: -$gl-padding;
- margin-right: -10px;
- }
-
- .dropdown-bold-header {
- color: initial;
- }
-
- .nav {
- > li:not(.hidden-xs) a {
- @media (max-width: $screen-xs-max) {
- margin-left: 0;
- min-width: 100%;
- }
- }
- }
- }
-
- .container-fluid {
- .navbar-toggle {
- min-width: 45px;
- padding: 6px $gl-padding;
- margin-right: -7px;
- font-size: 14px;
- text-align: center;
- color: currentColor;
- border-left: 1px solid lighten($purple-700, 10%);
-
- &:hover,
- &:focus,
- &.active {
- color: currentColor;
- background-color: transparent;
- }
- }
-
- .navbar-nav {
- @media (max-width: $screen-xs-max) {
- display: flex;
- padding-right: 10px;
- }
-
- li {
- .badge {
- box-shadow: none;
- }
- }
- }
-
- .nav > li {
- &.header-user {
- @media (max-width: $screen-xs-max) {
- padding-left: 10px;
- }
- }
-
- > a {
- background: none;
- opacity: .9;
- will-change: opacity;
-
- &.header-user-dropdown-toggle {
- .header-user-avatar {
- border-color: $white-light;
- }
- }
-
- &:hover,
- &:focus {
- color: $white-light;
- opacity: 1;
-
- > svg {
- fill: $white-light;
- }
-
- &.header-user-dropdown-toggle {
- .header-user-avatar {
- border-color: $white-light;
- }
- }
- }
- }
- }
- }
-}
-
-.navbar-sub-nav {
- display: flex;
- margin-bottom: 0;
- color: $white-light;
-
- > li {
- &.active > a,
- a:hover,
- a:focus {
- border-bottom-color: $white-light;
- text-decoration: none;
- outline: 0;
- opacity: 1;
- }
-
- > a {
- display: block;
- padding: 16px 10px 13px;
- font-size: 13px;
- color: currentColor;
- border-bottom: 3px solid transparent;
- opacity: .9;
- will-change: opacity;
-
- @media (min-width: $screen-sm-min) {
- padding: 15px $gl-padding 12px;
- font-size: 14px;
- }
- }
- }
-
- .dropdown-chevron {
- position: relative;
- top: -1px;
- font-size: 10px;
- }
-}
-
-.header-user .dropdown-menu-nav,
-.header-new .dropdown-menu-nav {
- margin-top: 4px;
-}
-
-.search {
- form {
- border-color: $purple-800;
-
- &:hover {
- border-color: rgba($white-light, .6);
- box-shadow: none;
- }
- }
-
- &.search-active form {
- border-color: $white-light;
- }
-
- form,
- .search-input {
- background-color: $purple-700;
- }
-
- .search-input {
- color: $white-light;
- }
-
- .search-input::placeholder {
- color: rgba($white-light, .6);
- }
-
- .location-badge {
- font-size: 12px;
- color: rgba($white-light, .6);
- background-color: $purple-800;
- transition: color 0.15s;
- will-change: color;
- }
-
- .search-input-wrap {
- .search-icon,
- .clear-icon {
- color: rgba($white-light, .6);
- }
- }
-
- &.search-active {
- .location-badge {
- color: $white-light;
- background-color: $purple-800;
- }
-
- .search-input-wrap {
- .search-icon {
- color: rgba($white-light, .6);
- }
-
- .clear-icon {
- color: $white-light;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss
new file mode 100644
index 00000000000..6c555aee20a
--- /dev/null
+++ b/app/assets/stylesheets/pages/admin.scss
@@ -0,0 +1,6 @@
+.info-well {
+ .admin-well-statistics,
+ .admin-well-features {
+ padding-bottom: 46px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 85109fec91a..318d3ddaece 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -1,4 +1,4 @@
-@import "./issues/issue_count_badge";
+@import './issues/issue_count_badge';
[v-cloak] {
display: none;
@@ -11,7 +11,7 @@
.is-dragging {
// Important because plugin sets inline CSS
- opacity: 1!important;
+ opacity: 1 !important;
* {
-webkit-user-select: none;
@@ -19,8 +19,8 @@
-ms-user-select: none;
user-select: none;
// !important to make sure no style can override this when dragging
- cursor: -webkit-grabbing!important;
- cursor: grabbing!important;
+ cursor: -webkit-grabbing !important;
+ cursor: grabbing !important;
}
}
@@ -31,8 +31,12 @@
.dropdown-menu-issues-board-new {
width: 320px;
+ .open & {
+ max-height: 400px;
+ }
+
.dropdown-content {
- max-height: 150px;
+ max-height: 162px;
}
}
@@ -55,6 +59,15 @@
.boards-app {
position: relative;
+
+ @media (min-width: $screen-sm-min) {
+ transition: width $sidebar-transition-duration;
+ width: 100%;
+
+ &.is-compact {
+ width: calc(100% - #{$gutter_width});
+ }
+ }
}
.boards-app-loading {
@@ -63,25 +76,36 @@
}
.boards-list {
- height: calc(100vh - 152px);
+ height: calc(100vh - #{$issue-board-list-difference-xs});
width: 100%;
- padding-top: 25px;
- padding-bottom: 25px;
- padding-right: ($gl-padding / 2);
- padding-left: ($gl-padding / 2);
+ padding: $gl-padding ($gl-padding / 2);
overflow-x: scroll;
white-space: nowrap;
+ min-height: 200px;
- @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;
+ @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
+ height: calc(100vh - #{$issue-board-list-difference-sm});
+ }
- &.is-compact {
- width: calc(100% - 290px);
+ @media (min-width: $screen-md-min) {
+ height: calc(100vh - #{$issue-board-list-difference-md});
+ }
+
+ .with-performance-bar & {
+ height: calc(
+ 100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}
+ );
+
+ @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
+ height: calc(
+ 100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height}
+ );
+ }
+
+ @media (min-width: $screen-md-min) {
+ height: calc(
+ 100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}
+ );
}
}
}
@@ -117,13 +141,12 @@
}
.board-title {
- position: initial;
padding: 0;
border-bottom: 0;
> span {
display: block;
- transform: rotate(90deg) translate(25px, 0);
+ transform: rotate(90deg) translate(35px, 10px);
}
}
@@ -151,11 +174,18 @@
}
.board-header {
- border-top-left-radius: $border-radius-default;
- border-top-right-radius: $border-radius-default;
+ position: relative;
- &.has-border {
+ &.has-border::before {
border-top: 3px solid;
+ border-color: inherit;
+ border-top-left-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+ content: '';
+ position: absolute;
+ width: calc(100% + 2px);
+ top: 0;
+ left: 0;
margin-top: -1px;
margin-right: -1px;
margin-left: -1px;
@@ -165,6 +195,7 @@
.board-title {
padding-top: ($gl-padding - 3px);
+ padding-bottom: $gl-padding;
}
}
}
@@ -175,11 +206,16 @@
}
.board-title {
- position: relative;
margin: 0;
- padding: $gl-padding;
+ padding: 12px $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
+ display: flex;
+ align-items: center;
+}
+
+.board-title-text {
+ margin-right: auto;
}
.board-delete {
@@ -219,43 +255,10 @@
}
}
-.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;
+ position: relative;
}
.board-list {
@@ -427,16 +430,11 @@
}
.board-new-issue-form {
- z-index: 1;
+ z-index: 4;
margin: 5px;
}
-.page-with-layout-nav.page-with-sub-nav .issue-boards-sidebar {
- &.right-sidebar {
- top: 0;
- bottom: 0;
- }
-
+.page-with-contextual-sidebar.layout-page .issue-boards-sidebar {
.issuable-sidebar-header {
position: relative;
}
@@ -466,7 +464,7 @@
padding-right: 35px;
> strong {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
}
@@ -474,8 +472,8 @@
.right-sidebar.right-sidebar-expanded {
&.boards-sidebar-slide-enter-active,
&.boards-sidebar-slide-leave-active {
- transition: width .2s,
- padding .2s;
+ transition: width $sidebar-transition-duration,
+ padding $sidebar-transition-duration;
}
&.boards-sidebar-slide-enter,
@@ -494,7 +492,7 @@
right: 0;
bottom: 0;
left: 0;
- background-color: rgba($black, .3);
+ background-color: rgba($black, 0.3);
z-index: 9999;
}
@@ -511,7 +509,7 @@
padding: 25px 15px 0;
background-color: $white-light;
border-radius: $border-radius-default;
- box-shadow: 0 2px 12px rgba($black, .5);
+ box-shadow: 0 2px 12px rgba($black, 0.5);
.empty-state {
display: -webkit-flex;
@@ -589,7 +587,7 @@
.card {
border: 1px solid $border-gray-dark;
- box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, .3);
+ box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3);
cursor: pointer;
}
}
diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss
index 3e2fa8ca88d..49fe50977f5 100644
--- a/app/assets/stylesheets/pages/branches.scss
+++ b/app/assets/stylesheets/pages/branches.scss
@@ -1,6 +1,17 @@
+.content-list > .branch-item,
+.branch-title {
+ display: flex;
+ align-items: center;
+}
+
+.branch-info {
+ flex: auto;
+ min-width: 0;
+ overflow: hidden;
+}
+
.divergence-graph {
- padding: 12px 12px 0 0;
- float: right;
+ padding: 0 6px;
.graph-side {
position: relative;
@@ -53,3 +64,9 @@
background-color: $divergence-graph-separator-bg;
}
}
+
+.divergence-graph,
+.branch-item .controls {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 7eee0a71c66..98d460339cd 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -6,26 +6,26 @@
@keyframes blinking-dots {
0% {
background-color: rgba($white-light, 1);
- box-shadow: 12px 0 0 0 rgba($white-light,0.2),
- 24px 0 0 0 rgba($white-light,0.2);
+ box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
+ 24px 0 0 0 rgba($white-light, 0.2);
}
25% {
background-color: rgba($white-light, 0.4);
- box-shadow: 12px 0 0 0 rgba($white-light,2),
- 24px 0 0 0 rgba($white-light,0.2);
+ box-shadow: 12px 0 0 0 rgba($white-light, 2),
+ 24px 0 0 0 rgba($white-light, 0.2);
}
75% {
background-color: rgba($white-light, 0.4);
- box-shadow: 12px 0 0 0 rgba($white-light,0.2),
- 24px 0 0 0 rgba($white-light,1);
+ box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
+ 24px 0 0 0 rgba($white-light, 1);
}
100% {
background-color: rgba($white-light, 1);
- box-shadow: 12px 0 0 0 rgba($white-light,0.2),
- 24px 0 0 0 rgba($white-light,0.2);
+ box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
+ 24px 0 0 0 rgba($white-light, 0.2);
}
}
@@ -37,65 +37,75 @@
}
.build-page {
- .sticky {
- position: absolute;
- left: 0;
- right: 0;
+ .build-trace-container {
+ position: relative;
}
- .build-trace-container {
- position: absolute;
- top: 225px;
- left: 15px;
- bottom: 10px;
+ .build-trace {
background: $black;
color: $gray-darkest;
- font-family: $monospace_font;
+ white-space: pre;
+ overflow-x: auto;
font-size: 12px;
+ border-radius: 0;
+ border: 0;
+ padding: $grid-size;
- &.sidebar-expanded {
- right: 305px;
+ .bash {
+ display: block;
}
+ }
- &.sidebar-collapsed {
- right: 16px;
- }
+ .top-bar {
+ height: 35px;
+ background: $gray-light;
+ border: 1px solid $border-color;
+ color: $gl-text-color;
+ position: sticky;
+ position: -webkit-sticky;
+ top: $header-height;
+ padding: $grid-size;
+
+ &.affix {
+ top: $header-height;
+
+ // with sidebar
+ &.sidebar-expanded {
+ right: 306px;
+ left: 16px;
+ }
- code {
- background: $black;
- color: $gray-darkest;
+ // without sidebar
+ &.sidebar-collapsed {
+ right: 16px;
+ left: 16px;
+ }
}
- .top-bar {
+ &.affix-top {
+ position: absolute;
+ right: 0;
+ left: 0;
top: 0;
- height: 35px;
- display: flex;
- justify-content: flex-end;
- background: $gray-light;
- border: 1px solid $border-color;
- color: $gl-text-color;
-
- .truncated-info {
- margin: 0 auto;
- align-self: center;
+ }
- .truncated-info-size {
- margin: 0 5px;
- }
+ .truncated-info {
+ .truncated-info-size {
+ margin: 0 5px;
+ }
- .raw-link {
- color: $gl-text-color;
- margin-left: 5px;
- text-decoration: underline;
- }
+ .raw-link {
+ color: $gl-text-color;
+ margin-left: 5px;
+ text-decoration: underline;
}
}
.controllers {
display: flex;
- align-self: center;
font-size: 15px;
- margin-bottom: 4px;
+ justify-content: center;
+ align-items: center;
svg {
height: 15px;
@@ -103,17 +113,13 @@
fill: $gl-text-color;
}
- .controllers-buttons,
- .btn-scroll {
+ .controllers-buttons {
color: $gl-text-color;
- height: 15px;
- vertical-align: middle;
- padding: 0;
- width: 12px;
- }
+ margin: 0 $grid-size;
- .controllers-buttons {
- margin: 1px 10px;
+ &:last-child {
+ margin-right: 0;
+ }
}
.btn-scroll.animate {
@@ -143,16 +149,6 @@
}
}
- .bash {
- top: 35px;
- left: 10px;
- bottom: 0;
- overflow-y: scroll;
- overflow-x: hidden;
- padding: 10px 20px 20px 5px;
- white-space: pre;
- }
-
.environment-information {
border: 1px solid $border-color;
padding: 8px $gl-padding 12px;
@@ -177,6 +173,16 @@
}
}
+.with-performance-bar .build-page {
+ .top-bar {
+ top: $header-height + $performance-bar-height;
+
+ &.affix {
+ top: $header-height + $performance-bar-height;
+ }
+ }
+}
+
.build-header {
.ci-header-container,
.header-action-buttons {
@@ -230,8 +236,18 @@
display: none;
}
+ .sidebar-container {
+ width: calc(100% + 100px);
+ padding-right: 100px;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
.blocks-container {
padding: 0 $gl-padding;
+ width: 289px;
}
.block {
@@ -250,7 +266,19 @@
}
}
+ .block-last {
+ padding: 16px 0;
+ }
+
+ .trigger-build-variables {
+ margin: 0;
+ overflow-x: auto;
+ -ms-overflow-style: scrollbar;
+ -webkit-overflow-scrolling: touch;
+ }
+
.trigger-build-variable {
+ font-weight: $gl-font-weight-normal;
color: $code-color;
}
@@ -295,22 +323,19 @@
}
.build-dropdown {
- padding: $gl-padding 0;
+ margin: $gl-padding 0;
+ padding: 0;
.dropdown-menu-toggle {
- margin-top: 8px;
- }
-
- .dropdown-menu {
- right: $gl-padding;
- left: $gl-padding;
- width: auto;
+ margin-top: #{$gl-padding / 2};
}
svg {
position: relative;
- top: 2px;
+ top: 3px;
margin-right: 3px;
+ width: 14px;
+ height: 14px;
}
}
@@ -319,13 +344,15 @@
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
max-height: 300px;
+ width: 289px;
overflow: auto;
svg {
position: relative;
- top: 2px;
+ top: 3px;
margin-right: 3px;
- height: 13px;
+ height: 14px;
+ width: 14px;
}
a {
@@ -344,7 +371,7 @@
.build-job {
position: relative;
- .fa-arrow-right {
+ .icon-arrow-right {
position: absolute;
left: 15px;
top: 20px;
@@ -352,9 +379,9 @@
}
&.active {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
- .fa-arrow-right {
+ .icon-arrow-right {
display: block;
}
}
@@ -367,8 +394,7 @@
background-color: $row-hover;
}
- .fa-refresh {
- font-size: 13px;
+ .icon-retry {
margin-left: 3px;
}
}
@@ -399,6 +425,7 @@
.build-light-text {
color: $gl-text-color-secondary;
+ word-wrap: break-word;
}
.build-gutter-toggle {
diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss
index 7b4eb689f1b..fbe1f3081a0 100644
--- a/app/assets/stylesheets/pages/ci_projects.scss
+++ b/app/assets/stylesheets/pages/ci_projects.scss
@@ -22,7 +22,7 @@
vertical-align: middle !important;
a {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
text-decoration: none;
}
}
@@ -36,7 +36,7 @@
pre.commit-message {
background: none;
padding: 0;
- border: none;
+ border: 0;
margin: 20px 0;
border-radius: 0;
}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
new file mode 100644
index 00000000000..7b8ee026357
--- /dev/null
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -0,0 +1,28 @@
+.edit-cluster-form {
+ .clipboard-addon {
+ background-color: $white-light;
+ }
+}
+
+.cluster-applications-table {
+ // Wait for the Vue to kick-in and render the applications block
+ min-height: 400px;
+}
+
+.clusters-dropdown-menu {
+ max-width: 100%;
+}
+
+.clusters-container {
+ .nav-bar-right {
+ padding: $gl-padding-top $gl-padding;
+ }
+
+ .empty-state .svg-content img {
+ width: 145px;
+ }
+
+ .top-area .nav-controls > .btn.btn-add-cluster {
+ margin-right: 0;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 9db0f2075cb..b487f6278c2 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -1,6 +1,6 @@
.commit-description {
background: none;
- border: none;
+ border: 0;
padding: 0;
margin-top: 10px;
word-break: normal;
@@ -54,7 +54,14 @@
.mr-widget-pipeline-graph {
display: inline-block;
vertical-align: middle;
- margin: 0 -6px 0 0;
+
+ .stage-cell .stage-container {
+ margin: 3px 3px 3px 0;
+ }
+
+ .stage-container:last-child {
+ margin-right: 0;
+ }
.dropdown-menu {
margin-top: 11px;
@@ -137,17 +144,17 @@
display: inline-block;
background: $white-light;
color: $gl-text-color-secondary;
- padding: 0 5px;
+ padding: 0 4px;
cursor: pointer;
border: 1px solid $border-gray-dark;
border-radius: $border-radius-default;
margin-left: 5px;
- font-size: $gl-font-size;
+ font-size: 12px;
line-height: $gl-font-size;
outline: none;
&.open {
- background: $gray-light;
+ background-color: darken($gray-light, 10%);
box-shadow: inset 0 0 2px rgba($black, 0.2);
}
@@ -182,17 +189,20 @@
.commit-content {
padding-right: 10px;
+ white-space: normal;
}
.commit-actions {
@media (min-width: $screen-sm-min) {
- font-size: 0;
+ .fa-spinner {
+ font-size: 12px;
+ }
}
.ci-status-link {
display: inline-block;
position: relative;
- top: 1px;
+ top: 2px;
}
.btn-clipboard,
@@ -209,7 +219,12 @@
.commit-sha {
font-size: 14px;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .ci-status-icon {
+ position: relative;
+ top: 2px;
}
}
@@ -222,6 +237,14 @@
vertical-align: baseline;
}
+ a.autodevops-badge {
+ color: $white-light;
+ }
+
+ a.autodevops-link {
+ color: $gl-link-color;
+ }
+
.commit-row-description {
font-size: 14px;
padding: 10px 15px;
@@ -232,7 +255,7 @@
word-break: normal;
pre {
- border: none;
+ border: 0;
background: inherit;
padding: 0;
margin: 0;
@@ -250,8 +273,8 @@
}
.committed_ago {
- float: right;
@extend .cgray;
+ float: right;
}
}
}
@@ -279,3 +302,68 @@
color: $gl-text-color;
}
}
+
+
+.gpg-status-box {
+ padding: 2px 10px;
+ margin-right: $gl-padding;
+
+ &:empty {
+ display: none;
+ }
+
+ &.valid {
+ @include green-status-color;
+ }
+
+ &.invalid {
+ @include status-color($gray-dark, $gray, $gray-darkest);
+ border-color: $gray-darkest;
+ }
+}
+
+.gpg-popover-status {
+ display: flex;
+ align-items: center;
+ font-weight: $gl-font-weight-normal;
+ line-height: 1.5;
+}
+
+.gpg-popover-icon {
+ // same margin as .s32.avatar
+ margin-right: $btn-side-margin;
+
+ &.valid {
+ svg {
+ border: 1px solid $brand-success;
+ fill: $brand-success;
+ }
+ }
+
+ &.invalid {
+ svg {
+ border: 1px solid $gray-darkest;
+ fill: $gray-darkest;
+ }
+ }
+
+ svg {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ vertical-align: middle;
+ }
+}
+
+.gpg-popover-user-link {
+ display: flex;
+ align-items: center;
+ margin-bottom: $gl-padding / 2;
+ text-decoration: none;
+ color: $gl-text-color;
+}
+
+.commit .gpg-popover-help-link {
+ display: block;
+ color: $link-color;
+}
diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss
index 3266714396e..dfff3e15556 100644
--- a/app/assets/stylesheets/pages/container_registry.scss
+++ b/app/assets/stylesheets/pages/container_registry.scss
@@ -9,6 +9,14 @@
.container-image-head {
padding: 0 16px;
line-height: 4em;
+
+ .btn-link {
+ padding: 0;
+
+ &:focus {
+ outline: none;
+ }
+ }
}
.table.tags {
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss
index 0413114c279..fb1899284fd 100644
--- a/app/assets/stylesheets/pages/convdev_index.scss
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -23,7 +23,7 @@ $space-between-cards: 8px;
line-height: 1;
color: $gl-text-color-secondary;
margin-left: 8px;
- font-weight: 500;
+ font-weight: $gl-font-weight-normal;
a {
font-size: 18px;
@@ -83,7 +83,7 @@ $space-between-cards: 8px;
border-top-color: $color-low-score;
.card-score-big {
- background-color: $red-25;
+ background-color: $red-50;
}
}
@@ -91,7 +91,7 @@ $space-between-cards: 8px;
border-top-color: $color-average-score;
.card-score-big {
- background-color: $orange-25;
+ background-color: $orange-50;
}
}
@@ -99,7 +99,7 @@ $space-between-cards: 8px;
border-top-color: $color-high-score;
.card-score-big {
- background-color: $green-25;
+ background-color: $green-50;
}
}
@@ -139,7 +139,7 @@ $space-between-cards: 8px;
.card-score-value {
font-size: 16px;
color: $gl-text-color;
- font-weight: 500;
+ font-weight: $gl-font-weight-normal;
}
.card-score-big {
@@ -147,7 +147,7 @@ $space-between-cards: 8px;
border-bottom: 1px solid $border-color;
font-size: 22px;
padding: 10px 0;
- font-weight: 500;
+ font-weight: $gl-font-weight-normal;
}
.card-buttons {
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 3039732ca5b..cfef6476d4d 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -20,13 +20,18 @@
}
}
}
+
+ svg {
+ width: 136px;
+ height: 136px;
+ }
}
.col-headers {
ul {
+ @include clearfix;
margin: 0;
padding: 0;
- @include clearfix;
}
li {
@@ -66,14 +71,14 @@
}
.stage-name {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
.panel {
.content-block {
padding: 24px 0;
- border-bottom: none;
+ border-bottom: 0;
position: relative;
@media (max-width: $screen-xs-max) {
@@ -91,7 +96,7 @@
.header {
font-size: 30px;
line-height: 38px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
margin: 0;
}
@@ -112,52 +117,6 @@
top: $gl-padding-top;
}
- .content-list {
- li {
- padding: 18px $gl-padding $gl-padding;
-
- .container-fluid {
- padding: 0;
- }
- }
-
- .title-col {
- p {
- margin: 0;
-
- &.title {
- line-height: 19px;
- font-size: 14px;
- font-weight: 600;
- color: $gl-text-color;
- }
-
- &.text {
- color: $layout-link-gray;
-
- &.value-col {
- color: $gl-text-color;
- }
- }
- }
- }
-
- .value-col {
- text-align: right;
-
- span {
- position: relative;
- vertical-align: middle;
- top: 3px;
- }
- }
- }
-
- .landing svg {
- width: 136px;
- height: 136px;
- }
-
.fa-spinner {
font-size: 28px;
position: relative;
@@ -189,8 +148,8 @@
}
li {
- list-style-type: none;
@include clearfix;
+ list-style-type: none;
}
.stage-nav-item {
@@ -209,7 +168,7 @@
box-shadow: inset 2px 0 0 0 $active-item-blue;
.stage-name {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
@@ -220,11 +179,11 @@
}
&:first-child {
- border-top: none;
+ border-top: 0;
}
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
}
.stage-nav-item-cell {
@@ -281,14 +240,14 @@
}
.stage-event-item {
+ @include clearfix;
list-style-type: none;
padding: 0 0 $gl-padding;
margin: 0 $gl-padding $gl-padding;
border-bottom: 1px solid $gray-darker;
- @include clearfix;
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
margin-bottom: 0;
}
@@ -307,9 +266,9 @@
&.issue-title,
&.commit-title,
&.merge-merquest-title {
+ @include text-overflow();
max-width: 100%;
display: block;
- @include text-overflow();
a {
color: $gl-text-color;
@@ -402,7 +361,7 @@
color: $gl-link-color;
line-height: 1.3;
vertical-align: top;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.fa {
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index 3d9eff35583..2f2c04206e2 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -3,6 +3,7 @@
border-bottom: 1px solid $border-color;
color: $gl-text-color;
line-height: 34px;
+ display: flex;
a {
color: $gl-text-color;
@@ -12,6 +13,39 @@
.author_link {
white-space: nowrap;
}
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+ }
+}
+
+.detail-page-header-body {
+ position: relative;
+ line-height: 35px;
+ display: flex;
+ flex-grow: 1;
+
+ @media (min-width: $screen-sm-min) {
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
+
+.detail-page-header-actions {
+ align-self: center;
+ flex-shrink: 0;
+ flex: 0 0 auto;
+
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+ margin-top: 10px;
+
+ > .issue-btn-group {
+ > .btn {
+ width: 100%;
+ }
+ }
+ }
}
.detail-page-description {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index b58922626fa..7f037582ca0 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -20,8 +20,6 @@
}
.diff-content {
- overflow: auto;
- overflow-y: hidden;
background: $white-light;
color: $gl-text-color;
border-radius: 0 0 3px 3px;
@@ -42,14 +40,14 @@
// "Changes suppressed. Click to show." link
.show-suppressed-diff {
font-size: 110%;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
}
table {
width: 100%;
font-family: $monospace_font;
- border: none;
+ border: 0;
border-collapse: separate;
margin: 0;
padding: 0;
@@ -79,6 +77,18 @@
word-wrap: break-word;
}
}
+
+ &.left-side-selected {
+ td.line_content.parallel.right-side {
+ @include user-select(none);
+ }
+ }
+
+ &.right-side-selected {
+ td.line_content.parallel.left-side {
+ @include user-select(none);
+ }
+ }
}
tr.line_holder.parallel {
@@ -93,20 +103,20 @@
.old_line,
.new_line {
+ @include user-select(none);
margin: 0;
- border: none;
+ border: 0;
padding: 0 5px;
border-right: 1px solid;
text-align: right;
min-width: 35px;
max-width: 50px;
width: 35px;
- @include user-select(none);
a {
float: left;
width: 35px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
&[disabled] {
cursor: default;
@@ -123,7 +133,7 @@
display: block;
margin: 0;
padding: 0 1.5em;
- border: none;
+ border: 0;
position: relative;
&.parallel {
@@ -287,6 +297,7 @@
.drag-track {
display: block;
position: absolute;
+ top: 0;
left: 12px;
height: 10px;
width: 276px;
@@ -348,7 +359,7 @@
cursor: pointer;
&:first-child {
- border-left: none;
+ border-left: 0;
}
&:hover {
@@ -356,12 +367,12 @@
}
&.active {
+ cursor: default;
+ color: $gl-text-color;
+
&:hover {
text-decoration: none;
}
-
- cursor: default;
- color: $gl-text-color;
}
&.disabled {
@@ -369,15 +380,15 @@
}
}
}
+
+ .line_content {
+ white-space: pre-wrap;
+ }
}
.file-content .diff-file {
margin: 0;
- border: none;
-}
-
-.diff-file .line_content {
- white-space: pre-wrap;
+ border: 0;
}
.diff-wrap-lines .line_content {
@@ -389,7 +400,7 @@
}
.files-changed {
- border-bottom: none;
+ border-bottom: 0;
}
.diff-stats-summary-toggler {
@@ -397,12 +408,11 @@
background-color: transparent;
border: 0;
color: $gl-link-color;
- transition: color 0.1s linear;
+ font-weight: $gl-font-weight-bold;
&:hover,
&:focus {
outline: none;
- text-decoration: underline;
color: $gl-link-hover-color;
}
}
@@ -454,7 +464,7 @@
}
.files {
- margin-top: -1px;
+ margin-top: 1px;
.diff-file:last-child {
margin-bottom: 0;
@@ -476,6 +486,7 @@
height: 19px;
width: 19px;
margin-left: -15px;
+ z-index: 100;
&:hover {
.diff-comment-avatar,
@@ -491,7 +502,7 @@
transform: translateX((($i * $x-pos) - $x-pos));
&:hover {
- transform: translateX((($i * $x-pos) - $x-pos)) scale(1.2);
+ transform: translateX((($i * $x-pos) - $x-pos));
}
}
}
@@ -537,29 +548,293 @@
}
.diff-notes-collapse {
- position: relative;
- width: 19px;
- height: 19px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
padding: 0;
transition: transform .1s ease-out;
+ z-index: 100;
+
+ .collapse-icon {
+ height: 50%;
+ width: 100%;
+ }
svg {
- position: absolute;
- left: 50%;
- top: 50%;
- margin-left: -5.5px;
- margin-top: -5.5px;
+ vertical-align: middle;
}
+ .collapse-icon,
path {
fill: $white-light;
}
- &:hover {
- transform: scale(1.2);
+ &:focus {
+ outline: 0;
+ }
+}
+
+.diff-files-changed {
+ .inline-parallel-buttons {
+ position: relative;
+ z-index: 1;
+ }
+
+ .commit-stat-summary {
+ @media (min-width: $screen-sm-min) {
+ margin-left: -$gl-padding;
+ padding-left: $gl-padding;
+ background-color: $white-light;
+ }
+ }
+
+ @media (min-width: $screen-sm-min) {
+ position: -webkit-sticky;
+ position: sticky;
+ top: 24px;
+ background-color: $white-light;
+ z-index: 190;
+
+ &.diff-files-changed-merge-request {
+ top: 76px;
+ }
+
+ &:not(.is-stuck) .diff-stats-additions-deletions-collapsed {
+ display: none;
+ }
+
+ &.is-stuck {
+ padding-top: 0;
+ padding-bottom: 0;
+ border-bottom: 1px solid $white-dark;
+ transform: translateY(16px);
+
+ .diff-stats-additions-deletions-expanded,
+ .inline-parallel-buttons {
+ display: none;
+ }
+ }
+ }
+}
+
+@media (min-width: $screen-sm-min) {
+ .with-performance-bar {
+ .diff-files-changed.diff-files-changed-merge-request {
+ top: 76px + $performance-bar-height;
+ }
+ }
+}
+
+.diff-file-changes {
+ max-width: 560px;
+ width: 100%;
+ z-index: 150;
+
+ @media (min-width: $screen-sm-min) {
+ left: $gl-padding;
+ }
+
+ .diff-changed-file {
+ display: flex;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ min-width: 0;
+ }
+
+ .diff-file-changed-icon {
+ margin-top: 2px;
+ }
+
+ .diff-changed-file-content {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ }
+
+ .diff-changed-file-name,
+ .diff-changed-blank-file-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .diff-changed-blank-file-name {
+ color: $gl-text-color-tertiary;
+ font-style: italic;
+ }
+
+ .diff-changed-file-path {
+ color: $gl-text-color-tertiary;
+ }
+
+ .diff-changed-stats {
+ margin-left: auto;
+ white-space: nowrap;
+ }
+}
+
+.diff-file-changes-path {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.note-container {
+ background-color: $gray-light;
+ border-top: 1px solid $white-normal;
+
+ // double jagged line divider
+ .discussion-notes + .discussion-notes::before,
+ .discussion-notes + .discussion-form::before {
+ content: '';
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 10px;
+ background-color: $white-light;
+ background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%),
+ linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%),
+ linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%),
+ linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%);
+ background-position: 5px 5px,0 5px,0 5px,5px 5px;
+ background-size: 10px 10px;
+ background-repeat: repeat;
+ }
+
+ .notes {
+ position: relative;
+ }
+
+ .diff-notes-collapse {
+ position: absolute;
+ left: -12px;
+ }
+}
+
+.diff-file .note-container > .new-note,
+.note-container .discussion-notes {
+ margin-left: 100px;
+ border-left: 1px solid $white-normal;
+}
+
+.notes.active {
+ .diff-file .note-container > .new-note,
+ .note-container .discussion-notes {
+ // Override our margin and border (set for diff tab)
+ // when user is on the discussion tab for MR
+ margin-left: inherit;
+ border-left: inherit;
}
+}
+
+.files:not([data-can-create-note]) .frame {
+ cursor: auto;
+}
+
+.frame.click-to-comment {
+ position: relative;
+ cursor: image-url('illustrations/image_comment_light_cursor.svg')
+ $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto;
+
+ // Retina cursor
+ cursor: -webkit-image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, image-url('illustrations/image_comment_light_cursor@2x.svg') 2x)
+ $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto;
+
+ .comment-indicator {
+ position: absolute;
+ padding: 0;
+ width: (2px * $image-comment-cursor-left-offset);
+ height: (2px * $image-comment-cursor-top-offset);
+ // center the indicator to match the top left click region
+ margin-top: (-1px * $image-comment-cursor-top-offset) + 2;
+ margin-left: (-1px * $image-comment-cursor-left-offset) + 1;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+
+ &:focus {
+ outline: none;
+ }
+ }
+}
+
+.frame .badge,
+.image-diff-avatar-link .badge,
+.notes > .badge {
+ position: absolute;
+ background-color: $blue-400;
+ color: $white-light;
+ border: $white-light 1px solid;
+ min-height: $gl-padding;
+ padding: 5px 8px;
+ border-radius: 12px;
&:focus {
- outline: 0;
+ outline: none;
+ }
+}
+
+.frame .badge,
+.frame .image-comment-badge {
+ // Center align badges on the frame
+ transform: translate(-50%, -50%);
+}
+
+.image-comment-badge {
+ position: absolute;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background: none;
+ border: 0;
+
+ > svg {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.image-diff-avatar-link {
+ position: relative;
+
+ .badge,
+ .image-comment-badge {
+ top: 25px;
+ right: 8px;
}
}
+
+.notes > .badge {
+ display: none;
+ left: -13px;
+}
+
+.discussion-notes {
+ min-height: 35px;
+
+ &:first-child {
+ // First child does not have the jagged borders
+ min-height: 25px;
+ }
+
+ &.collapsed {
+ background-color: $white-light;
+
+ .diff-notes-collapse,
+ .note,
+ .discussion-reply-holder, {
+ display: none;
+ }
+
+ .notes > .badge {
+ display: block;
+ }
+ }
+}
+
+.discussion-body .image .frame {
+ position: relative;
+}
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index f6b8c8ee2bc..8ecda50602d 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -3,13 +3,13 @@
border-top: 1px solid $border-color;
border-right: 1px solid $border-color;
border-left: 1px solid $border-color;
- border-bottom: none;
- border-radius: 2px;
+ border-bottom: 0;
+ border-radius: $border-radius-small $border-radius-small 0 0;
background: $gray-normal;
}
#editor {
- border: none;
+ border: 0;
border-radius: 0;
height: 500px;
margin: 0;
@@ -171,7 +171,7 @@
width: 100%;
margin: 5px 0;
padding: 0;
- border-left: none;
+ border-left: 0;
}
}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 1046ebfa2e2..58700661142 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -6,7 +6,7 @@
}
.environments-folder-name {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
padding-top: 20px;
}
@@ -115,12 +115,16 @@
}
.no-btn {
- border: none;
+ border: 0;
background: none;
outline: none;
width: 100%;
text-align: left;
}
+
+ .environment-child-row {
+ padding-left: 20px;
+ }
}
}
@@ -131,29 +135,11 @@
}
.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;
- stroke-width: 0;
- }
-
- .label-axis-text,
- .text-metric-usage {
- fill: $black;
- font-weight: 500;
- }
+ border-left: 0;
+ border-right: 0;
- .legend-axis-text {
- fill: $black;
+ @media (min-width: $screen-sm-max) {
+ border-top: 0;
}
}
@@ -184,12 +170,12 @@
}
.metric-area {
- opacity: 0.8;
+ opacity: 0.25;
}
.prometheus-graph-overlay {
fill: none;
- opacity: 0.0;
+ opacity: 0;
pointer-events: all;
}
@@ -204,7 +190,7 @@
}
.text-metric {
- font-weight: 600;
+ font-size: 12px;
}
.selected-metric-line {
@@ -214,23 +200,26 @@
.deployment-line {
stroke: $black;
- stroke-width: 2;
-}
-
-.deploy-info-text {
- dominant-baseline: text-before-edge;
+ stroke-width: 1;
}
-.text-metric-bold {
- font-weight: 600;
+.divider-line {
+ stroke-width: 1;
+ stroke: $gray-darkest;
}
.prometheus-state {
- margin-top: 10px;
- display: none;
+ max-width: 460px;
+ margin: 10px auto;
+ text-align: center;
- .state-button-section {
- margin-top: 10px;
+ .state-svg {
+ max-width: 80vw;
+ margin: 0 auto;
+ }
+
+ .state-button {
+ padding: $gl-padding / 2;
}
}
@@ -242,3 +231,186 @@
width: 38px;
}
}
+
+.prometheus-panel {
+ margin-top: 20px;
+}
+
+.prometheus-graph-group {
+ display: flex;
+ flex-wrap: wrap;
+ padding: $gl-padding / 2;
+}
+
+.prometheus-graph {
+ flex: 1 0 auto;
+ min-width: 450px;
+ padding: $gl-padding / 2;
+
+ h5 {
+ font-size: 16px;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ min-width: 100%;
+ }
+}
+
+.prometheus-graph-cursor {
+ position: absolute;
+ background: $theme-gray-600;
+ width: 1px;
+}
+
+.prometheus-graph-flag {
+ display: block;
+ min-width: 160px;
+
+ h5 {
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.2;
+ }
+
+ table {
+ border-collapse: collapse;
+ padding: 0;
+ margin: 0;
+ }
+
+ td {
+ vertical-align: middle;
+
+ + td {
+ padding-left: 5px;
+ vertical-align: top;
+ }
+ }
+
+ .deploy-meta-content {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ height: 15px;
+ vertical-align: bottom;
+ }
+ }
+
+ &.popover {
+ &.left {
+ left: auto;
+ right: 0;
+ margin-right: 10px;
+ }
+
+ &.right {
+ left: 0;
+ right: auto;
+ margin-left: 10px;
+ }
+
+ > .arrow {
+ top: 40px;
+ }
+
+ > .popover-title,
+ > .popover-content {
+ padding: 5px 8px;
+ font-size: 12px;
+ white-space: nowrap;
+ }
+ }
+}
+
+.prometheus-svg-container {
+ position: relative;
+ height: 0;
+ width: 100%;
+ padding: 0;
+ padding-bottom: 100%;
+
+ .text-metric-usage,
+ .legend-metric-title {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 12px;
+ }
+
+ > svg {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+
+ text {
+ fill: $gl-text-color;
+ stroke-width: 0;
+ }
+
+ .text-metric-bold {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .label-axis-text {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 10px;
+ }
+
+ .legend-axis-text {
+ fill: $black;
+ }
+
+ .tick {
+ > line {
+ stroke: $gray-darker;
+ }
+
+ > text {
+ fill: $theme-gray-600;
+ font-size: 10px;
+ }
+ }
+
+ .text-metric-title {
+ font-size: 12px;
+ }
+
+ .y-label-text,
+ .x-label-text {
+ fill: $gray-darkest;
+ }
+
+ .axis-tick {
+ stroke: $gray-darker;
+ }
+
+ .deploy-info-text {
+ dominant-baseline: text-before-edge;
+ font-size: 12px;
+ }
+
+ .deploy-info-text-link {
+ font-family: $monospace_font;
+ fill: $gl-link-color;
+
+ &:hover {
+ fill: $gl-link-hover-color;
+ }
+ }
+
+ @media (max-width: $screen-sm-max) {
+ .label-axis-text,
+ .text-metric-usage,
+ .legend-axis-text {
+ font-size: 8px;
+ }
+
+ .tick > text {
+ font-size: 8px;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 4c3fa1fb8d4..d9267f5cdf3 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -57,7 +57,7 @@
.event-title {
@include str-truncated(calc(100% - 174px));
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
color: $list-text-color;
}
@@ -72,7 +72,6 @@
.label {
color: $gl-text-color;
- font-size: inherit;
}
iframe.twitter-share-button {
@@ -85,7 +84,7 @@
}
pre {
- border: none;
+ border: 0;
background: $gray-light;
border-radius: 0;
color: $events-pre-color;
@@ -128,14 +127,14 @@
}
}
- &:last-child { border: none; }
+ &:last-child { border: 0; }
.event_commits {
li {
&.commit {
background: transparent;
padding: 0;
- border: none;
+ border: 0;
.commit-row-title {
font-size: $gl-font-size;
@@ -159,22 +158,18 @@
}
}
-
/*
* Last push widget
*/
.event-last-push {
- overflow: auto;
width: 100%;
+ display: flex;
+ align-items: center;
.event-last-push-text {
@include str-truncated(100%);
- padding: 4px 0;
font-size: 13px;
- float: left;
- margin-right: -150px;
- padding-right: 150px;
- line-height: 20px;
+ margin-right: $gl-padding;
}
}
@@ -182,6 +177,12 @@
.event-item {
padding-left: 0;
+ &.event-inline {
+ .event-title {
+ line-height: 20px;
+ }
+ }
+
.event-title {
white-space: normal;
overflow: visible;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 6f6c6839975..6ee8b33bd39 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -26,14 +26,117 @@
}
}
-.groups-header {
- @media (min-width: $screen-sm-min) {
- .nav-links {
- width: 35%;
+.group-nav-container .nav-controls {
+ display: flex;
+ align-items: flex-start;
+ padding: $gl-padding-top 0;
+ border-bottom: 1px solid $border-color;
+
+ .group-filter-form {
+ flex: 1;
+ }
+
+ .dropdown-menu-align-right {
+ margin-top: 0;
+ }
+
+ .new-project-subgroup {
+ .dropdown-primary {
+ min-width: 115px;
+ }
+
+ .dropdown-toggle {
+ .dropdown-btn-icon {
+ pointer-events: none;
+ color: inherit;
+ margin-left: 0;
+ }
+ }
+
+ .dropdown-menu {
+ min-width: 280px;
+ margin-top: 2px;
+ }
+
+ li:not(.divider) {
+ padding: 0;
+
+ &.droplab-item-selected {
+ .icon-container {
+ .list-item-checkmark {
+ visibility: visible;
+ }
+ }
+ }
+
+ .menu-item {
+ padding: 8px 4px;
+
+ &:hover {
+ background-color: $gray-darker;
+ color: $theme-gray-900;
+ }
+ }
+
+ .icon-container {
+ float: left;
+ padding-left: 6px;
+
+ .list-item-checkmark {
+ visibility: hidden;
+ }
+ }
+
+ .description {
+ font-size: 14px;
+
+ strong {
+ display: block;
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+ }
+ }
+
+ @media (max-width: $screen-sm-max) {
+ &,
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-new {
+ display: block;
+ }
+
+ .group-filter-form,
+ .dropdown {
+ margin-bottom: 10px;
+ margin-right: 0;
}
- .nav-controls {
- width: 65%;
+ .group-filter-form,
+ .dropdown .dropdown-toggle,
+ .btn-new {
+ width: 100%;
+ }
+
+ .dropdown .dropdown-toggle .fa-chevron-down {
+ position: absolute;
+ top: 11px;
+ right: 8px;
+ }
+
+ .new-project-subgroup {
+ display: flex;
+ align-items: flex-start;
+
+ .dropdown-primary {
+ flex: 1;
+ }
+
+ .dropdown-menu {
+ width: 100%;
+ max-width: inherit;
+ min-width: inherit;
+ }
}
}
}
@@ -109,3 +212,28 @@
height: 50px;
}
}
+
+.user-access-role {
+ display: inline-block;
+ color: $gl-text-color-secondary;
+ font-size: 12px;
+ line-height: 20px;
+ margin: -5px 3px;
+ padding: 0 $label-padding;
+ border: 1px solid $border-color;
+ border-radius: $label-border-radius;
+ font-weight: $gl-font-weight-normal;
+}
+
+.js-groups-dropdown {
+ width: 100%;
+}
+
+.dropdown-group-transfer {
+ bottom: 100%;
+ top: initial;
+
+ .dropdown-content {
+ overflow-y: unset;
+ }
+}
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index dae8ccdef6c..9cc9e11bcd1 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -1,23 +1,3 @@
-.documentation-index {
- h1 {
- margin: 0;
- }
-
- h2 {
- font-size: 20px;
- }
-
- li {
- line-height: 24px;
- color: $document-index-color;
-
- a {
- margin-right: 3px;
- }
- }
-}
-
-
.shortcut-mappings {
font-size: 12px;
color: $help-shortcut-mapping-color;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index e3ebcc8af6c..e21a9f0afc9 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -5,13 +5,31 @@
margin-right: auto;
}
+.issuable-warning-icon {
+ background-color: $orange-100;
+ border-radius: $border-radius-default;
+ margin: 0 $btn-side-margin 0 0;
+ width: $issuable-warning-size;
+ height: $issuable-warning-size;
+ text-align: center;
+
+ .icon {
+ fill: $orange-600;
+ vertical-align: text-bottom;
+ }
+
+ &:first-of-type {
+ margin-right: $issuable-warning-icon-margin;
+ }
+}
+
.limit-container-width {
.detail-page-header,
.page-content-header,
.commit-box,
.info-well,
.commit-ci-menu,
- .files-changed,
+ .files-changed-inner,
.limited-header-width,
.limited-width-notes {
@extend .fixed-width-container;
@@ -46,10 +64,19 @@
}
}
+ .title-container {
+ display: flex;
+ }
+
.title {
padding: 0;
- margin-bottom: 16px;
- border-bottom: none;
+ margin-bottom: $gl-padding;
+ border-bottom: 0;
+ }
+
+ .btn-edit {
+ margin-left: auto;
+ height: $gl-padding * 2;
}
// Border around images in issue and MR descriptions.
@@ -57,6 +84,7 @@
border: 1px solid $white-normal;
padding: 5px;
max-height: calc(100vh - 100px);
+ max-width: 100%;
}
.emoji-block {
@@ -74,28 +102,57 @@
.issuable-show-labels {
a {
- margin-right: 5px;
margin-bottom: 5px;
+ margin-right: 5px;
display: inline-block;
.color-label {
- padding: 6px 10px;
+ padding: 4px $grid-size;
border-radius: $label-border-radius;
}
+
+ &:hover .color-label {
+ text-decoration: underline;
+ }
}
&.has-labels {
+ // this font size is a fix to
+ // prevent unintended spacing between labels
+ // which shows up when rendering markup has white-space
+ // characters present.
+ // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3
+ font-size: 0;
margin-bottom: -5px;
}
}
.right-sidebar {
- a,
- .btn-link {
+ position: fixed;
+ top: $header-height;
+ bottom: 0;
+ right: 0;
+ transition: width $sidebar-transition-duration;
+ background: $gray-light;
+ z-index: 200;
+ overflow: hidden;
+
+ a:not(.btn) {
color: inherit;
+
+ &:hover {
+ color: $gl-link-hover-color;
+
+ .avatar {
+ border-color: rgba($avatar-border, .2);
+ }
+
+ }
+
}
.btn-link {
+ color: inherit;
outline: none;
}
@@ -118,11 +175,7 @@
}
&:last-child {
- border: none;
- }
-
- span {
- display: inline-block;
+ border: 0;
}
.select2-container span {
@@ -137,6 +190,14 @@
color: $gray-darkest;
}
}
+
+ &.assignee {
+ .author_link:hover {
+ .author {
+ text-decoration: underline;
+ }
+ }
+ }
}
.block-first {
@@ -152,11 +213,18 @@
margin-left: 0;
}
+ a.edit-link:not([href]):hover {
+ color: rgba($avatar-border, .2);
+ }
+
+ .lock-edit, // uses same style, different js behaviour
.edit-link {
+ @extend .btn-blank;
color: $gl-text-color;
- &:not([href]):hover {
- color: rgba($avatar-border, .2);
+ &:hover {
+ text-decoration: underline;
+ color: $gl-link-hover-color;
}
}
}
@@ -191,27 +259,19 @@
.btn-clipboard:hover {
color: $gl-text-color;
}
-}
-
-.right-sidebar {
- position: absolute;
- top: $header-height;
- bottom: 0;
- right: 0;
- transition: width .3s;
- background: $gray-light;
- padding: 0 20px;
- z-index: 200;
- overflow: hidden;
.issuable-sidebar {
width: calc(100% + 100px);
- height: calc(100% - #{$header-height});
+ height: 100%;
overflow-y: scroll;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
+ &.affix-top .issuable-sidebar {
+ height: 100%;
+ }
+
&.right-sidebar-expanded {
width: $gutter_width;
@@ -224,11 +284,15 @@
}
}
+ .issuable-sidebar {
+ padding: 0 20px;
+ }
+
.issuable-sidebar-header {
padding-top: 10px;
}
- &:not(.issue-boards-sidebar):not([data-signed-in]) {
+ &:not(.issue-boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
.issuable-sidebar-header {
display: none;
}
@@ -239,13 +303,18 @@
}
.light {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
- .no-value {
+ .no-value,
+ .btn-secondary-hover-link {
color: $gl-text-color-secondary;
}
+ .btn-secondary-hover-link:hover {
+ color: $gl-link-color;
+ }
+
.sidebar-collapsed-icon {
display: none;
}
@@ -253,6 +322,7 @@
.gutter-toggle {
margin-top: 7px;
border-left: 1px solid $border-gray-normal;
+ text-align: center;
}
.title .gutter-toggle {
@@ -274,7 +344,7 @@
display: block;
margin-top: 4px;
font-size: 13px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.hide-expanded {
@@ -296,7 +366,7 @@
.block {
width: $gutter_collapsed_width - 2px;
padding: 15px 0 0;
- border-bottom: none;
+ border-bottom: 0;
overflow: hidden;
}
@@ -321,9 +391,17 @@
margin-bottom: 10px;
color: $issuable-sidebar-color;
- &:hover,
+ svg {
+ fill: $issuable-sidebar-color;
+ }
+
+ &:hover:not(.disabled),
&:hover .todo-undone {
color: $gl-text-color;
+
+ svg {
+ fill: $gl-text-color;
+ }
}
span {
@@ -343,17 +421,13 @@
display: none;
}
- .avatar:hover,
- .avatar-counter:hover {
- border-color: $issuable-sidebar-color;
- }
-
.avatar-counter:hover {
color: $issuable-sidebar-color;
+ border-color: $issuable-sidebar-color;
}
.btn-clipboard {
- border: none;
+ border: 0;
color: $issuable-sidebar-color;
&:hover {
@@ -413,22 +487,12 @@
}
}
}
- }
- a {
- &:hover {
- color: $md-link-color;
- text-decoration: none;
-
- .avatar {
- border-color: rgba($avatar-border, .2);
- }
- }
- }
-
- .dropdown-content {
- a:hover {
- color: inherit;
+ .milestone-title span,
+ .collapse-truncated-title {
+ @include str-truncated(100%);
+ display: block;
+ margin: 0 4px;
}
}
@@ -437,11 +501,47 @@
padding-top: 6px;
}
- .open .dropdown-menu {
+ .dropdown-menu {
width: 100%;
+
+ /*
+ * Overwrite hover style for dropdown items, so that they are not blue
+ * This should be removed during dev of https://gitlab.com/gitlab-org/gitlab-ce/issues/44040
+ */
+ li a {
+ &:hover,
+ &:active,
+ &:focus,
+ &.is-focused {
+ @include dropdown-item-hover;
+ }
+ }
+
+ }
+}
+
+.with-performance-bar .right-sidebar {
+ top: $header-height + $performance-bar-height;
+
+ .issuable-sidebar {
+ height: calc(100% - #{$performance-bar-height});
}
}
+.sidebar-move-issue-confirmation-button {
+ width: 100%;
+
+ &.is-loading {
+ .sidebar-move-issue-confirmation-loading-icon {
+ display: inline-block;
+ }
+ }
+}
+
+.sidebar-move-issue-confirmation-loading-icon {
+ display: none;
+}
+
.detail-page-description {
padding: 16px 0;
@@ -454,6 +554,7 @@
color: $gray-darkest;
display: block;
margin: 16px 0 0;
+ font-size: 85%;
.author_link {
color: $gray-darkest;
@@ -461,7 +562,9 @@
}
.participants-list {
- margin: -5px;
+ display: flex;
+ flex-wrap: wrap;
+ margin: -7px;
}
@@ -472,7 +575,7 @@
.participants-author {
display: inline-block;
- padding: 5px;
+ padding: 7px;
&:nth-of-type(7n) {
padding-right: 0;
@@ -529,51 +632,33 @@
}
.issuable-status-box {
- float: none;
- display: inline-block;
+ align-self: stretch;
+ display: flex;
+ justify-content: center;
+ align-items: center;
margin-top: 0;
-
- @media (max-width: $screen-xs-max) {
- position: absolute;
- top: 0;
- left: 0;
- }
-}
-
-.issuable-header {
- position: relative;
- padding-left: 45px;
- padding-right: 45px;
- line-height: 35px;
+ padding-left: 9px;
+ padding-right: 9px;
@media (min-width: $screen-sm-min) {
- float: left;
- padding-left: 0;
- padding-right: 0;
- }
-}
-
-.issuable-actions {
- padding-top: 10px;
-
- @media (min-width: $screen-sm-min) {
- float: right;
- padding-top: 0;
+ display: inline-block;
+ height: auto;
+ align-self: center;
}
}
.issuable-gutter-toggle {
@media (max-width: $screen-sm-max) {
- position: absolute;
- top: 0;
- right: 0;
+ margin-left: $btn-side-margin;
}
}
.issuable-meta {
+ flex: 1;
display: inline-block;
- line-height: 18px;
font-size: 14px;
+ line-height: 24px;
+ align-self: center;
}
.js-issuable-selector-wrap {
@@ -597,7 +682,38 @@
.issue-info-container {
-webkit-flex: 1;
flex: 1;
+ display: flex;
padding-right: $gl-padding;
+
+ .issue-main-info {
+ flex: 1 auto;
+ margin-right: 10px;
+ }
+
+ .issuable-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex: 1 0 auto;
+
+ .controls {
+ margin-bottom: 2px;
+ line-height: 20px;
+ padding: 0;
+ }
+
+ .issue-updated-at {
+ line-height: 20px;
+ }
+ }
+
+ @media(max-width: $screen-xs-max) {
+ .issuable-meta {
+ .controls li {
+ margin-right: 0;
+ }
+ }
+ }
}
.issue-check {
@@ -609,6 +725,30 @@
vertical-align: text-top;
}
}
+
+ .issuable-milestone,
+ .issuable-info,
+ .task-status,
+ .issuable-updated-at {
+ font-weight: $gl-font-weight-normal;
+ color: $gl-text-color-secondary;
+
+ a {
+ color: $gl-text-color;
+
+ .fa {
+ color: $gl-text-color-secondary;
+ }
+ }
+ }
+
+ @media(max-width: $screen-md-max) {
+ .task-status,
+ .issuable-due-date,
+ .project-ref-path {
+ display: none;
+ }
+ }
}
}
@@ -741,3 +881,44 @@
}
}
}
+
+.issuable-close-button,
+.issuable-close-toggle {
+ @include transition(border-color, color);
+}
+
+.issuable-close-dropdown {
+ .dropdown-menu {
+ min-width: 270px;
+ left: auto;
+ right: 0;
+ }
+
+ .description {
+ .text {
+ margin: 0;
+ }
+ }
+
+ .dropdown-toggle > .icon {
+ margin: 0 3px;
+ }
+}
+
+.right-sidebar-collapsed {
+ .sidebar-grouped-item {
+ .sidebar-collapsed-icon {
+ margin-bottom: 0;
+ }
+
+ .sidebar-collapsed-divider {
+ line-height: 5px;
+ font-size: 12px;
+ color: $theme-gray-700;
+
+ + .sidebar-collapsed-icon {
+ padding-top: 0;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 8cdb3f34ae5..b9390450477 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -13,10 +13,20 @@
display: inline-block;
}
+ .issuable-meta {
+ .author_link {
+ display: inline-block;
+ }
+
+ .issuable-comments {
+ height: 18px;
+ }
+ }
+
.icon-merge-request-unmerged {
height: 13px;
margin-bottom: 3px;
- }
+ }
}
}
@@ -75,7 +85,7 @@ ul.related-merge-requests > li {
.merge-requests-title,
.related-branches-title {
font-size: 16px;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.merge-request-id {
@@ -134,25 +144,20 @@ ul.related-merge-requests > li {
}
@media (max-width: $screen-xs-max) {
- .issue-btn-group {
- width: 100%;
-
- .btn {
- width: 100%;
+ .detail-page-header {
+ .issuable-meta {
+ line-height: 18px;
}
}
}
-.issue-form .select2-container {
- width: 250px !important;
-}
-
-.issues-footer {
- padding-top: $gl-padding;
- padding-bottom: 37px;
+.issue-form {
+ .select2-container {
+ width: 250px !important;
+ }
}
-.issue-email-modal-btn {
+.issuable-email-modal-btn {
padding: 0;
color: $gl-link-color;
background-color: transparent;
@@ -186,6 +191,20 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
+ .ref::selection {
+ color: $placeholder-text-color;
+ }
+
+ .dropdown {
+ .dropdown-menu-toggle {
+ min-width: 285px;
+ }
+
+ .dropdown-select {
+ width: 285px;
+ }
+ }
+
.btn-group:not(.hide) {
display: flex;
}
@@ -195,59 +214,37 @@ ul.related-merge-requests > li {
flex-shrink: 0;
}
- .dropdown-menu {
+ .create-merge-request-dropdown-menu {
width: 300px;
opacity: 1;
visibility: visible;
transform: translateY(0);
display: none;
+ margin-top: 4px;
+
+ // override dropdown item styles
+ .btn.btn-success {
+ @include btn-default;
+ @include btn-green;
+
+ border-style: solid;
+ border-width: 1px;
+ line-height: $line-height-base;
+ width: auto;
+ }
}
- .dropdown-toggle {
+ .create-merge-request-dropdown-toggle {
.fa-caret-down {
pointer-events: none;
color: inherit;
margin-left: 0;
}
}
+}
- li:not(.divider) {
- padding: 6px;
- cursor: pointer;
-
- &:hover,
- &:focus {
- background-color: $dropdown-hover-color;
- color: $white-light;
- }
-
- &.droplab-item-selected {
- .icon-container {
- i {
- visibility: visible;
- }
- }
- }
-
- .icon-container {
- float: left;
- padding-left: 6px;
-
- i {
- visibility: hidden;
- }
- }
-
- .description {
- padding-left: 30px;
- font-size: 13px;
-
- strong {
- display: block;
- font-weight: 600;
- }
- }
- }
+.discussion-reply-holder .note-edit-form {
+ display: block;
}
@media (min-width: $screen-sm-min) {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index b158416b940..b0852adb459 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -26,9 +26,15 @@
}
}
+.dropdown-menu-labels {
+ .dropdown-content {
+ max-height: 135px;
+ }
+}
+
.dropdown-new-label {
.dropdown-content {
- max-height: 260px;
+ max-height: 136px;
}
}
@@ -58,13 +64,13 @@
@media (min-width: $screen-sm-min) {
width: 200px;
+ margin-left: $gl-padding * 2;
margin-bottom: 0;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
- vertical-align: middle;
max-width: 100%;
}
}
@@ -79,32 +85,42 @@
width: 100px;
margin-left: 10px;
margin-bottom: 0;
- vertical-align: middle;
+ vertical-align: top;
}
}
.label-description {
display: block;
margin-bottom: 10px;
- margin-left: 50px;
+
+ .description-text {
+ margin-bottom: $gl-padding;
+ }
+
+ a {
+ color: $blue-600;
+ }
@media (min-width: $screen-sm-min) {
display: inline-block;
- width: 30%;
+ max-width: 50%;
margin-left: 10px;
margin-bottom: 0;
- vertical-align: middle;
+ vertical-align: top;
}
}
.label {
- padding: 8px 9px 9px;
- font-size: 14px;
+ padding: 4px $grid-size;
+ font-size: $label-font-size;
+ position: relative;
+ top: ($grid-size / 2);
}
}
.color-label {
- padding: 3px 7px;
+ padding: 0 $grid-size;
+ line-height: 16px;
border-radius: $label-border-radius;
}
@@ -116,6 +132,12 @@
}
.manage-labels-list {
+ @media(min-width: $screen-md-min) {
+ &.content-list li {
+ padding: $gl-padding 0;
+ }
+ }
+
> li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
cursor: move;
@@ -133,8 +155,6 @@
}
.btn-action {
- color: $gl-text-color;
-
.fa {
font-size: 18px;
vertical-align: middle;
@@ -155,10 +175,18 @@
float: right;
}
}
+
+ @media (max-width: $screen-xs-max) {
+ .dropdown-menu {
+ min-width: 100%;
+ }
+ }
}
.draggable-handler {
display: inline-block;
+ vertical-align: top;
+ margin: 5px 0;
opacity: 0;
transition: opacity .3s;
color: $gray-darkest;
@@ -188,7 +216,7 @@
.toggle-priority {
display: inline-block;
- vertical-align: middle;
+ vertical-align: top;
button {
border-color: transparent;
@@ -255,6 +283,11 @@
}
.label-subscribe-button {
+ @media(min-width: $screen-md-min) {
+ min-width: 105px;
+ margin-left: $gl-padding;
+ }
+
.label-subscribe-button-icon {
&[disabled] {
opacity: 0.5;
@@ -278,6 +311,11 @@
}
.label-link {
- display: inline-block;
- vertical-align: text-top;
+ display: inline-flex;
+ vertical-align: top;
+
+ .label {
+ vertical-align: inherit;
+ font-size: $label-font-size;
+ }
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 3cbe8dededb..b2250a1ce2f 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -22,7 +22,7 @@
}
h1:first-child {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
margin-bottom: 0.68em;
margin-top: 0;
font-size: 34px;
@@ -38,7 +38,7 @@
}
a {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
}
@@ -54,7 +54,7 @@
padding: 15px;
.login-heading h3 {
- font-weight: 300;
+ font-weight: $gl-font-weight-normal;
line-height: 1.5;
margin: 0 0 10px;
}
@@ -95,6 +95,8 @@
}
.omniauth-container {
+ font-size: 13px;
+
p {
margin: 0;
}
@@ -107,13 +109,37 @@
border-top-right-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
+ // Ldap configurations may need more tabs & the tab labels are user generated (arbitrarily long).
+ // These styles prevent this from breaking the layout, and only applied when providers are configured.
+ &.custom-provider-tabs {
+ flex-wrap: wrap;
+
+ li {
+ min-width: 85px;
+ flex-basis: auto;
+
+ // This styles tab elements that have wrapped to a second line. We cannot easily predict when this will happen.
+ // We are making somewhat of an assumption about the configuration here: that users do not have more than
+ // 3 LDAP servers configured (in addition to standard login) and they are not using especially long names for any
+ // of them. If either condition is false, this will work as expected. If both are true, there may be a missing border
+ // above one of the bottom row elements. If you know a better way, please implement it!
+ &:nth-child(n+5) {
+ border-top: 1px solid $border-color;
+ }
+ }
+
+ a {
+ font-size: 16px;
+ }
+ }
+
li {
flex: 1;
text-align: center;
border-left: 1px solid $border-color;
&:first-of-type {
- border-left: none;
+ border-left: 0;
border-top-left-radius: $border-radius-default;
}
@@ -139,7 +165,7 @@
border-bottom: 1px solid $border-color;
a {
- border: none;
+ border: 0;
border-bottom: 2px solid $link-underline-blue;
margin-right: 0;
color: $black;
@@ -152,32 +178,6 @@
}
}
- // Ldap configurations may need more tabs & the tab labels are user generated (arbitrarily long).
- // These styles prevent this from breaking the layout, and only applied when providers are configured.
-
- .new-session-tabs.custom-provider-tabs {
- flex-wrap: wrap;
-
- li {
- min-width: 85px;
- flex-basis: auto;
-
- // This styles tab elements that have wrapped to a second line. We cannot easily predict when this will happen.
- // We are making somewhat of an assumption about the configuration here: that users do not have more than
- // 3 LDAP servers configured (in addition to standard login) and they are not using especially long names for any
- // of them. If either condition is false, this will work as expected. If both are true, there may be a missing border
- // above one of the bottom row elements. If you know a better way, please implement it!
- &:nth-child(n+5) {
- border-top: 1px solid $border-color;
- }
- }
-
- a {
- font-size: 16px;
- }
- }
-
-
.form-control {
&:active,
&:focus {
@@ -186,7 +186,7 @@
}
label {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.submit-container {
@@ -229,35 +229,39 @@
margin: 0;
padding: 0;
height: 100%;
-}
-// Fixes footer container to bottom of viewport
-.devise-layout-html body {
- // offset height of fixed header + 1 to avoid scroll
- height: calc(100% - 51px);
- margin: 0;
- padding: 0;
+ // Fixes footer container to bottom of viewport
+ body {
+ // offset height of fixed header + 1 to avoid scroll
+ height: calc(100% - 51px);
+ margin: 0;
+ padding: 0;
- .page-wrap {
- min-height: 100%;
- position: relative;
- }
+ .page-wrap {
+ min-height: 100%;
+ position: relative;
+ }
- .footer-container,
- hr.footer-fixed {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- height: 40px;
- background: $white-light;
- }
+ .footer-container,
+ hr.footer-fixed {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 40px;
+ background: $white-light;
+ }
- .navless-container {
- padding: 65px 15px; // height of footer + bottom padding of email confirmation link
+ .login-page-broadcast {
+ margin-top: 50px;
+ }
+
+ .navless-container {
+ padding: 65px 15px; // height of footer + bottom padding of email confirmation link
- @media (max-width: $screen-xs-max) {
- padding: 0 15px 65px;
+ @media (max-width: $screen-xs-max) {
+ padding: 0 15px 65px;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index f21005895e4..3422829de58 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -3,41 +3,12 @@
border-bottom: 1px solid $border-color;
}
-.project-member-tabs {
- background: $gray-light;
- border: 1px solid $border-color;
-
- li {
- width: 50%;
-
- &.active {
- background: $white-light;
- }
-
- &:first-child {
- border-right: 1px solid $border-color;
- }
-
- a {
- width: 100%;
- text-align: center;
- }
- }
-}
-
.users-project-form {
.btn-create {
margin-right: 10px;
}
}
-.project-member-tab-content {
- padding: $gl-padding;
- border: 1px solid $border-color;
- border-top: 0;
- margin-bottom: $gl-padding;
-}
-
.member {
.list-item-name {
@media (min-width: $screen-sm-min) {
@@ -46,7 +17,7 @@
}
strong {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
@@ -54,8 +25,10 @@
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;
- width: 400px;
- max-width: 50%;
+ }
+
+ .dropdown-menu.dropdown-menu-align-right {
+ margin-top: -2px;
}
}
@@ -65,7 +38,6 @@
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;
- width: 100%;
margin-top: 3px;
}
}
@@ -77,22 +49,20 @@
width: auto;
}
}
+
+ &.existing-title {
+ @media (min-width: $screen-sm-min) {
+ float: left;
+ }
+ }
}
.member-form-control {
@media (max-width: $screen-xs-max) {
- padding: 5px 0;
+ padding-bottom: 5px;
margin-left: 0;
margin-right: 0;
}
-
- @media (min-width: $screen-sm-min) {
- width: 50%;
- }
-
- .dropdown-menu-toggle {
- width: 100%;
- }
}
.member-access-text {
@@ -100,12 +70,6 @@
line-height: 43px;
}
-.member.existing-title {
- @media (min-width: $screen-sm-min) {
- float: left;
- }
-}
-
.member-search-form {
position: relative;
@@ -216,3 +180,102 @@
}
}
}
+
+.content-list.members-list li {
+ display: flex;
+ justify-content: space-between;
+
+ .list-item-name {
+ float: none;
+ display: flex;
+ flex: 1;
+ }
+
+ .user-info {
+ padding-right: 10px;
+ }
+
+ .member {
+ font-weight: $gl-font-weight-bold;
+ overflow-wrap: break-word;
+ word-break: break-all;
+ }
+
+ .member-group-link {
+ display: inline-block;
+ }
+
+ .form-control {
+ width: inherit;
+ }
+
+ .btn {
+ align-self: flex-start;
+ }
+
+ .form-horizontal ~ .btn {
+ margin-right: 0;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+
+ .controls > .btn {
+ margin-left: 0;
+ margin-right: 0;
+ display: block;
+ }
+
+ .form-control {
+ width: 100%;
+ }
+
+ .member-access-text {
+ line-height: 0;
+ margin-left: 50px;
+ }
+
+ .member-controls {
+ margin-top: 5px;
+ }
+
+ .form-horizontal {
+ margin-top: 10px;
+ }
+ }
+}
+
+.panel-mobile {
+ .content-list.members-list li {
+ display: block;
+
+ .member-controls {
+ float: none;
+ display: block;
+ }
+
+ .dropdown-menu-toggle,
+ .dropdown-menu,
+ .form-control,
+ .list-item-name {
+ width: 100%;
+ }
+
+ .dropdown-menu {
+ margin-top: 0;
+ }
+
+ .form-horizontal {
+ display: block;
+ }
+
+ .member-form-control {
+ margin: 5px 0;
+ }
+
+ .btn {
+ width: 100%;
+ margin-left: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
index 35cefd449f1..04bde64c752 100644
--- a/app/assets/stylesheets/pages/merge_conflicts.scss
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -255,14 +255,14 @@ $colors: (
&.saved {
.editor {
- border-top: solid 2px $green-200;
+ border-top: solid 2px $green-300;
}
}
.editor {
pre {
height: 350px;
- border: none;
+ border: 0;
border-radius: 0;
margin-bottom: 0;
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 59e0624d94e..4692d0fb873 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -2,10 +2,35 @@
* MR -> show: Automerge widget
*
*/
+
+.space-children {
+ @include clearfix;
+
+ > * {
+ float: left;
+ }
+
+ > *:not(:last-child) {
+ margin-right: 10px;
+ }
+}
+
.mr-state-widget {
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 2px;
+ line-height: 28px;
+
+ .mr-widget-heading,
+ .mr-widget-section,
+ .mr-widget-footer {
+ padding: $gl-padding;
+ border-top: solid 1px $border-color;
+ }
+
+ .mr-widget-footer {
+ padding: 0;
+ }
form {
margin-bottom: 0;
@@ -15,15 +40,35 @@
}
}
+ label {
+ margin-bottom: 0;
+ }
+
+ .btn {
+ font-size: $gl-font-size;
+
+ &[disabled] {
+ opacity: 0.3;
+ }
+
+ &.btn-xs {
+ line-height: 1;
+ padding: 5px 10px;
+ margin-top: 1px;
+ }
+
+ &.dropdown-toggle {
+ .fa {
+ color: inherit;
+ }
+ }
+ }
+
.accept-merge-holder {
.accept-action {
display: inline-block;
float: left;
- .btn-success.dropdown-toggle .fa {
- color: inherit;
- }
-
.accept-merge-request {
&.ci-pending,
&.ci-running {
@@ -84,77 +129,45 @@
.ci-widget {
color: $gl-text-color;
- display: -webkit-flex;
display: flex;
- -webkit-align-items: center;
- align-items: center;
- padding: $gl-padding-top $gl-padding 0;
-
- svg {
- position: relative;
- top: 1px;
- overflow: visible;
- }
-
- & > span {
- padding-right: 4px;
- }
@media (max-width: $screen-xs-max) {
flex-wrap: wrap;
}
+ }
- .icon-link > .ci-status-icon > svg {
- width: 22px;
- height: 22px;
- margin-right: 8px;
- }
-
- .ci-error {
- margin-right: $btn-side-margin;
- }
+ .mr-widget-icon {
+ font-size: 22px;
+ margin-right: $status-icon-margin;
}
- .mr-widget-body,
- .mr-widget-footer {
- margin: 16px;
+ .ci-status-icon svg {
+ width: $status-icon-size;
+ height: $status-icon-size;
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
}
.mr-widget-pipeline-graph {
- flex-shrink: 0;
+ padding: 0 4px;
.dropdown-menu {
- margin-top: 11px;
- z-index: 200;
+ z-index: 300;
}
.ci-action-icon-wrapper {
line-height: 16px;
}
+ }
- @media (max-width: $screen-xs-max) {
- order: 1;
- margin-top: $gl-padding-top;
- border-radius: 3px;
- background-color: $white-light;
- border: 1px solid $gray-darker;
- width: 100%;
- text-align: center;
-
- .dropdown-menu {
- margin-left: -97.5px;
- }
-
- .arrow-up::before,
- .arrow-up::after, {
- margin-left: 97.5px;
- }
- }
+ .mini-pipeline-graph-dropdown-toggle {
+ vertical-align: top;
}
.normal {
- color: $gl-text-color;
- font-size: 15px;
+ line-height: 28px;
}
.capitalize {
@@ -165,9 +178,8 @@
@extend .ref-name;
color: $gl-text-color;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
overflow: hidden;
- margin: 0 3px;
word-break: break-all;
&.label-truncated {
@@ -189,59 +201,31 @@
}
}
- .js-deployment-link {
- display: inline-block;
- }
-
- .mr-widget-help {
- margin: $gl-padding;
- color: $ci-skipped-color;
- }
-
- .mr-info-list {
-
- &.mr-links {
- margin-left: 28px;
- }
-
- &.mr-memory-usage {
- margin: 5px 0 10px 25px;
- }
- }
-
- .mr-widget-heading {
- .btn-default.btn-xs {
- margin-left: 5px;
- }
- }
-
.mr-widget-body {
- .btn {
- font-size: 15px;
- }
+ @include clearfix;
- .btn-group .btn {
- padding: 5px 10px;
+ &.media > *:first-child {
+ margin-right: 10px;
+ }
- &.dropdown-toggle {
- padding: 5px 7px;
- }
+ .approve-btn {
+ margin-right: 5px;
}
- }
- .mr-widget-body {
h4 {
- font-weight: bold;
- font-size: 15px;
- margin: 5px 0;
- color: $gl-text-color;
+ float: left;
+ font-weight: $gl-font-weight-bold;
+ font-size: 14px;
+ line-height: inherit;
+ margin-top: 0;
+ margin-bottom: 0;
&.has-conflicts .fa-exclamation-triangle {
color: $gl-warning;
}
time {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
}
@@ -251,22 +235,20 @@
}
label {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.spacing {
- margin: 0 $gl-padding;
+ margin: 0 0 0 10px;
}
.bold {
- font-weight: bold;
- font-size: 15px;
+ font-weight: $gl-font-weight-bold;
color: $gl-gray-light;
}
.state-label {
- font-size: 16px;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
padding-right: 10px;
}
@@ -274,16 +256,6 @@
color: $gl-danger;
}
- .mr-widget-help {
- margin: $gl-padding 0;
- }
-
- .with-button {
- position: relative;
- top: 6px;
- margin-bottom: 24px;
- }
-
.spacing,
.bold {
vertical-align: middle;
@@ -294,15 +266,8 @@
padding: 5px;
}
- .merge-opt-icon,
- .merge-opt-title {
- display: inline-block;
- float: left;
- }
-
- .merge-opt-icon svg {
- height: 15px;
- width: 15px;
+ .merge-opt-icon {
+ line-height: 1.5;
}
.merge-opt-title {
@@ -312,38 +277,20 @@
.dropdown-toggle {
.fa {
+ margin-left: 0;
color: inherit;
}
}
- .has-error-message + .has-custom-error {
- margin-left: 0;
- }
-
.has-custom-error {
display: inline-block;
- margin-left: 70px;
- }
-
- .merge-error-text {
- margin-left: 70px;
}
@media (max-width: $screen-xs-max) {
- h4 {
- font-size: 14px;
- }
-
p {
font-size: 13px;
}
- .btn,
- .btn-group,
- .accept-action {
- margin-bottom: 4px;
- }
-
.btn-grouped {
float: none;
margin-right: 0;
@@ -367,19 +314,16 @@
}
}
- &.mr-state-locked .mr-info-list {
- margin-top: 10px;
- margin-left: 12px;
- }
+ &.mr-widget-empty-state {
+ line-height: 20px;
- &.empty-state {
.artwork {
margin-bottom: $gl-padding;
}
.text {
span {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
p {
@@ -389,8 +333,9 @@
}
}
- .mr-widget-footer {
- border-top: 1px solid $gray-darker;
+ .mr-widget-help {
+ padding: 10px 16px 10px 48px;
+ font-style: italic;
}
.ci-coverage {
@@ -407,10 +352,8 @@
}
}
-.mr-state-widget .mr-widget-body {
- .approve-btn {
- margin-right: 5px;
- }
+.mr-widget-body-controls {
+ flex-wrap: wrap;
}
.mr_source_commit,
@@ -441,6 +384,12 @@
}
}
+.nothing-here-block {
+ img {
+ width: 230px;
+ }
+}
+
.mr-list {
.merge-request {
padding: 10px 0 10px 15px;
@@ -497,8 +446,6 @@
}
.btn-clipboard {
- @extend .pull-right;
-
margin-right: 20px;
margin-top: 5px;
position: absolute;
@@ -506,71 +453,45 @@
}
}
+.mr-links {
+ padding-left: $status-icon-size + $status-icon-margin;
+}
+
.mr-info-list {
+ clear: left;
position: relative;
- margin: 10px 0 $gl-padding 12px;
+ padding-top: 4px;
p {
- margin: 6px 0;
+ margin: 0;
position: relative;
- padding-left: 15px;
-
- &::before {
- content: '';
- position: absolute;
- border-top: 2px solid $border-color;
- height: 1px;
- top: 9px;
- width: 8px;
- left: 0;
- }
+ padding: 4px 0;
&:last-child {
- margin-bottom: 0;
+ padding-bottom: 0;
}
}
- .legend {
- height: 100%;
- width: 2px;
- background: $border-color;
- position: absolute;
- top: -9px;
- }
-}
-
-.mr-info-list.mr-memory-usage {
- .legend {
- height: 65%;
- top: 0;
-
- @media (max-width: $screen-xs-max) {
- height: 20px;
+ &.mr-memory-usage {
+ p {
+ float: left;
}
- }
-
- p {
- float: left;
- padding-left: 21px;
- &::before {
- top: 13px;
+ .memory-graph-container {
+ float: left;
+ margin-left: 5px;
}
}
-
- .memory-graph-container {
- float: left;
- margin-left: 5px;
- }
}
.mr-source-target {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
background-color: $gray-light;
- border-radius: 3px 3px 0 0;
- border-bottom: 1px solid $border-color;
- padding: 0 $gl-padding;
- margin-bottom: 6px;
- line-height: 44px;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ padding: $gl-padding / 2 $gl-padding;
.dropdown-toggle .fa {
color: $gl-text-color;
@@ -580,7 +501,7 @@
.panel-new-merge-request {
.panel-heading {
padding: 5px 10px;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
line-height: 25px;
}
@@ -679,20 +600,16 @@
}
.merged-buttons {
- margin-top: 20px;
-
.btn {
float: left;
-
- &:not(:last-child) {
- margin-right: 10px;
- }
}
}
.mr-version-controls {
+ position: relative;
background: $gray-light;
color: $gl-text-color;
+ z-index: 199;
.mr-version-menus-container {
display: -webkit-flex;
@@ -731,11 +648,11 @@
.merge-request-tabs-holder {
top: $header-height;
- z-index: 100;
+ z-index: 200;
background-color: $white-light;
border-bottom: 1px solid $border-color;
- @media(min-width: $screen-sm-min) {
+ @media (min-width: $screen-sm-min) {
position: sticky;
position: -webkit-sticky;
}
@@ -759,6 +676,10 @@
}
}
+.with-performance-bar .merge-request-tabs-holder {
+ top: $header-height + $performance-bar-height;
+}
+
.merge-request-tabs {
display: flex;
margin-bottom: 0;
@@ -770,6 +691,12 @@
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
+
+ .inner-page-scroll-tabs {
+ background-color: $white-light;
+ margin-left: -$gl-padding;
+ padding-left: $gl-padding;
+ }
}
}
@@ -791,20 +718,47 @@
}
.mr-memory-usage {
- p.usage-info-loading,
- p.usage-info-unavailable,
- p.usage-info-failed {
- margin-bottom: 5px;
- }
+ width: 100%;
p.usage-info-loading .usage-info-load-spinner {
margin-right: 10px;
font-size: 16px;
}
+}
- @media (max-width: $screen-md-min) {
- .mr-info-list.mr-memory-usage .legend {
- height: 80%;
- }
+.fork-sprite {
+ margin-right: -5px;
+}
+
+.deploy-heading {
+ .media-body {
+ min-width: 0;
+ }
+}
+
+.deploy-body {
+ display: flex;
+ flex-wrap: wrap;
+
+ @media (min-width: $screen-xs) {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ }
+
+ > *:not(:last-child) {
+ margin-right: .3em;
+ }
+}
+
+.deploy-link {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 100px;
+ max-width: 150px;
+
+ @media (min-width: $screen-xs) {
+ min-width: 0;
+ max-width: 100%;
}
}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 55e0ee1936e..e5afa8fffcb 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -7,7 +7,7 @@
padding: 10px 16px;
h4 {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
.progress {
@@ -66,6 +66,15 @@
height: 6px;
margin: 0;
}
+
+ .sidebar-collapsed-icon {
+ clear: both;
+ padding: 15px 5px 5px;
+
+ .progress {
+ margin: 5px 0;
+ }
+ }
}
.collapsed-milestone-date {
@@ -81,7 +90,7 @@
}
.remaining-days strong {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.milestone-stat {
@@ -93,17 +102,6 @@
margin-right: 0;
}
- .milestone-progress {
- .sidebar-collapsed-icon {
- clear: both;
- padding: 15px 5px 5px;
-
- .progress {
- margin: 5px 0;
- }
- }
- }
-
.right-sidebar-collapsed & {
.reference {
border-top: 1px solid $border-gray-normal;
@@ -117,6 +115,10 @@
display: block;
margin-top: 7px;
+ .issue-link {
+ display: inline-block;
+ }
+
.issuable-number {
color: $gl-text-color-secondary;
margin-right: 5px;
@@ -156,18 +158,16 @@
.status-box {
margin-top: 0;
- }
-
- .milestone-buttons {
- margin-left: auto;
- }
-
- .status-box {
order: 1;
}
.milestone-buttons {
+ margin-left: auto;
order: 2;
+
+ .verbose {
+ display: none;
+ }
}
.header-text-content {
@@ -175,10 +175,6 @@
width: 100%;
}
- .milestone-buttons .verbose {
- display: none;
- }
-
@media (min-width: $screen-xs-min) {
.milestone-buttons .verbose {
display: inline;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 9877ed2cfd6..4a528bc2bb1 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -7,7 +7,7 @@
.diff-file .diff-content {
tr.line_holder:hover > td .line_note_link {
- opacity: 1.0;
+ opacity: 1;
filter: alpha(opacity = 100);
}
}
@@ -16,14 +16,10 @@
.discussion {
.new-note {
margin: 0;
- border: none;
+ border: 0;
}
}
-.new-note {
- display: none;
-}
-
.new-note,
.note-edit-form {
.note-form-actions {
@@ -103,47 +99,87 @@
}
}
-.confidential-issue-warning {
- background-color: $gray-normal;
- border-radius: 3px;
+.issuable-note-warning {
+ color: $orange-600;
+ background-color: $orange-100;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ border: 1px solid $border-gray-normal;
+ border-bottom: 0;
padding: 3px 12px;
margin: auto;
- margin-top: 0;
- text-align: center;
- font-size: 12px;
align-items: center;
- @media (max-width: $screen-md-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;
- margin: 6px auto;
- width: 100%;
+ .icon {
+ margin-right: $issuable-warning-icon-margin;
+ vertical-align: text-bottom;
+ fill: $orange-600;
}
- .fa {
- margin-right: 8px;
+ + .md-area {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ .disabled-comment {
+ border: 0;
+ border-radius: $label-border-radius;
+ padding-top: $gl-vert-padding;
+ padding-bottom: $gl-vert-padding;
+
+ .icon svg {
+ position: relative;
+ top: 2px;
+ margin-right: $btn-xs-side-margin;
+ width: $gl-font-size;
+ height: $gl-font-size;
+ fill: $orange-600;
+ }
}
}
-.right-sidebar-expanded {
- .confidential-issue-warning {
- // When the sidebar is open 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;
- margin: 6px auto;
- width: 100%;
+.sidebar-item-icon {
+ border-radius: $border-radius-default;
+ margin: 0 5px 0 0;
+ vertical-align: text-bottom;
+
+ &.is-active {
+ fill: $orange-600;
+ }
+
+ .sidebar-collapsed-icon & {
+ margin: 0;
+ }
+
+ .sidebar-item-value & {
+ fill: $theme-gray-700;
}
}
+.sidebar-item-warning-message {
+ line-height: 1.5;
+ padding: 16px;
+
+ .text {
+ color: $text-color;
+ }
+
+ .sidebar-item-warning-message-actions {
+ display: flex;
+
+ .btn {
+ flex-grow: 1;
+ }
+ }
+}
.discussion-form {
- padding: $gl-padding-top $gl-padding $gl-padding;
background-color: $white-light;
}
+.discussion-form-container {
+ padding: $gl-padding-top $gl-padding $gl-padding;
+}
+
.discussion-notes .disabled-comment {
padding: 6px 0;
}
@@ -189,7 +225,7 @@
.close {
color: $white-light;
opacity: 0.85;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
&:hover {
opacity: 1;
@@ -203,6 +239,10 @@
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
+
+ &.is-replying {
+ padding-bottom: $gl-padding;
+ }
}
}
@@ -221,13 +261,12 @@
width: 100%;
padding-right: 5px;
}
-
}
.discussion-actions {
display: table;
- .new-issue-for-discussion path {
+ .btn-default path {
fill: $gray-darkest;
}
@@ -352,69 +391,23 @@
.dropdown-toggle {
float: right;
- .toggle-icon {
+ i {
color: $white-light;
padding-right: 2px;
margin-top: 2px;
- pointer-events: none;
}
- }
- .dropdown-menu {
- top: initial;
- bottom: 40px;
- width: 298px;
- }
-
- .description {
- display: inline-block;
- white-space: normal;
- margin-left: 8px;
- padding-right: 33px;
- }
-
- li {
- padding-top: 6px;
-
- & > a {
- margin: 0;
- padding: 0;
- color: inherit;
- border-radius: 0;
- text-overflow: inherit;
-
- &:hover,
- &:focus {
- background-color: inherit;
- color: inherit;
+ &[disabled] {
+ i {
+ color: $gl-text-color-disabled;
}
}
-
- &:hover,
- &:focus {
- background-color: $dropdown-hover-color;
- color: $white-light;
- }
-
- &.droplab-item-selected i {
- visibility: visible;
- }
-
- i {
- visibility: hidden;
- }
- }
-
- i {
- display: inline-block;
- vertical-align: top;
- padding-top: 2px;
}
- .divider {
- margin: 0 8px;
- padding: 0;
- border-top: $gray-darkest;
+ .dropdown-menu {
+ top: initial;
+ bottom: 100%;
+ width: 298px;
}
@media (max-width: $screen-xs-max) {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index f0dbe4249c5..81e98f358a8 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -16,7 +16,7 @@ ul.notes {
.note-created-ago,
.note-updated-at {
- white-space: nowrap;
+ white-space: normal;
}
.discussion-body {
@@ -100,6 +100,20 @@ ul.notes {
}
}
+ .editing-spinner {
+ display: none;
+ }
+
+ &.is-requesting {
+ .note-timestamp {
+ display: none;
+ }
+
+ .editing-spinner {
+ display: inline-block;
+ }
+ }
+
&.is-editing {
.note-header,
.note-text,
@@ -121,15 +135,10 @@ ul.notes {
overflow-y: hidden;
.note-text {
- word-wrap: break-word;
@include md-typography;
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
- ul.task-list {
- ul:not(.task-list) {
- padding-left: 1.3em;
- }
- }
+ word-wrap: break-word;
table {
@include markdown-table;
@@ -193,7 +202,6 @@ ul.notes {
a {
color: $gl-link-color;
- text-decoration: none;
}
p {
@@ -250,11 +258,11 @@ ul.notes {
}
.note-text {
- & p:first-child {
+ p:first-child {
display: none;
}
- &.system-note-commit-list {
+ &.system-note-commit-list:not(.hide-shade) {
max-height: 70px;
overflow: hidden;
display: block;
@@ -276,16 +284,6 @@ ul.notes {
bottom: 0;
background: linear-gradient(rgba($white-light, 0.1) -100px, $white-light 100%);
}
-
- &.hide-shade {
- max-height: 100%;
- overflow: auto;
-
- &::after {
- display: none;
- background: transparent;
- }
- }
}
}
}
@@ -307,66 +305,79 @@ ul.notes {
}
}
-.diff-file .notes_holder {
- font-family: $regular_font;
+.diff-file {
+ .is-over {
+ .add-diff-note {
+ display: inline-block;
+ }
+ }
- td {
- border: 1px solid $white-normal;
- border-left: none;
+ // Merge request notes in diffs
+ // Diff is inline
+ .notes_content .note-header .note-headline-light {
+ display: inline-block;
+ position: relative;
+ }
- &.notes_line {
- vertical-align: middle;
- text-align: center;
- padding: 10px 0;
- background: $gray-light;
- color: $text-color;
- }
+ .notes_holder {
+ font-family: $regular_font;
- &.notes_line2 {
- text-align: center;
- padding: 10px 0;
- border-left: 1px solid $note-line2-border !important;
- }
+ td {
+ border: 1px solid $white-normal;
+ border-left: 0;
- &.notes_content {
- background-color: $gray-light;
- border-width: 1px 0;
- padding: 0;
- vertical-align: top;
- white-space: normal;
+ &.notes_line {
+ vertical-align: middle;
+ text-align: center;
+ padding: 10px 0;
+ background: $gray-light;
+ color: $text-color;
+ }
- &.parallel {
- border-width: 1px;
+ &.notes_line2 {
+ text-align: center;
+ padding: 10px 0;
+ border-left: 1px solid $note-line2-border !important;
}
- .discussion-notes {
- &:not(:first-child) {
- border-top: 1px solid $white-normal;
- margin-top: 20px;
+ &.notes_content {
+ background-color: $gray-light;
+ border-width: 1px 0;
+ padding: 0;
+ vertical-align: top;
+ white-space: normal;
+
+ &.parallel {
+ border-width: 1px;
}
- &:not(:last-child) {
- border-bottom: 1px solid $white-normal;
- margin-bottom: 20px;
+ .discussion-notes {
+ &:not(:first-child) {
+ border-top: 1px solid $white-normal;
+ margin-top: 20px;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $white-normal;
+ margin-bottom: 20px;
+ }
}
- }
- .notes {
- background-color: $white-light;
- }
+ .notes {
+ background-color: $white-light;
+ }
- a code {
- top: 0;
- margin-right: 0;
+ a code {
+ top: 0;
+ margin-right: 0;
+ }
}
}
}
}
.discussion-header,
-.note-header {
- position: relative;
-
+.note-header-info {
a {
color: inherit;
@@ -377,6 +388,10 @@ ul.notes {
&:focus,
&:hover {
text-decoration: none;
+
+ .note-header-author-name {
+ text-decoration: underline;
+ }
}
}
@@ -401,6 +416,10 @@ ul.notes {
.note-header-info {
min-width: 0;
padding-bottom: 8px;
+
+ &.discussion {
+ padding-bottom: 0;
+ }
}
.system-note .note-header-info {
@@ -439,6 +458,10 @@ ul.notes {
.system-note-message {
white-space: normal;
}
+
+ a:hover {
+ text-decoration: underline;
+ }
}
/**
@@ -449,12 +472,22 @@ ul.notes {
float: right;
margin-left: 10px;
color: $gray-darkest;
+
+ @include notes-media('max', $screen-md-max) {
+ float: none;
+ margin-left: 0;
+ }
+
+ .btn-group > .discussion-next-btn {
+ margin-left: -1px;
+ }
}
.note-actions {
+ align-self: flex-start;
flex-shrink: 0;
- // For PhantomJS that does not support flex
- float: right;
+ display: inline-flex;
+ align-items: center;
margin-left: 10px;
color: $gray-darkest;
@@ -462,18 +495,11 @@ ul.notes {
float: none;
margin-left: 0;
}
-
- .note-action-button {
- margin-left: 8px;
- }
-
- .more-actions-toggle {
- margin-left: 2px;
- }
}
.more-actions {
- display: inline;
+ display: flex;
+ align-items: flex-end;
.tooltip {
white-space: nowrap;
@@ -481,65 +507,42 @@ ul.notes {
}
.more-actions-toggle {
- padding: 0;
-
&: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 {
- @include notes-media('max', $screen-md-max) {
- float: none;
- margin-left: 0;
+.note-actions-item {
+ margin-left: 12px;
+ display: flex;
+ align-items: center;
- .note-action-button {
- margin-left: 0;
- }
+ &.more-actions {
+ // compensate for narrow icon
+ margin-left: 10px;
}
}
.note-action-button {
- display: inline;
- line-height: 20px;
+ line-height: 1;
+ padding: 0;
+ min-width: 16px;
+ color: $gray-darkest;
+ fill: $gray-darkest;
.fa {
- color: $gray-darkest;
position: relative;
- font-size: 17px;
+ font-size: 16px;
}
svg {
- height: 16px;
- width: 16px;
- fill: $gray-darkest;
- vertical-align: text-top;
+ @include btn-svg;
}
.award-control-icon-positive,
@@ -558,10 +561,7 @@ ul.notes {
.link-highlight {
color: $gl-link-color;
-
- svg {
- fill: $gl-link-color;
- }
+ fill: $gl-link-color;
}
.award-control-icon-neutral {
@@ -611,49 +611,34 @@ ul.notes {
}
.note-role {
+ margin: 0 3px;
+}
+
+.note-role-special {
position: relative;
- top: -2px;
display: inline-block;
- padding-left: 7px;
- padding-right: 7px;
- color: $notes-role-color;
+ color: $gl-text-color-secondary;
font-size: 12px;
- line-height: 20px;
- border: 1px solid $border-color;
- border-radius: $label-border-radius;
+ text-shadow: 0 0 15px $gl-text-color-inverted;
}
-
/**
* Line note button on the side of diffs
*/
+.line_holder .is-over:not(.no-comment-btn) {
+ .add-diff-note {
+ opacity: 1;
+ }
+}
+
.add-diff-note {
- display: none;
+ @include btn-comment-icon;
+ opacity: 0;
margin-top: -2px;
- border-radius: 50%;
- background: $white-light;
- padding: 1px 5px;
- font-size: 12px;
- color: $blue-500;
margin-left: -55px;
position: absolute;
z-index: 10;
- width: 23px;
- height: 23px;
- border: 1px solid $blue-500;
- transition: transform .1s ease-in-out;
-
- &:hover {
- background: $blue-500;
- border-color: $blue-600;
- color: $white-light;
- transform: scale(1.15);
- }
-
- &:active {
- outline: 0;
- }
}
.discussion-body,
@@ -664,15 +649,7 @@ ul.notes {
.timeline-entry-inner {
padding-left: $gl-padding;
padding-right: $gl-padding;
- border-bottom: none;
- }
- }
-}
-
-.diff-file {
- .is-over {
- .add-diff-note {
- display: inline-block;
+ border-bottom: 0;
}
}
}
@@ -684,6 +661,12 @@ ul.notes {
color: $note-disabled-comment-color;
padding: 90px 0;
+ &.discussion-locked {
+ border: 0;
+ background-color: $white-light;
+ }
+
+
a {
color: $gl-link-color;
}
@@ -708,23 +691,25 @@ ul.notes {
border-bottom-left-radius: 0;
}
- .btn.discussion-create-issue-btn {
- margin-left: -4px;
- border-radius: 0;
- border-right: 0;
+ .btn {
+ svg path {
+ fill: $gray-darkest;
+ }
- a {
- padding: 0;
- line-height: 0;
+ &.discussion-create-issue-btn {
+ margin-left: -4px;
+ border-radius: 0;
+ border-right: 0;
- &:hover {
- text-decoration: none;
- border: 0;
- }
- }
+ a {
+ padding: 0;
+ line-height: 0;
- .new-issue-for-discussion path {
- fill: $gray-darkest;
+ &:hover {
+ text-decoration: none;
+ border: 0;
+ }
+ }
}
}
}
@@ -732,7 +717,7 @@ ul.notes {
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: 5px 10px 6px;
+ padding: 6px 10px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
@@ -757,28 +742,31 @@ ul.notes {
top: 0;
padding: 0;
background-color: transparent;
- border: none;
+ border: 0;
outline: 0;
+ color: $gray-darkest;
+ transition: color $general-hover-transition-duration $general-hover-transition-curve;
&.is-disabled {
cursor: default;
}
- &:not(.is-disabled):hover,
+ &:not(.is-disabled) {
+ &:hover,
+ &:focus {
+ color: $gl-text-green;
+ }
+ }
+
&.is-active {
color: $gl-text-green;
- svg {
- fill: $gl-text-green;
+ &:hover,
+ &:focus {
+ color: $gl-text-green-hover;
}
}
- svg {
- fill: $gray-darkest;
- height: 16px;
- width: 16px;
- }
-
.loading {
margin: 0;
height: auto;
@@ -788,26 +776,3 @@ ul.notes {
.line-resolve-text {
vertical-align: middle;
}
-
-.discussion-next-btn {
- svg {
- margin: 0;
-
- path {
- fill: $gray-darkest;
- }
- }
-}
-
-.discussion-notes .flash-container {
- margin-bottom: 0;
-}
-
-// Merge request notes in diffs
-.diff-file {
- // Diff is inline
- .notes_content .note-header .note-headline-light {
- display: inline-block;
- position: relative;
- }
-}
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
index 595eb40fec7..bc7fa8a26d9 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -1,7 +1,7 @@
.js-pipeline-schedule-form {
.dropdown-select,
.dropdown-menu-toggle {
- width: 100%!important;
+ width: 100% !important;
}
.gl-field-error {
@@ -12,7 +12,7 @@
.interval-pattern-form-group {
label {
margin-right: 10px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
&[for='custom'] {
margin-right: 0;
@@ -39,6 +39,10 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ }
}
.next-run-cell {
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 9637d26e56d..42772f13155 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -31,7 +31,6 @@
}
.pipeline-actions {
- padding-right: 0;
min-width: 170px; //Guarantees buttons don't break in several lines.
.btn-default {
@@ -40,7 +39,7 @@
.btn.btn-retry:hover,
.btn.btn-retry:focus {
- border-color: $gray-darkest;
+ border-color: $dropdown-toggle-active-border-color;
background-color: $white-normal;
}
@@ -49,7 +48,7 @@
}
.dropdown-menu {
- max-height: 250px;
+ max-height: $dropdown-max-height;
overflow-y: auto;
}
@@ -70,13 +69,6 @@
border-color: $border-white-normal;
}
}
-
- .btn {
- .icon-play {
- height: 13px;
- width: 12px;
- }
- }
}
.btn .text-center {
@@ -128,8 +120,8 @@
.branch-commit {
.ref-name {
- font-weight: bold;
- max-width: 120px;
+ font-weight: $gl-font-weight-bold;
+ max-width: 100px;
overflow: hidden;
display: inline-block;
white-space: nowrap;
@@ -144,6 +136,12 @@
fill: $gl-text-color-secondary;
}
+ .sprite {
+ width: 12px;
+ height: 12px;
+ fill: $gl-text-color;
+ }
+
.fa {
font-size: 12px;
color: $gl-text-color;
@@ -176,6 +174,25 @@
}
}
+ /**
+ * Play button with icon in dropdowns
+ */
+ .no-btn {
+ border: 0;
+ background: none;
+ outline: none;
+ width: 100%;
+ text-align: left;
+
+ .icon-play {
+ position: relative;
+ top: 2px;
+ margin-right: 5px;
+ height: 13px;
+ width: 12px;
+ }
+ }
+
.duration,
.finished-at {
color: $gl-text-color-secondary;
@@ -202,12 +219,23 @@
.btn-group.open .dropdown-toggle {
box-shadow: none;
}
+
+ .pipeline-tags .label-container {
+ white-space: normal;
+ }
}
.stage-cell {
+ &.table-section {
+ @media (min-width: $screen-md-min) {
+ min-width: 160px; /* Hack alert: Without this the mini graph pipeline won't work properly*/
+ margin-right: -4px;
+ }
+ }
+
.mini-pipeline-graph-dropdown-toggle svg {
- height: 22px;
- width: 22px;
+ height: $ci-action-icon-size;
+ width: $ci-action-icon-size;
position: absolute;
top: -1px;
left: -1px;
@@ -219,8 +247,12 @@
display: inline-block;
position: relative;
vertical-align: middle;
- height: 22px;
- margin: 3px 6px 3px 0;
+ height: $ci-action-icon-size;
+ margin: 3px 0;
+
+ + .stage-container {
+ margin-left: 6px;
+ }
// Hack to show a button tooltip inline
button.has-tooltip + .tooltip {
@@ -253,7 +285,7 @@
// Pipeline visualization
.pipeline-actions {
- border-bottom: none;
+ border-bottom: 0;
}
.tab-pane {
@@ -268,7 +300,7 @@
.build-name {
float: right;
- font-weight: 500;
+ font-weight: $gl-font-weight-normal;
}
.ci-status-icon-failed svg {
@@ -277,25 +309,28 @@
.stage {
color: $gl-text-color-secondary;
- font-weight: 500;
+ font-weight: $gl-font-weight-normal;
vertical-align: middle;
}
}
.build-log {
- border: none;
+ border: 0;
line-height: initial;
}
}
-// Pipeline graph
-.pipeline-graph {
+.pipeline-tab-content {
width: 100%;
background-color: $gray-light;
padding: $gl-padding;
+ overflow: auto;
+}
+
+// Pipeline graph
+.pipeline-graph {
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
- overflow: auto;
.stage-column-list,
.builds-container > ul {
@@ -304,7 +339,7 @@
a {
text-decoration: none;
- color: $gl-text-color-secondary;
+ color: $gl-text-color;
}
svg {
@@ -351,13 +386,13 @@
// Remove right connecting horizontal line from first build in last stage
&:first-child {
&::after {
- border: none;
+ border: 0;
}
}
// Remove right curved connectors from all builds in last stage
&:not(:first-child) {
&::after {
- border: none;
+ border: 0;
}
}
// Remove opposite curve
@@ -374,7 +409,7 @@
// Remove left curved connectors from all builds in first stage
&:not(:first-child) {
&::before {
- border: none;
+ border: 0;
}
}
// Remove opposite curve
@@ -416,7 +451,7 @@
.stage-name {
margin: 0 0 15px 10px;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
width: 176px;
white-space: nowrap;
overflow: hidden;
@@ -428,38 +463,52 @@
width: 186px;
margin-bottom: 10px;
white-space: normal;
- color: $gl-text-color-secondary;
-
- // Action Icons in big pipeline-graph nodes
- .ci-action-icon-container .ci-action-icon-wrapper {
- height: 30px;
- width: 30px;
- background: $white-light;
- border: 1px solid $border-color;
- border-radius: 100%;
- display: block;
- &:hover {
- background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-bg;
- }
-
- svg {
- fill: $border-color;
- position: relative;
- left: -1px;
- top: -1px;
- }
-
- &:hover svg {
- fill: $gl-text-color;
- }
+ // ensure .build-content has hover style when action-icon is hovered
+ .ci-job-dropdown-container:hover .build-content {
+ @extend .build-content:hover;
}
.ci-action-icon-container {
position: absolute;
right: 5px;
top: 5px;
+
+ // Action Icons in big pipeline-graph nodes
+ &.ci-action-icon-wrapper {
+ height: 30px;
+ width: 30px;
+ background: $white-light;
+ border: 1px solid $border-color;
+ border-radius: 100%;
+ display: block;
+
+ &:hover {
+ background-color: $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
+
+ svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ svg {
+ fill: $gl-text-color-secondary;
+ position: relative;
+ left: 5px;
+ top: 2px;
+ width: 18px;
+ height: 18px;
+ }
+
+ &.play {
+ svg {
+ width: #{$ci-action-icon-size - 8};
+ height: #{$ci-action-icon-size - 8};
+ left: 8px;
+ }
+ }
+ }
}
.ci-status-icon svg {
@@ -469,21 +518,12 @@
.dropdown-menu-toggle {
background-color: transparent;
- border: none;
+ border: 0;
padding: 0;
- color: $gl-text-color-secondary;
&:focus {
outline: none;
}
-
- &:hover {
- color: $gl-text-color;
-
- .dropdown-counter-badge {
- color: $gl-text-color;
- }
- }
}
.build-content {
@@ -498,8 +538,7 @@
a.build-content:hover,
button.build-content:hover {
background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-border;
- color: $gl-text-color;
+ border: 1px solid $dropdown-toggle-active-border-color;
}
@@ -560,7 +599,6 @@
// Triggers the dropdown in the big pipeline graph
.dropdown-counter-badge {
- color: $border-color;
font-weight: 100;
font-size: 15px;
position: absolute;
@@ -576,7 +614,7 @@
vertical-align: bottom;
display: inline-block;
position: relative;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
@mixin mini-pipeline-graph-color($color-light, $color-main, $color-dark) {
@@ -597,17 +635,18 @@
}
// Dropdown button in mini pipeline graph
-.mini-pipeline-graph-dropdown-toggle {
+button.mini-pipeline-graph-dropdown-toggle {
border-radius: 100px;
background-color: $white-light;
border-width: 1px;
border-style: solid;
- width: 22px;
- height: 22px;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
margin: 0;
padding: 0;
transition: all 0.2s linear;
position: relative;
+ vertical-align: middle;
> .fa.fa-caret-down {
position: absolute;
@@ -635,20 +674,20 @@
// Dropdown button animation in mini pipeline graph
&.ci-status-icon-success {
- @include mini-pipeline-graph-color($green-50, $green-500, $green-600);
+ @include mini-pipeline-graph-color($green-100, $green-500, $green-600);
}
&.ci-status-icon-failed {
- @include mini-pipeline-graph-color($red-50, $red-500, $red-600);
+ @include mini-pipeline-graph-color($red-100, $red-500, $red-600);
}
&.ci-status-icon-pending,
&.ci-status-icon-success_with_warnings {
- @include mini-pipeline-graph-color($orange-50, $orange-500, $orange-600);
+ @include mini-pipeline-graph-color($orange-100, $orange-500, $orange-600);
}
&.ci-status-icon-running {
- @include mini-pipeline-graph-color($blue-50, $blue-400, $blue-600);
+ @include mini-pipeline-graph-color($blue-100, $blue-400, $blue-600);
}
&.ci-status-icon-canceled,
@@ -670,99 +709,146 @@
width: 195px;
max-width: 195px;
- li {
- padding: 2px 3px;
- }
-
.scrollable-menu {
padding: 0;
max-height: 245px;
overflow: auto;
}
- // Action icon on the right
- a.ci-action-icon-wrapper {
- color: $action-icon-color;
- border: 1px solid $action-icon-color;
- border-radius: 20px;
- width: 22px;
- height: 22px;
- padding: 2px 0 0 5px;
- cursor: pointer;
- float: right;
- margin: -26px 9px 0 0;
- font-size: 12px;
- background-color: $white-light;
+ li {
+ position: relative;
- &:hover,
- &:focus {
- background-color: $stage-hover-bg;
- border: 1px solid transparent;
+ // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
+ &:hover > .mini-pipeline-graph-dropdown-item,
+ &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
+ @extend .mini-pipeline-graph-dropdown-item:hover;
}
- svg {
- width: 22px;
- height: 22px;
- left: -6px;
- position: relative;
- top: -3px;
- fill: $action-icon-color;
- }
+ // Action icon on the right
+ a.ci-action-icon-wrapper {
+ border-radius: 50%;
+ border: 1px solid $border-color;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
+ padding: 2px 0 0 5px;
+ font-size: 12px;
+ background-color: $white-light;
+ position: absolute;
+ top: 50%;
+ right: $gl-padding;
+ margin-top: -#{$ci-action-icon-size / 2};
- &:hover svg,
- &:focus svg {
- fill: $gl-text-color;
- }
- }
+ &:hover,
+ &:focus {
+ background-color: $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
+ }
- // link to the build
- .mini-pipeline-graph-dropdown-item {
- padding: 3px 7px 4px;
- clear: both;
- font-weight: normal;
- line-height: 1.428571429;
- white-space: nowrap;
- margin: 0 5px;
- border-radius: 3px;
+ svg {
+ fill: $gl-text-color-secondary;
+ width: #{$ci-action-icon-size - 6};
+ height: #{$ci-action-icon-size - 6};
+ left: -3px;
+ position: relative;
+ top: -2px;
+
+ &.icon-action-stop,
+ &.icon-action-cancel {
+ width: 12px;
+ height: 12px;
+ top: 1px;
+ left: -1px;
+ }
- // build name
- .ci-build-text,
- .ci-status-text {
- font-weight: 200;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 70%;
- color: $gl-text-color-secondary;
- margin-left: 2px;
- display: inline-block;
- top: 1px;
- vertical-align: text-bottom;
- position: relative;
+ &.icon-action-play {
+ width: 11px;
+ height: 11px;
+ top: 1px;
+ left: 1px;
+ }
- @media (max-width: $screen-xs-max) {
- max-width: 60%;
+ &.icon-action-retry {
+ width: 16px;
+ height: 16px;
+ top: 0;
+ left: -3px;
+ }
}
- }
- // status icon on the left
- .ci-status-icon {
- top: 3px;
- position: relative;
+ &:hover svg,
+ &:focus svg {
+ fill: $gl-text-color;
+ }
- > svg {
- overflow: visible;
- width: 18px;
- height: 18px;
+ &.icon-action-retry,
+ &.icon-action-play {
+ svg {
+ width: #{$ci-action-icon-size - 6};
+ height: #{$ci-action-icon-size - 6};
+ left: 8px;
+ }
}
+
+
}
- &:hover,
- &:focus {
- outline: none;
- text-decoration: none;
- color: $gl-text-color;
- background-color: $stage-hover-bg;
+ // link to the build
+ .mini-pipeline-graph-dropdown-item {
+ align-items: center;
+ clear: both;
+ display: flex;
+ font-weight: normal;
+ line-height: $line-height-base;
+ white-space: nowrap;
+
+ .ci-job-name-component {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ }
+
+ // build name
+ .ci-build-text,
+ .ci-status-text {
+ font-weight: 200;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 70%;
+ margin-left: 2px;
+ display: inline-block;
+
+ &::after {
+ content: '';
+ display: block;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ max-width: 60%;
+ }
+ }
+
+ .ci-status-icon {
+ @extend .append-right-8;
+
+ position: relative;
+
+ > svg {
+ width: $pipeline-dropdown-status-icon-size;
+ height: $pipeline-dropdown-status-icon-size;
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ outline: none;
+ text-decoration: none;
+ background-color: $stage-hover-bg;
+ }
}
}
}
@@ -771,23 +857,13 @@
.big-pipeline-graph-dropdown-menu {
width: 195px;
min-width: 195px;
- left: auto;
- right: -195px;
- top: -4px;
+ left: 100%;
+ top: -10px;
box-shadow: 0 1px 5px $black-transparent;
- .mini-pipeline-graph-dropdown-item {
- .ci-status-icon {
- top: -1px;
- }
- }
-}
-
-/**
- * Top arrow in the dropdown in the big pipeline graph
- */
-.big-pipeline-graph-dropdown-menu {
-
+ /**
+ * Top arrow in the dropdown in the big pipeline graph
+ */
&::before,
&::after {
content: '';
@@ -801,15 +877,14 @@
}
&::before {
- left: -5px;
- margin-top: -6px;
+ left: -6px;
+ margin-top: 3px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
- left: -4px;
- margin-top: -9px;
+ left: -5px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
@@ -819,6 +894,7 @@
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
+ z-index: 200;
&::before,
&::after {
@@ -849,22 +925,23 @@
margin-top: 1px;
border-bottom-color: $white-light;
}
-}
-/**
- * Center dropdown menu in mini graph
- */
-.mini-pipeline-graph-dropdown-menu.dropdown-menu {
- transform: translate(-80%, 0);
- min-width: 150px;
+ /**
+ * Center dropdown menu in mini graph
+ */
+ &.dropdown-menu {
+ transform: translate(-80%, 0);
+ min-width: 150px;
- @media(min-width: $screen-md-min) {
- transform: translate(-50%, 0);
- right: auto;
- left: 50%;
- min-width: 240px;
+ @media(min-width: $screen-md-min) {
+ transform: translate(-50%, 0);
+ right: auto;
+ left: 50%;
+ min-width: 240px;
+ }
}
}
+
/**
* Terminal
*/
@@ -874,7 +951,7 @@
.terminal-container {
.content-block {
- border-bottom: none;
+ border-bottom: 0;
}
#terminal {
@@ -888,25 +965,6 @@
}
}
-/**
- * Play button with icon in dropdowns
- */
-.ci-table .no-btn {
- border: none;
- background: none;
- outline: none;
- width: 100%;
- text-align: left;
-
- .icon-play {
- position: relative;
- top: 2px;
- margin-right: 5px;
- height: 13px;
- width: 12px;
- }
-}
-
.ci-header-container {
min-height: 55px;
@@ -921,3 +979,20 @@
}
}
}
+
+.pipelines-container .top-area .nav-controls > .btn:last-child {
+ float: none;
+}
+
+.autodevops-title {
+ font-weight: $gl-font-weight-normal;
+ line-height: 1.5;
+}
+
+.legend-all {
+ color: $gl-text-color-secondary;
+}
+
+.legend-success {
+ color: $green-500;
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index c207159f606..ac745019319 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -73,7 +73,7 @@
.profile-link-holder {
display: inline;
- a {
+ a:not(.text-link) {
text-decoration: none;
}
}
@@ -83,7 +83,7 @@
&::after {
content: "\00B7"; // Middle Dot
padding: 0 6px;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
&:last-child {
@@ -108,6 +108,15 @@
}
}
+.subkeys-list {
+ @include basic-list;
+
+ li {
+ padding: 3px 0;
+ border: 0;
+ }
+}
+
.key-list-item {
.key-list-item-info {
@media (min-width: $screen-sm-min) {
@@ -277,7 +286,7 @@ table.u2f-registrations {
.oauth-application-show {
.scope-name {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
.scopes-list {
@@ -286,13 +295,12 @@ table.u2f-registrations {
}
.user-callout {
- margin: 0 auto;
- max-width: $screen-lg-min;
+ margin: 20px -5px 0;
.bordered-box {
border: 1px solid $blue-300;
border-radius: $border-radius-default;
- background-color: $blue-25;
+ background-color: $blue-50;
position: relative;
display: flex;
justify-content: center;
@@ -377,3 +385,41 @@ table.u2f-registrations {
}
}
}
+
+.nav-wip {
+ border: 1px solid $blue-500;
+ background: $blue-50;
+ padding: $gl-padding;
+ margin-bottom: $gl-padding;
+
+ a {
+ color: $blue-500;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.email-badge {
+ display: inline;
+ margin-right: $gl-padding / 2;
+
+ .email-badge-email {
+ display: inline;
+ margin-right: $gl-padding / 4;
+ }
+
+ .label-verification-status {
+ border-width: 1px;
+ border-style: solid;
+
+ &.verified {
+ @include green-status-color;
+ }
+
+ &.unverified {
+ @include status-color($gray-dark, $gray, $common-gray-dark);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index 305feaacaa1..68d40b56133 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -1,3 +1,83 @@
+@mixin application-theme-preview($color-1, $color-2, $color-3, $color-4) {
+ .one {
+ background-color: $color-1;
+ border-top-left-radius: $border-radius-default;
+ }
+
+ .two {
+ background-color: $color-2;
+ border-top-right-radius: $border-radius-default;
+ }
+
+ .three {
+ background-color: $color-3;
+ border-bottom-left-radius: $border-radius-default;
+ }
+
+ .four {
+ background-color: $color-4;
+ border-bottom-right-radius: $border-radius-default;
+ }
+}
+
+.multi-file-editor-options {
+ label {
+ margin-right: 20px;
+ text-align: center;
+ }
+
+ .preview {
+ font-size: 0;
+
+ img {
+ border: 1px solid $border-color-settings;
+ border-radius: 4px;
+ }
+ }
+}
+
+.application-theme {
+ label {
+ margin-right: 20px;
+ text-align: center;
+ }
+
+ .preview {
+ font-size: 0;
+ margin-bottom: 10px;
+
+ &.indigo {
+ @include application-theme-preview($indigo-900, $indigo-700, $indigo-800, $indigo-500);
+ }
+
+ &.dark {
+ @include application-theme-preview($theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-600);
+ }
+
+ &.light {
+ @include application-theme-preview($theme-gray-600, $theme-gray-200, $theme-gray-400, $theme-gray-100);
+ }
+
+ &.blue {
+ @include application-theme-preview($theme-blue-900, $theme-blue-700, $theme-blue-800, $theme-blue-500);
+ }
+
+ &.green {
+ @include application-theme-preview($theme-green-900, $theme-green-700, $theme-green-800, $theme-green-500);
+ }
+ }
+
+ .preview-row {
+ display: block;
+ }
+
+ .quadrant {
+ display: inline-block;
+ height: 50px;
+ width: 80px;
+ }
+}
+
.syntax-theme {
label {
margin-right: 20px;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 562ecbc6986..584b0579b72 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -2,49 +2,13 @@
margin: -16px;
.alert-link {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
}
.new_project,
-.edit-project {
-
- .sharing-and-permissions {
- .header {
- padding-top: $gl-vert-padding;
- }
-
- .label-light {
- margin-bottom: 0;
- }
-
- .help-block {
- margin-top: 0;
- }
-
- .form-group {
- margin-bottom: 5px;
- }
-
- & > .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;
- }
- }
-
+.edit-project,
+.import-project {
.help-block {
margin-bottom: 10px;
}
@@ -53,18 +17,25 @@
border-radius: $border-radius-base;
}
- .input-group > div {
+ .input-group {
+ display: flex;
- &:last-child {
- padding-right: 0;
+ .select2-container {
+ display: unset;
+ max-width: unset;
+ width: unset !important;
+ flex-grow: 1;
+ }
+
+ > div {
+ &:last-child {
+ padding-right: 0;
+ }
}
}
@media (max-width: $screen-xs-max) {
.input-group > div {
-
- margin-bottom: 14px;
-
&:last-child {
margin-bottom: 0;
}
@@ -76,20 +47,98 @@
}
.input-group-addon {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: unset;
+ width: unset;
+ max-width: 50%;
+ text-align: left;
&.static-namespace {
height: 35px;
border-radius: 3px;
border: 1px solid $border-color;
+ max-width: 100%;
+ flex-grow: 1;
}
- & + .select2 a {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
+ + .select2 a,
+ + .btn-default {
+ border-radius: 0 $border-radius-base $border-radius-base 0;
}
}
}
+.toggle-wrapper {
+ margin-top: 5px;
+}
+
+.project-feature-row > .toggle-wrapper {
+ margin: 10px 0;
+}
+
+.project-visibility-setting,
+.project-feature-settings {
+ border: 1px solid $border-color;
+ padding: 10px 32px;
+
+ @media (max-width: $screen-xs-min) {
+ padding: 10px 20px;
+ }
+}
+
+.project-visibility-setting .request-access {
+ line-height: 2;
+}
+
+.project-feature-settings {
+ background: $gray-lighter;
+ border-top: 0;
+ margin-bottom: 16px;
+}
+
+.project-repo-select {
+ transition: background 2s ease-out;
+
+ &:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .highlight-changes & {
+ background: $highlight-changes-color;
+ transition: none;
+ }
+}
+
+.project-feature-controls {
+ display: flex;
+ align-items: center;
+ margin: 8px 0;
+ max-width: 432px;
+
+ .toggle-wrapper {
+ flex: 0;
+ margin-right: 10px;
+ }
+
+ .select-wrapper {
+ flex: 1;
+ }
+}
+
+.project-feature-setting-group {
+ padding-left: 32px;
+
+ .project-feature-controls {
+ max-width: 400px;
+ }
+
+ @media (max-width: $screen-xs-min) {
+ padding-left: 20px;
+ }
+}
+
.project-home-panel,
.group-home-panel {
padding-top: 24px;
@@ -114,7 +163,7 @@
margin-top: 10px;
margin-bottom: 10px;
font-size: 24px;
- font-weight: 400;
+ font-weight: $gl-font-weight-normal;
line-height: 1;
word-wrap: break-word;
@@ -168,14 +217,7 @@
}
svg {
-
- path {
- fill: $layout-link-gray;
- }
-
- use {
- stroke: $layout-link-gray;
- }
+ fill: $layout-link-gray;
}
.fa-caret-down {
@@ -259,9 +301,9 @@
border-width: 1px;
border-style: solid;
font-size: 13px;
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
line-height: 13px;
- letter-spacing: .4px;
+ letter-spacing: 0.4px;
padding: 6px 14px;
text-align: center;
vertical-align: middle;
@@ -279,12 +321,17 @@
}
}
}
-}
-.project-repo-buttons {
- .project-action-button .dropdown-menu {
- max-height: 250px;
- overflow-y: auto;
+ .clone-dropdown-btn {
+ background-color: $white-light;
+ }
+
+ .clone-options-dropdown {
+ min-width: 240px;
+
+ .dropdown-menu-inner-content {
+ min-width: 320px;
+ }
}
}
@@ -297,28 +344,6 @@
}
}
-.project-visibility-level-holder {
- .radio {
- margin-bottom: 10px;
-
- i {
- margin: 2px 0;
- font-size: 20px;
- }
-
- .option-title {
- font-weight: normal;
- display: inline-block;
- color: $gl-text-color;
- }
-
- .option-descr {
- margin-left: 29px;
- color: $project-option-descr-color;
- }
- }
-}
-
.save-project-loader {
margin-top: 50px;
margin-bottom: 50px;
@@ -377,9 +402,10 @@ a.deploy-project-label {
}
.breadcrumb.repo-breadcrumb {
+ flex: 1;
padding: 0;
background: transparent;
- border: none;
+ border: 0;
line-height: 34px;
margin: 0;
@@ -397,67 +423,146 @@ a.deploy-project-label {
}
}
-.fork-namespaces {
- .row {
- -webkit-flex-wrap: wrap;
- display: -webkit-flex;
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-start;
+.fork-thumbnail {
+ height: 200px;
+ width: calc((100% / 2) - #{$gl-padding * 2});
- .fork-thumbnail {
- border-radius: $border-radius-base;
- background-color: $white-light;
- border: 1px solid $border-white-light;
- height: 202px;
- margin: $gl-padding;
- text-align: center;
- width: 169px;
+ @media (min-width: $screen-md-min) {
+ width: calc((100% / 4) - #{$gl-padding * 2});
+ }
- &:hover,
- &.forked {
- background-color: $row-hover;
- border-color: $row-hover-border;
- }
+ @media (min-width: $screen-lg-min) {
+ width: calc((100% / 5) - #{$gl-padding * 2});
+ }
- .no-avatar {
- width: 100px;
- height: 100px;
- background-color: $gray-light;
- border: 1px solid $white-normal;
- margin: 0 auto;
- border-radius: 50%;
-
- i {
- font-size: 100px;
- color: $white-normal;
- }
- }
+ &:hover:not(.disabled),
+ &.forked {
+ background-color: $row-hover;
+ border-color: $row-hover-border;
+ }
- a {
- display: block;
- width: 100%;
- height: 100%;
- padding-top: $gl-padding;
- color: $gl-text-color;
+ .avatar-container,
+ .identicon {
+ float: none;
+ margin-left: auto;
+ margin-right: auto;
+ }
- .caption {
- min-height: 30px;
- padding: $gl-padding 0;
- }
- }
+ a {
+ display: block;
+ width: 100%;
+ height: 100%;
+ padding-top: $gl-padding;
+ text-decoration: none;
- img {
- border-radius: 50%;
- max-width: 100px;
+ &.disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+ }
+}
+
+.fork-thumbnail-container {
+ display: flex;
+ flex-wrap: wrap;
+ margin-left: -$gl-padding;
+ margin-right: -$gl-padding;
+
+ > h5 {
+ width: 100%;
+ }
+}
+
+.project-template {
+ > .form-group {
+ margin-bottom: 0;
+ }
+
+ .template-option {
+ padding: $gl-padding $gl-padding $gl-padding ($gl-padding * 4);
+ position: relative;
+
+ &:not(:first-child) {
+ border-top: 1px solid $border-color;
+ }
+ }
+
+ .template-title {
+ font-size: 16px;
+ }
+
+ .template-description {
+ margin: 6px 0 12px;
+ }
+
+ .template-button {
+ input {
+ position: absolute;
+ clip: rect(0, 0, 0, 0);
+ }
+ }
+
+ svg {
+ position: absolute;
+ left: $gl-padding;
+ top: $gl-padding;
+ }
+
+ .project-fields-form {
+ display: none;
+
+ &.selected {
+ display: block;
+ padding: $gl-padding;
+ }
+ }
+
+ .template-input-group {
+ position: relative;
+
+ @media (min-width: $screen-sm-min) {
+ display: flex;
+ }
+
+ .input-group-addon {
+ flex: 1;
+ text-align: left;
+ padding-left: ($gl-padding * 3);
+ background-color: $white-light;
+ }
+
+ .selected-template {
+ line-height: 20px;
+ }
+
+ .selected-icon {
+ svg {
+ display: none;
+ top: 7px;
+ height: 20px;
+ width: 20px;
+
+ &.active {
+ display: block;
+ }
}
}
}
}
+.gitlab-tab-content {
+ .import-project-pane {
+ padding-bottom: 6px;
+ }
+}
+
.project-import {
- .form-group {
- margin-bottom: 5px;
+ .import-btn-container {
+ margin-bottom: 0;
+ }
+
+ .toggle-import-form {
+ padding-bottom: 10px;
}
.import-buttons {
@@ -469,7 +574,28 @@ a.deploy-project-label {
.btn {
padding: 8px;
- margin-left: 10px;
+ margin-right: 10px;
+ }
+
+ .btn-template-icon {
+ height: 24px;
+ width: inherit;
+ display: block;
+ margin: 0 auto 4px;
+ font-size: 24px;
+
+ @media (min-width: $screen-xs-max) {
+ top: 0;
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .btn-template-icon {
+ display: inline-block;
+ height: 14px;
+ font-size: 14px;
+ margin: 0;
+ }
}
> div {
@@ -479,59 +605,149 @@ a.deploy-project-label {
}
}
-.project-stats {
- font-size: 0;
- text-align: center;
+.create-project-options {
+ display: flex;
- .nav {
- padding-top: 12px;
- padding-bottom: 12px;
- border-bottom: 1px solid $border-color;
+ @media (max-width: $screen-xs-max) {
+ display: block;
}
- .nav > li {
- display: inline-block;
+ .first-column {
+ @media (min-width: $screen-xs-min) {
+ max-width: 50%;
+ padding-right: 30px;
+ }
- &:not(:last-child) {
- margin-right: $gl-padding;
+ @media (max-width: $screen-xs-max) {
+ max-width: 100%;
+ width: 100%;
}
+ }
- &.right {
- vertical-align: top;
- margin-top: 0;
+ .second-column {
+ @media (min-width: $screen-xs-min) {
+ width: 50%;
+ flex: 1;
+ padding-left: 30px;
+ position: relative;
+ }
- @media (min-width: $screen-lg-min) {
- float: right;
- }
+ @media (max-width: $screen-xs-max) {
+ max-width: 100%;
+ width: 100%;
+ padding-left: 0;
+ position: relative;
}
- }
- .nav > li > a {
- padding: 0;
- background-color: transparent;
- font-size: 14px;
- line-height: 29px;
- color: $notes-light-color;
+ // Mobile
+ @media (max-width: $screen-xs-max) {
+ padding-top: 30px;
+ }
+
+ &::before {
+ content: 'OR';
+ position: absolute;
+ left: -10px;
+ top: 50%;
+ z-index: 10;
+ padding: 8px 0;
+ text-align: center;
+ background-color: $white-light;
+ color: $gl-text-color-tertiary;
+ transform: translateY(-50%);
+ font-size: 12px;
+ font-weight: $gl-font-weight-bold;
+ line-height: 20px;
+
+ // Mobile
+ @media (max-width: $screen-xs-max) {
+ left: 50%;
+ top: 0;
+ transform: translateX(-50%);
+ padding: 0 8px;
+ }
+ }
- &:hover,
- &:focus {
- color: $gl-text-color;
+ &::after {
+ content: '';
+ position: absolute;
+ background-color: $border-color;
+ bottom: 0;
+ left: 0;
+ right: auto;
+ height: 100%;
+ width: 1px;
+ top: 0;
+
+ // Mobile
+ @media (max-width: $screen-xs-max) {
+ top: 10px;
+ left: 10px;
+ right: 10px;
+ height: 1px;
+ width: auto;
+ }
}
}
+}
- li.missing {
- border: 1px dashed $border-gray-normal;
- border-radius: $border-radius-default;
+.project-empty-note-panel {
+ border-bottom: 1px solid $border-color;
+}
- a {
- padding-left: 10px;
- padding-right: 10px;
+.project-stats {
+ font-size: 0;
+ text-align: center;
+ max-width: 100%;
+ border-bottom: 1px solid $border-color;
+
+ .nav {
+ margin-top: $gl-padding-8;
+ margin-bottom: $gl-padding-8;
+
+ > li {
+ display: inline-block;
+ margin-top: $gl-padding-4;
+ margin-bottom: $gl-padding-4;
+
+ &:not(:last-child) {
+ margin-right: $gl-padding;
+ }
+
+ &.right {
+ vertical-align: top;
+ margin-top: 0;
+
+ @media (min-width: $screen-lg-min) {
+ float: right;
+ }
+ }
+ }
+
+ .stat-text,
+ .stat-link {
+ padding: $gl-btn-vert-padding 0;
+ background-color: transparent;
+ font-size: $gl-font-size;
+ line-height: $gl-btn-line-height;
color: $notes-light-color;
- display: block;
}
- &:hover {
- background-color: $gray-normal;
+ .stat-link {
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ text-decoration: underline;
+ }
+ }
+
+ .btn {
+ padding: $gl-btn-vert-padding $gl-btn-horz-padding;
+ line-height: $gl-btn-line-height;
+ }
+
+ .btn-missing {
+ @extend .btn-missing;
}
}
}
@@ -541,7 +757,7 @@ pre.light-well {
}
.git-empty {
- margin: 0 7px 7px;
+ margin-bottom: 7px;
h5 {
color: $gl-text-color;
@@ -585,19 +801,15 @@ pre.light-well {
}
.project-row {
+ @include basic-list-stats;
display: flex;
align-items: center;
- @include basic-list-stats;
}
h3 {
font-size: $gl-font-size;
}
- a {
- color: $gl-text-color;
- }
-
.avatar-container,
.controls {
flex: 0 0 auto;
@@ -689,45 +901,29 @@ pre.light-well {
}
}
-.new_protected_branch,
+.new-protected-branch,
.new-protected-tag {
label {
margin-top: 6px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
}
-.create-new-protected-branch-button,
-.create-new-protected-tag-button {
- @include dropdown-link;
-
- width: 100%;
- background-color: transparent;
- border: 0;
- text-align: left;
+.project-tip-command {
+ > .input-group-btn:first-child {
+ width: auto;
+ }
}
.protected-branches-list,
.protected-tags-list {
margin-bottom: 30px;
- a {
- color: $gl-text-color;
-
- &:hover {
- color: $gl-link-color;
- }
-
- &.is-active {
- font-weight: 600;
- }
- }
-
.settings-message {
margin: 0;
border-radius: 0 0 1px 1px;
padding: 20px 0;
- border: none;
+ border: 0;
}
.table-bordered {
@@ -738,9 +934,7 @@ pre.light-well {
border-right: solid 1px transparent;
}
}
-}
-.protected-tags-list {
.dropdown-menu-toggle {
width: 100%;
max-width: 300px;
@@ -817,7 +1011,7 @@ pre.light-well {
&::before {
font-family: FontAwesome;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
font-style: normal;
}
}
@@ -835,6 +1029,12 @@ pre.light-well {
min-width: 100px;
}
+ &.form-group {
+ @media (min-width: $screen-sm-min) {
+ margin-bottom: 0;
+ }
+ }
+
.select2-choice {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -869,18 +1069,11 @@ pre.light-well {
}
}
-.project-repo-select {
- &.disabled {
- opacity: 0.5;
- pointer-events: none;
- }
-}
-
.variables-table {
table-layout: fixed;
&.table-responsive {
- border: none;
+ border: 0;
}
.variable-key {
@@ -923,3 +1116,8 @@ pre.light-well {
border-color: $border-color;
}
}
+
+.issuable-footer {
+ padding-top: $gl-padding;
+ padding-bottom: 37px;
+}
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
new file mode 100644
index 00000000000..57b995adb64
--- /dev/null
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -0,0 +1,786 @@
+.project-refs-form,
+.project-refs-target-form {
+ display: inline-block;
+}
+
+.fade-enter,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.commit-message {
+ @include str-truncated(250px);
+}
+
+.editable-mode {
+ display: inline-block;
+}
+
+.ide-view {
+ display: flex;
+ height: calc(100vh - #{$header-height});
+ margin-top: 40px;
+ color: $almost-black;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+
+ &.is-collapsed {
+ .ide-file-list {
+ max-width: 250px;
+ }
+ }
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+.ide-file-list {
+ flex: 1;
+
+ .file {
+ cursor: pointer;
+
+ &.file-open {
+ background: $white-normal;
+ }
+
+ .ide-file-name {
+ flex: 1;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
+ }
+
+ .ide-file-changed-icon {
+ margin-left: auto;
+ }
+
+ .ide-new-btn {
+ display: none;
+ margin-bottom: -4px;
+ margin-right: -8px;
+ }
+
+ &:hover {
+ .ide-new-btn {
+ display: block;
+ }
+ }
+
+ &.folder {
+ svg {
+ fill: $gl-text-color-secondary;
+ }
+ }
+ }
+
+ a {
+ color: $gl-text-color;
+ }
+
+ th {
+ position: sticky;
+ top: 0;
+ }
+}
+
+.file-name,
+.file-col-commit-message {
+ display: flex;
+ overflow: visible;
+ padding: 6px 12px;
+}
+
+.multi-file-loading-container {
+ margin-top: 10px;
+ padding: 10px;
+
+ .animation-container {
+ background: $gray-light;
+
+ div {
+ background: $gray-light;
+ }
+ }
+}
+
+.multi-file-table-col-commit-message {
+ white-space: nowrap;
+ width: 50%;
+}
+
+.multi-file-edit-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ border-left: 1px solid $white-dark;
+ overflow: hidden;
+}
+
+.multi-file-tabs {
+ display: flex;
+ background-color: $white-normal;
+ box-shadow: inset 0 -1px $white-dark;
+
+ > ul {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ li {
+ position: relative;
+ }
+
+ .dropdown {
+ display: flex;
+ margin-left: auto;
+ margin-bottom: 1px;
+ padding: 0 $grid-size;
+ border-left: 1px solid $white-dark;
+ background-color: $white-light;
+
+ &.shadow {
+ box-shadow: 0 0 10px $dropdown-shadow-color;
+ }
+
+ .btn {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+}
+
+.multi-file-tab {
+ @include str-truncated(150px);
+ padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
+ background-color: $gray-normal;
+ border-right: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ cursor: pointer;
+
+ svg {
+ vertical-align: middle;
+ }
+
+ &.active {
+ background-color: $white-light;
+ border-bottom-color: $white-light;
+ }
+}
+
+.multi-file-tab-close {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ background: none;
+ border: 0;
+ border-radius: $border-radius-default;
+ color: $theme-gray-900;
+ transform: translateY(-50%);
+
+ svg {
+ position: relative;
+ top: -1px;
+ }
+
+ &:hover {
+ background-color: $theme-gray-200;
+ }
+
+ &:focus {
+ background-color: $blue-500;
+ color: $white-light;
+ outline: 0;
+
+ svg {
+ fill: currentColor;
+ }
+ }
+}
+
+.multi-file-edit-pane-content {
+ flex: 1;
+ height: 0;
+}
+
+.blob-editor-container {
+ flex: 1;
+ height: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ .vertical-center {
+ min-height: auto;
+ }
+
+ .monaco-editor .lines-content .cigr {
+ display: none;
+ }
+
+ .monaco-diff-editor.vs {
+ .editor.modified {
+ box-shadow: none;
+ }
+
+ .diagonal-fill {
+ display: none !important;
+ }
+
+ .diffOverview {
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
+ cursor: ns-resize;
+ }
+
+ .diffViewport {
+ display: none;
+ }
+
+ .char-insert {
+ background-color: $line-added-dark;
+ }
+
+ .char-delete {
+ background-color: $line-removed-dark;
+ }
+
+ .line-numbers {
+ color: $black-transparent;
+ }
+
+ .view-overlays {
+ .line-insert {
+ background-color: $line-added;
+ }
+
+ .line-delete {
+ background-color: $line-removed;
+ }
+ }
+
+ .margin {
+ background-color: $gray-light;
+ border-right: 1px solid $white-normal;
+
+ .line-insert {
+ border-right: 1px solid $line-added-dark;
+ }
+
+ .line-delete {
+ border-right: 1px solid $line-removed-dark;
+ }
+ }
+
+ .margin-view-overlays .insert-sign,
+ .margin-view-overlays .delete-sign {
+ opacity: 0.4;
+ }
+
+ .cursors-layer {
+ display: none;
+ }
+ }
+}
+
+.multi-file-editor-holder {
+ height: 100%;
+}
+
+.multi-file-editor-btn-group {
+ padding: $gl-bar-padding $gl-padding;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ background: $white-light;
+}
+
+.ide-status-bar {
+ padding: $gl-bar-padding $gl-padding;
+ background: $white-light;
+ display: flex;
+ justify-content: space-between;
+
+ svg {
+ vertical-align: middle;
+ }
+}
+
+// Not great, but this is to deal with our current output
+.multi-file-preview-holder {
+ height: 100%;
+ overflow: scroll;
+
+ .file-content.code {
+ display: flex;
+
+ i {
+ margin-left: -10px;
+ }
+ }
+
+ .line-numbers {
+ min-width: 50px;
+ }
+
+ .file-content,
+ .line-numbers,
+ .blob-content,
+ .code {
+ min-height: 100%;
+ }
+}
+
+.file-content.blob-no-preview {
+ a {
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+.multi-file-commit-panel {
+ display: flex;
+ position: relative;
+ flex-direction: column;
+ width: 340px;
+ padding: 0;
+ background-color: $gray-light;
+ padding-right: 3px;
+
+ .projects-sidebar {
+ display: flex;
+ flex-direction: column;
+
+ .context-header {
+ width: auto;
+ margin-right: 0;
+ }
+ }
+
+ .multi-file-commit-panel-inner {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ }
+
+ .multi-file-commit-panel-inner-scroll {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: auto;
+ }
+
+ &.is-collapsed {
+ width: 60px;
+
+ .multi-file-commit-list {
+ padding-top: $gl-padding;
+ overflow: hidden;
+ }
+
+ .multi-file-context-bar-icon {
+ align-items: center;
+
+ svg {
+ float: none;
+ margin: 0;
+ }
+ }
+ }
+
+ .branch-container {
+ border-left: 4px solid $indigo-700;
+ margin-bottom: $gl-bar-padding;
+ }
+
+ .branch-header {
+ background: $white-dark;
+ display: flex;
+ }
+
+ .branch-header-title {
+ flex: 1;
+ padding: $grid-size $gl-padding;
+ color: $indigo-700;
+ font-weight: $gl-font-weight-bold;
+
+ svg {
+ vertical-align: middle;
+ }
+ }
+
+ .branch-header-btns {
+ padding: $gl-vert-padding $gl-padding;
+ }
+
+ .left-collapse-btn {
+ display: none;
+ background: $gray-light;
+ text-align: left;
+ border-top: 1px solid $white-dark;
+
+ svg {
+ vertical-align: middle;
+ }
+ }
+}
+
+.multi-file-context-bar-icon {
+ padding: 10px;
+
+ svg {
+ margin-right: 10px;
+ float: left;
+ }
+}
+
+.multi-file-commit-panel-section {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.multi-file-commit-empty-state-container {
+ align-items: center;
+ justify-content: center;
+}
+
+.multi-file-commit-panel-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ border-bottom: 1px solid $white-dark;
+ padding: $gl-btn-padding 0;
+
+ &.is-collapsed {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .multi-file-commit-panel-collapse-btn {
+ margin-right: auto;
+ margin-left: auto;
+ border-left: 0;
+ }
+ }
+}
+
+.multi-file-commit-panel-header-title {
+ display: flex;
+ flex: 1;
+ padding: 0 $gl-btn-padding;
+
+ svg {
+ margin-right: $gl-btn-padding;
+ }
+}
+
+.multi-file-commit-panel-collapse-btn {
+ border-left: 1px solid $white-dark;
+}
+
+.multi-file-commit-list {
+ flex: 1;
+ overflow: auto;
+ padding: $gl-padding 0;
+ min-height: 60px;
+}
+
+.multi-file-commit-list-item {
+ display: flex;
+ padding: 0;
+ align-items: center;
+
+ .multi-file-discard-btn {
+ display: none;
+ margin-left: auto;
+ color: $gl-link-color;
+ padding: 0 2px;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &:hover {
+ background: $white-normal;
+
+ .multi-file-discard-btn {
+ display: block;
+ }
+ }
+}
+
+.multi-file-addition {
+ fill: $green-500;
+}
+
+.multi-file-modified {
+ fill: $orange-500;
+}
+
+.multi-file-commit-list-collapsed {
+ display: flex;
+ flex-direction: column;
+
+ > svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ margin-left: 3px;
+ }
+}
+
+.multi-file-commit-list-path {
+ padding: $grid-size / 2;
+ padding-left: $gl-padding;
+ background: none;
+ border: 0;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+
+ svg {
+ min-width: 16px;
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+}
+
+.multi-file-commit-list-file-path {
+ @include str-truncated(100%);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
+}
+
+.multi-file-commit-form {
+ padding: $gl-padding;
+ border-top: 1px solid $white-dark;
+
+ .btn {
+ font-size: $gl-font-size;
+ }
+}
+
+.multi-file-commit-message.form-control {
+ height: 160px;
+ resize: none;
+}
+
+.dirty-diff {
+ // !important need to override monaco inline style
+ width: 4px !important;
+ left: 0 !important;
+
+ &-modified {
+ background-color: $blue-500;
+ }
+
+ &-added {
+ background-color: $green-600;
+ }
+
+ &-removed {
+ height: 0 !important;
+ width: 0 !important;
+ bottom: -2px;
+ border-style: solid;
+ border-width: 5px;
+ border-color: transparent transparent transparent $red-500;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 1px;
+ background-color: rgba($red-500, 0.5);
+ }
+ }
+}
+
+.ide-loading {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.ide-empty-state {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.ide-new-btn {
+ .dropdown-toggle svg {
+ margin-top: -2px;
+ margin-bottom: 2px;
+ }
+
+ .dropdown-menu {
+ left: auto;
+ right: 0;
+
+ label {
+ font-weight: $gl-font-weight-normal;
+ padding: 5px 8px;
+ margin-bottom: 0;
+ }
+ }
+}
+
+.ide {
+ overflow: hidden;
+
+ &.nav-only {
+ .flash-container {
+ margin-top: $header-height;
+ margin-bottom: 0;
+ }
+
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
+
+ .content-wrapper {
+ margin-top: $header-height;
+ padding-bottom: 0;
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
+ }
+
+ .projects-sidebar {
+ .multi-file-commit-panel-inner-scroll {
+ flex: 1;
+ }
+ }
+ }
+}
+
+.with-performance-bar .ide.nav-only {
+ .flash-container {
+ margin-top: #{$header-height + $performance-bar-height};
+ }
+
+ .content-wrapper {
+ margin-top: #{$header-height + $performance-bar-height};
+ padding-bottom: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height});
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(
+ 100vh - #{$header-height + $performance-bar-height + $flash-height}
+ );
+ }
+ }
+}
+
+.dragHandle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background-color: $white-dark;
+
+ &.dragright {
+ right: 0;
+ }
+
+ &.dragleft {
+ left: 0;
+ }
+}
+
+.ide-commit-radios {
+ label {
+ font-weight: normal;
+ }
+
+ .help-block {
+ margin-top: 0;
+ line-height: 0;
+ }
+}
+
+.ide-commit-new-branch {
+ margin-left: 25px;
+}
+
+.ide-external-links {
+ p {
+ margin: 0;
+ }
+}
+
+.ide-sidebar-link {
+ padding: $gl-padding-8 $gl-padding;
+ background: $indigo-700;
+ color: $white-light;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+
+ &:focus,
+ &:hover {
+ color: $white-light;
+ text-decoration: underline;
+ background: $indigo-500;
+ }
+
+ &:active {
+ background: $indigo-800;
+ }
+}
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
index 9b6ff237557..5fb97b13470 100644
--- a/app/assets/stylesheets/pages/runners.scss
+++ b/app/assets/stylesheets/pages/runners.scss
@@ -30,6 +30,30 @@
}
h4 {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
+ }
+}
+
+.admin-runner-btn-group-cell {
+ min-width: 150px;
+
+ .btn-sm {
+ padding: 4px 9px;
+ }
+
+ .btn-default {
+ color: $gl-text-color-secondary;
+ }
+
+ .fa-pause,
+ .fa-play {
+ font-size: 11px;
+ }
+}
+
+@media (max-width: $screen-md-max) {
+ .runners-content {
+ width: 100%;
+ overflow: auto;
}
}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index b9818ffcf42..dbde0720993 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -5,7 +5,7 @@
margin-bottom: $gl-padding;
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
}
}
@@ -28,9 +28,7 @@ input[type="checkbox"]:hover {
}
.search {
- margin-right: 10px;
- margin-left: 10px;
- margin-top: ($header-height - 35) / 2;
+ margin: 4px 8px 0;
form {
@extend .form-control;
@@ -38,77 +36,63 @@ input[type="checkbox"]:hover {
padding: 4px;
width: $search-input-width;
line-height: 24px;
+ height: 32px;
+ border: 0;
+ border-radius: $border-radius-default;
+ transition: border-color ease-in-out $default-transition-duration, background-color ease-in-out $default-transition-duration;
&:hover {
- border-color: lighten($dropdown-input-focus-border, 20%);
- box-shadow: 0 0 4px lighten($search-input-focus-shadow-color, 20%);
+ box-shadow: none;
}
}
- .location-text {
- font-style: normal;
+ .location-badge {
+ height: 32px;
+ font-size: 12px;
+ margin: -4px 4px -4px -4px;
+ line-height: 25px;
+ padding: 4px 8px;
+ border-radius: $border-radius-default 0 0 $border-radius-default;
+ transition: border-color ease-in-out $default-transition-duration;
}
.search-input {
- border: none;
+ border: 0;
font-size: 14px;
padding: 0 20px 0 0;
margin-left: 5px;
line-height: 25px;
width: 98%;
+ color: $white-light;
+ background: none;
+ transition: color ease-in-out $default-transition-duration;
}
- .location-badge {
- line-height: 25px;
- padding: 0 5px;
- border-radius: $border-radius-default;
- font-size: 14px;
- font-style: normal;
- color: $note-disabled-comment-color;
- display: inline-block;
- background-color: $gray-normal;
- vertical-align: top;
- cursor: default;
+ .search-input::placeholder {
+ transition: color ease-in-out $default-transition-duration;
}
.search-input-container {
- display: -webkit-flex;
display: flex;
position: relative;
}
.search-input-wrap {
- // Fallback if flexbox is not supported
- display: inline-block;
- }
-
- .search-input-wrap {
- width: 100%;
-
.search-icon,
.clear-icon {
position: absolute;
right: 5px;
- top: 0;
- color: $location-icon-color;
-
- &::before {
- font-family: FontAwesome;
- font-weight: normal;
- font-style: normal;
- }
+ top: 4px;
}
.search-icon {
- @extend .fa-search;
- transition: color 0.15s;
+ transition: color $default-transition-duration;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.clear-icon {
- @extend .fa-times;
display: none;
}
@@ -124,23 +108,11 @@ input[type="checkbox"]:hover {
// Custom dropdown positioning
.dropdown-menu {
- transition-property: opacity, transform;
- transition-duration: 250ms, 250ms;
- transition-delay: 0ms, 25ms;
- transition-timing-function: $dropdown-animation-timing;
- transform: translateY(0);
- opacity: 0;
- display: block;
left: -5px;
- padding: 0;
-
- ul {
- padding: 10px 0;
- }
}
.dropdown-content {
- max-height: 350px;
+ max-height: 302px;
}
}
@@ -148,26 +120,30 @@ input[type="checkbox"]:hover {
form {
@extend .form-control:focus;
border-color: $dropdown-input-focus-border;
- box-shadow: 0 0 4px $search-input-focus-shadow-color;
- }
+ box-shadow: none;
+
+ .search-input-wrap {
+ .search-icon,
+ .clear-icon {
+ color: $gl-text-color-tertiary;
+ transition: color ease-in-out $default-transition-duration;
+ }
+ }
- .location-badge {
- transition: all 0.15s;
- background-color: $location-badge-active-bg;
- color: $white-light;
- }
+ .search-input {
+ color: $gl-text-color;
+ transition: color ease-in-out $default-transition-duration;
+ }
- .search-input-wrap {
- i {
- color: $layout-link-gray;
+ .search-input::placeholder {
+ color: $gl-text-color-tertiary;
}
}
- .dropdown-menu {
- transition-duration: 100ms, 75ms;
- transition-delay: 75ms, 100ms;
- transform: translateY(13px);
- opacity: 1;
+ .location-badge {
+ transition: all $default-transition-duration;
+ background-color: $nav-badge-bg;
+ border-color: $border-color;
}
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index d69a8e0995c..a6ca8ed5016 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -23,15 +23,14 @@
}
.settings {
- overflow: hidden;
border-bottom: 1px solid $gray-darker;
&:first-of-type {
margin-top: 10px;
}
- &.expanded {
- overflow: visible;
+ &.animating {
+ overflow: hidden;
}
}
@@ -54,17 +53,20 @@
.settings-content {
max-height: 1px;
overflow-y: scroll;
- margin-right: -20px;
- padding-right: 130px;
+ padding-right: 110px;
animation: collapseMaxHeight 300ms ease-out;
+ // Keep the section from expanding when we scroll over it
+ pointer-events: none;
- &.expanded {
+ .settings.expanded & {
max-height: none;
overflow-y: visible;
animation: expandMaxHeight 300ms ease-in;
+ // Reset and allow clicks again when expanded
+ pointer-events: auto;
}
- &.no-animate {
+ .settings.no-animate & {
animation: none;
}
@@ -87,6 +89,23 @@
overflow: hidden;
margin-top: 20px;
}
+
+ .sub-section {
+ margin-bottom: 32px;
+ padding: 16px;
+ border: 1px solid $border-color;
+ background-color: $gray-light;
+ }
+
+ .bs-callout,
+ .checkbox:first-child,
+ .help-block {
+ margin-top: 0;
+ }
+
+ .label-light {
+ margin-bottom: 0;
+ }
}
.settings-list-icon {
@@ -116,6 +135,17 @@
padding-top: 0;
}
+.integration-settings-form {
+ .well {
+ padding: $gl-padding / 2;
+ box-shadow: none;
+ }
+
+ .svg-container {
+ max-width: 150px;
+ }
+}
+
.token-token-container {
#impersonation-token-token {
width: 80%;
@@ -127,6 +157,47 @@
}
}
+.visibility-level-setting {
+ .radio {
+ margin-bottom: 10px;
+
+ i.fa {
+ margin: 2px 0;
+ font-size: 20px;
+ }
+
+ .option-title {
+ font-weight: $gl-font-weight-normal;
+ display: inline-block;
+ color: $gl-text-color;
+ }
+
+ .option-description,
+ .option-disabled-reason {
+ margin-left: 29px;
+ color: $project-option-descr-color;
+ }
+
+ .option-disabled-reason {
+ display: none;
+ }
+
+ &.disabled {
+ i.fa {
+ opacity: 0.5;
+ }
+
+ .option-description {
+ display: none;
+ }
+
+ .option-disabled-reason {
+ display: block;
+ }
+ }
+ }
+}
+
.prometheus-metrics-monitoring {
.panel {
.panel-toggle {
@@ -134,7 +205,8 @@
}
.badge {
- font-size: inherit;
+ font-size: 12px;
+ line-height: 12px;
}
.panel-heading .badge-count {
@@ -181,11 +253,34 @@
margin-left: 5px;
background: $badge-bg;
}
- }
- /* Ensure we don't add border if there's only single li */
- li + li {
- border-top: 1px solid $border-color;
+ /* Ensure we don't add border if there's only single li */
+ + li {
+ border-top: 1px solid $border-color;
+ }
}
}
}
+
+.modal-doorkeepr-auth,
+.doorkeeper-app-form {
+ .scope-description {
+ color: $theme-gray-700;
+ }
+}
+
+.modal-doorkeepr-auth {
+ .modal-body {
+ padding: $gl-padding;
+ }
+}
+
+.doorkeeper-app-form {
+ .scope-description {
+ margin: 0 0 5px 17px;
+ }
+}
+
+.deprecated-service {
+ cursor: default;
+}
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
index fe22d186af1..a355e2dee24 100644
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ b/app/assets/stylesheets/pages/settings_ci_cd.scss
@@ -12,3 +12,7 @@
margin-left: 10px;
}
}
+
+.registry-placeholder {
+ min-height: 60px;
+}
diff --git a/app/assets/stylesheets/pages/sherlock.scss b/app/assets/stylesheets/pages/sherlock.scss
index 23a9c2ada80..2bf0bedb1f5 100644
--- a/app/assets/stylesheets/pages/sherlock.scss
+++ b/app/assets/stylesheets/pages/sherlock.scss
@@ -5,10 +5,10 @@ table .sherlock-code {
.sherlock-code {
pre {
word-wrap: normal;
- }
- pre code {
- white-space: pre;
+ code {
+ white-space: pre;
+ }
}
}
@@ -21,13 +21,13 @@ table .sherlock-code {
text-align: right;
padding: 0 10px !important;
}
+
+ .slow {
+ color: $red-500;
+ font-weight: $gl-font-weight-bold;
+ }
}
.sherlock-file-sample pre {
padding-top: 28px !important;
}
-
-.sherlock-line-samples-table .slow {
- color: $red-500;
- font-weight: bold;
-}
diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss
index dfa4d033fb8..8e2c42c1bd3 100644
--- a/app/assets/stylesheets/pages/stat_graph.scss
+++ b/app/assets/stylesheets/pages/stat_graph.scss
@@ -10,7 +10,6 @@
}
.axis {
- fill: $stat-graph-axis-fill;
font-size: 10px;
}
@@ -40,23 +39,21 @@
@media (max-width: $screen-xs-max) {
width: 100%;
}
- }
- .person .spark {
- display: block;
- background: $stat-graph-common-bg;
- width: 100%;
- }
+ .spark {
+ display: block;
+ background: $stat-graph-common-bg;
+ width: 100%;
+ }
- .person .area-contributor {
- fill: $stat-graph-orange-fill;
+ .area-contributor {
+ fill: $stat-graph-orange-fill;
+ }
}
}
.selection rect {
- fill: $stat-graph-selection-fill;
fill-opacity: 0.1;
- stroke: $stat-graph-selection-stroke;
stroke-width: 1px;
stroke-opacity: 0.4;
shape-rendering: crispedges;
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 67ad1ae60af..ade5ddd147b 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -1,22 +1,3 @@
-@mixin status-color($color-light, $color-main, $color-dark) {
- color: $color-main;
- border-color: $color-main;
-
- &:not(span):hover {
- background-color: $color-light;
- color: $color-dark;
- border-color: $color-dark;
-
- svg {
- fill: $color-dark;
- }
- }
-
- svg {
- fill: $color-main;
- }
-}
-
.ci-status {
padding: 2px 7px 4px;
border: 1px solid $gray-darker;
@@ -37,11 +18,11 @@
}
&.ci-failed {
- @include status-color($red-50, $red-500, $red-600);
+ @include status-color($red-100, $red-500, $red-600);
}
&.ci-success {
- @include status-color($green-50, $green-500, $green-700);
+ @include green-status-color;
}
&.ci-canceled,
@@ -58,12 +39,12 @@
&.ci-pending,
&.ci-failed_with_warnings,
&.ci-success_with_warnings {
- @include status-color($orange-50, $orange-500, $orange-700);
+ @include status-color($orange-100, $orange-500, $orange-700);
}
&.ci-info,
&.ci-running {
- @include status-color($blue-50, $blue-500, $blue-600);
+ @include status-color($blue-100, $blue-500, $blue-600);
}
&.ci-created,
@@ -74,10 +55,6 @@
&:not(span):hover {
background-color: rgba($gl-text-color-secondary, .07);
}
-
- svg {
- fill: $gl-text-color-secondary;
- }
}
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index de652a79369..4b9824fab0c 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -81,7 +81,7 @@
.todo-title {
display: flex;
- & > .title-item {
+ > .title-item {
-webkit-flex: 0 0 auto;
flex: 0 0 auto;
margin: 0 2px;
@@ -108,14 +108,14 @@
margin: 0;
float: none;
display: inline-block;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
padding: 0 5px;
line-height: inherit;
font-size: 14px;
}
.action-name {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
}
.todo-body {
@@ -128,7 +128,6 @@
.label {
color: $gl-text-color;
- font-size: inherit;
}
p {
@@ -141,7 +140,7 @@
}
pre {
- border: none;
+ border: 0;
background: $gray-light;
border-radius: 0;
color: $todo-body-pre-color;
@@ -262,6 +261,6 @@
}
a {
- font-weight: 600;
+ font-weight: $gl-font-weight-bold;
}
}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index ce1a13c6afa..e0ee7e9aa3d 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -2,6 +2,11 @@
.nav-block {
margin: 10px 0;
+ .btn .fa,
+ .btn svg {
+ color: $gl-text-color-secondary;
+ }
+
@media (min-width: $screen-sm-min) {
display: flex;
@@ -15,6 +20,11 @@
.btn-group {
margin-left: 10px;
}
+
+ .control {
+ float: left;
+ margin-left: 10px;
+ }
}
.tree-ref-holder {
@@ -22,6 +32,10 @@
margin-right: 15px;
}
+ .tree-ref-target-holder {
+ display: inline-block;
+ }
+
.repo-breadcrumb {
li:last-of-type {
position: relative;
@@ -70,7 +84,8 @@
}
.file-finder {
- width: 50%;
+ max-width: 500px;
+ width: 100%;
.file-finder-input {
width: 95%;
@@ -80,7 +95,11 @@
.add-to-tree {
vertical-align: top;
- padding: 6px 10px;
+ padding: 8px;
+
+ svg {
+ top: 0;
+ }
}
.tree-table {
@@ -113,7 +132,7 @@
color: $white-normal;
}
- &:hover {
+ &:hover:not(.tree-truncated-warning) {
td {
background-color: $row-hover;
border-top: 1px solid $row-hover-border;
@@ -157,6 +176,14 @@
}
}
+ .tree-item-file-external-link {
+ margin-right: 4px;
+
+ span {
+ text-decoration: inherit;
+ }
+ }
+
.tree_commit {
max-width: 320px;
@@ -178,6 +205,11 @@
}
}
+ .tree-truncated-warning {
+ color: $orange-600;
+ background-color: $orange-100;
+ }
+
.tree-time-ago {
min-width: 135px;
color: $gl-text-color-secondary;
@@ -208,6 +240,9 @@
}
.blob-upload-dropzone-previews {
+ display: flex;
+ justify-content: center;
+ align-items: center;
text-align: center;
border: 2px;
border-style: dashed;
@@ -216,7 +251,7 @@
}
.upload-link {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
color: $md-link-color;
}
@@ -229,7 +264,7 @@
margin-top: 20px;
padding: 0;
border-top: 1px solid $white-dark;
- border-bottom: none;
+ border-bottom: 0;
}
.commit-stats li {
diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss
index 8c87bc3cafd..48ac5b21db8 100644
--- a/app/assets/stylesheets/pages/ui_dev_kit.scss
+++ b/app/assets/stylesheets/pages/ui_dev_kit.scss
@@ -1,17 +1,17 @@
.gitlab-ui-dev-kit {
> h2 {
margin: 35px 0 20px;
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
.example {
+ padding: 15px;
+ border: 1px dashed $ui-dev-kit-example-border;
+ margin-bottom: 15px;
+
&::before {
content: "Example";
color: $ui-dev-kit-example-color;
}
-
- padding: 15px;
- border: 1px dashed $ui-dev-kit-example-border;
- margin-bottom: 15px;
}
}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 94d0a39f397..9a0ec936979 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -6,6 +6,14 @@
}
}
+.wiki-form {
+ .edit-wiki-page-slug-tip {
+ display: inline-block;
+ max-width: 100%;
+ margin-top: 5px;
+ }
+}
+
.title .edit-wiki-header {
width: 780px;
margin-left: auto;
@@ -37,7 +45,7 @@
}
.light {
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
color: $gl-text-color-secondary;
}
@@ -89,18 +97,28 @@
h3 {
font-size: 19px;
- font-weight: normal;
+ font-weight: $gl-font-weight-normal;
margin: $gl-padding 0;
}
}
.right-sidebar.wiki-sidebar {
- padding: $gl-padding 0;
+ padding: 0;
&.right-sidebar-collapsed {
display: none;
}
+ .sidebar-container {
+ padding: $gl-padding 0;
+ width: calc(100% + 100px);
+ padding-right: 100px;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
.blocks-container {
padding: 0 $gl-padding;
}
@@ -114,7 +132,11 @@
&:hover,
&.active {
- color: $black;
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
}
}
@@ -147,17 +169,22 @@
}
ul.wiki-pages-list.content-list {
- & ul {
+ ul {
list-style: none;
margin-left: 0;
padding-left: 15px;
- }
- & ul li {
- padding: 5px 0;
+ li {
+ padding: 5px 0;
+ }
}
}
+.wiki-holder {
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
.wiki {
table {
@include markdown-table;
diff --git a/app/assets/stylesheets/pages/xterm.scss b/app/assets/stylesheets/pages/xterm.scss
index b085c56390d..7d40c61da26 100644
--- a/app/assets/stylesheets/pages/xterm.scss
+++ b/app/assets/stylesheets/pages/xterm.scss
@@ -3,22 +3,21 @@
// see also: https://gist.github.com/jasonm23/2868981
$black: #000;
- $red: #cd0000;
- $green: #00cd00;
- $yellow: #cdcd00;
- $blue: #00e; // according to wikipedia, this is the xterm standard
- //$blue: #1e90ff; // this is used by all the terminals I tried (when configured with the xterm color profile)
- $magenta: #cd00cd;
- $cyan: #00cdcd;
- $white: #e5e5e5;
+ $red: #ea1010;
+ $green: #009900;
+ $yellow: #999900;
+ $blue: #0073e6;
+ $magenta: #d411d4;
+ $cyan: #009999;
+ $white: #ccc;
$l-black: #373b41;
- $l-red: #c66;
- $l-green: #b5bd68;
- $l-yellow: #f0c674;
- $l-blue: #81a2be;
- $l-magenta: #b294bb;
- $l-cyan: #8abeb7;
- $l-white: $gray-darkest;
+ $l-red: #ff6161;
+ $l-green: #00d600;
+ $l-yellow: #bdbd00;
+ $l-blue: #5797ff;
+ $l-magenta: #d96dd9;
+ $l-cyan: #00bdbd;
+ $l-white: #fff;
/*
* xterm colors
@@ -281,7 +280,7 @@
$xterm-fg-255: #eee;
.term-bold {
- font-weight: bold;
+ font-weight: $gl-font-weight-bold;
}
.term-italic {
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
new file mode 100644
index 00000000000..45ae94abaff
--- /dev/null
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -0,0 +1,132 @@
+@import 'framework/variables';
+@import 'peek/views/performance_bar';
+@import 'peek/views/rblineprof';
+
+#js-peek {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100%;
+ z-index: 2000;
+ overflow-x: hidden;
+
+ height: $performance-bar-height;
+ background: $black;
+ line-height: $performance-bar-height;
+ color: $perf-bar-text;
+
+ select {
+ width: 200px;
+ }
+
+ &.disabled {
+ display: none;
+ }
+
+ &.production {
+ background-color: $perf-bar-production;
+
+ select {
+ background: $perf-bar-production;
+ }
+ }
+
+ &.staging {
+ background-color: $perf-bar-staging;
+
+ select {
+ background: $perf-bar-staging;
+ }
+ }
+
+ &.development {
+ background-color: $perf-bar-development;
+
+ select {
+ background: $perf-bar-development;
+ }
+ }
+
+ // UI Elements
+ .bucket {
+ background: $perf-bar-bucket-bg;
+ display: inline-block;
+ padding: 4px 6px;
+ font-family: Consolas, 'Liberation Mono', Courier, monospace;
+ line-height: 1;
+ color: $perf-bar-bucket-color;
+ border-radius: 3px;
+ box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from,
+ inset 0 1px 2px $perf-bar-bucket-box-shadow-to;
+
+ .hidden {
+ display: none;
+ }
+
+ &:hover .hidden {
+ display: inline;
+ }
+ }
+
+ strong {
+ color: $white-light;
+ }
+
+ table {
+ color: $black;
+
+ strong {
+ color: $black;
+ }
+ }
+
+ .view {
+ margin-right: 15px;
+ float: left;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .css-truncate {
+ &.css-truncate-target,
+ .css-truncate-target {
+ display: inline-block;
+ max-width: 125px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ vertical-align: top;
+ }
+
+ &.expandable:hover .css-truncate-target,
+ &.expandable:hover.css-truncate-target {
+ max-width: 10000px !important;
+ }
+ }
+
+ .performance-bar-modal {
+ .modal-footer {
+ display: none;
+ }
+
+ .modal-dialog {
+ width: 860px;
+ }
+ }
+}
+
+#modal-peek-pg-queries-content {
+ color: $black;
+}
+
+.peek-rblineprof-file {
+ pre.duration {
+ width: 280px;
+ }
+
+ .data {
+ overflow: visible;
+ }
+}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 136d0c79467..b07a5ae22cd 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -17,7 +17,7 @@
.wiki h3 {
font-size: 18px;
- font-weight: bold;
+ font-weight: 600;
}
header,
@@ -37,7 +37,7 @@ ul.notes-form,
.issuable-details .content-block-small,
.edit-link,
.note-action-button {
- display: none!important;
+ display: none !important;
}
pre {
diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/test.scss
index 7d9f3da79c5..e65b49c36f3 100644
--- a/app/assets/stylesheets/test.scss
+++ b/app/assets/stylesheets/test.scss
@@ -4,14 +4,15 @@
-ms-transition: none !important;
-webkit-transition: none !important;
transition: none !important;
- -o-transform: none !important;
- -moz-transform: none !important;
- -ms-transform: none !important;
- -webkit-transform: none !important;
- transform: none !important;
-webkit-animation: none !important;
-moz-animation: none !important;
-o-animation: none !important;
-ms-animation: none !important;
animation: none !important;
}
+
+// Disable sticky changes bar for tests
+.diff-files-changed {
+ position: relative !important;
+ top: 0 !important;
+}
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 2eac0cabf7a..ed13ead63f9 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -1,7 +1,9 @@
class AbuseReportsController < ApplicationController
+ before_action :set_user, only: [:new]
+
def new
@abuse_report = AbuseReport.new
- @abuse_report.user_id = params[:user_id]
+ @abuse_report.user_id = @user.id
@ref_url = params.fetch(:ref_url, '')
end
@@ -27,4 +29,14 @@ class AbuseReportsController < ApplicationController
user_id
))
end
+
+ def set_user
+ @user = User.find_by(id: params[:user_id])
+
+ if @user.nil?
+ redirect_to root_path, alert: "Cannot create the abuse report. The user has been deleted."
+ elsif @user.blocked?
+ redirect_to @user, alert: "Cannot create the abuse report. This user has been blocked."
+ end
+ end
end
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index 4b0ec54b3f4..dd0b38970bd 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -4,8 +4,8 @@ class Admin::AppearancesController < Admin::ApplicationController
def show
end
- def preview
- render 'preview', layout: 'devise'
+ def preview_sign_in
+ render 'preview_sign_in', layout: 'devise'
end
def create
@@ -45,14 +45,14 @@ class Admin::AppearancesController < Admin::ApplicationController
# Use callbacks to share common setup or constraints between actions.
def set_appearance
- @appearance = Appearance.last || Appearance.new
+ @appearance = Appearance.current || Appearance.new
end
# Only allow a trusted parameter "white list" through.
def appearance_params
params.require(:appearance).permit(
:title, :description, :logo, :logo_cache, :header_logo, :header_logo_cache,
- :updated_by
+ :new_project_guidelines, :updated_by
)
end
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 4d4b8a8425f..4dfb397e82c 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -20,8 +20,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def usage_data
respond_to do |format|
format.html do
- usage_data = Gitlab::UsageData.data
- usage_data_json = params[:pretty] ? JSON.pretty_generate(usage_data) : usage_data.to_json
+ usage_data_json = JSON.pretty_generate(Gitlab::UsageData.data)
render html: Gitlab::Highlight.highlight('payload.json', usage_data_json)
end
@@ -71,89 +70,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params[:application_setting][:disabled_oauth_sign_in_sources] =
AuthHelper.button_based_providers.map(&:to_s) -
Array(enabled_oauth_sign_in_sources)
+
+ params[:application_setting][:restricted_visibility_levels]&.delete("")
params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file]
params.require(:application_setting).permit(
- application_setting_params_ce
+ visible_application_setting_attributes
)
end
- def application_setting_params_ce
- [
- :admin_notification_email,
- :after_sign_out_path,
- :after_sign_up_text,
- :akismet_api_key,
- :akismet_enabled,
- :container_registry_token_expire_delay,
- :default_artifacts_expire_in,
- :default_branch_protection,
- :default_group_visibility,
- :default_project_visibility,
- :default_projects_limit,
- :default_snippet_visibility,
- :domain_blacklist_enabled,
+ def visible_application_setting_attributes
+ ApplicationSettingsHelper.visible_attributes + [
:domain_blacklist_file,
- :domain_blacklist_raw,
- :domain_whitelist_raw,
- :email_author_in_body,
- :enabled_git_access_protocol,
- :gravatar_enabled,
- :help_page_text,
- :help_page_hide_commercial_content,
- :help_page_support_url,
- :home_page_url,
- :housekeeping_bitmaps_enabled,
- :housekeeping_enabled,
- :housekeeping_full_repack_period,
- :housekeeping_gc_period,
- :housekeeping_incremental_repack_period,
- :html_emails_enabled,
- :koding_enabled,
- :koding_url,
- :plantuml_enabled,
- :plantuml_url,
- :max_artifacts_size,
- :max_attachment_size,
- :max_pages_size,
- :metrics_enabled,
- :metrics_host,
- :metrics_method_call_threshold,
- :metrics_packet_size,
- :metrics_pool_size,
- :metrics_port,
- :metrics_sample_interval,
- :metrics_timeout,
- :recaptcha_enabled,
- :recaptcha_private_key,
- :recaptcha_site_key,
- :repository_checks_enabled,
- :require_two_factor_authentication,
- :session_expire_delay,
- :sign_in_text,
- :signin_enabled,
- :signup_enabled,
- :sentry_dsn,
- :sentry_enabled,
- :clientside_sentry_dsn,
- :clientside_sentry_enabled,
- :send_user_confirmation_email,
- :shared_runners_enabled,
- :shared_runners_text,
- :sidekiq_throttling_enabled,
- :sidekiq_throttling_factor,
- :two_factor_grace_period,
- :user_default_external,
- :user_oauth_applications,
- :unique_ips_limit_per_user,
- :unique_ips_limit_time_window,
- :unique_ips_limit_enabled,
- :version_check_enabled,
- :terminal_max_session_time,
- :polling_interval_multiplier,
- :prometheus_metrics_enabled,
- :usage_ping_enabled,
-
disabled_oauth_sign_in_sources: [],
import_sources: [],
repository_storages: [],
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 434ff6b2a62..5be23c76a95 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -19,10 +19,11 @@ class Admin::ApplicationsController < Admin::ApplicationController
end
def create
- @application = Doorkeeper::Application.new(application_params)
+ @application = Applications::CreateService.new(current_user, application_params).execute(request)
- if @application.save
+ if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
+
redirect_to admin_application_url(@application)
else
render :new
@@ -50,6 +51,6 @@ class Admin::ApplicationsController < Admin::ApplicationController
# Only allow a trusted parameter "white list" through.
def application_params
- params[:doorkeeper_application].permit(:name, :redirect_uri, :scopes)
+ params.require(:doorkeeper_application).permit(:name, :redirect_uri, :trusted, :scopes)
end
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 762e36ee2e9..a9109a1d4d0 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -1,8 +1,10 @@
class Admin::BroadcastMessagesController < Admin::ApplicationController
+ include BroadcastMessagesHelper
+
before_action :finder, only: [:edit, :update, :destroy]
def index
- @broadcast_messages = BroadcastMessage.reorder("ends_at DESC").page(params[:page])
+ @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page])
@broadcast_message = BroadcastMessage.new
end
@@ -37,7 +39,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
end
def preview
- @broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ render json: { message: render_broadcast_message(broadcast_message) }
end
protected
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index 9b77c554908..10d9d1b5345 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -1,6 +1,6 @@
class Admin::CohortsController < Admin::ApplicationController
def index
- if current_application_settings.usage_ping_enabled
+ if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
CohortsService.new.execute
end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 8360ce08bdc..e85cdcb8db7 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -1,7 +1,7 @@
class Admin::DashboardController < Admin::ApplicationController
def index
- @projects = Project.with_route.limit(10)
- @users = User.limit(10)
- @groups = Group.with_route.limit(10)
+ @projects = Project.order_id_desc.without_deleted.with_route.limit(10)
+ @users = User.order_id_desc.limit(10)
+ @groups = Group.order_id_desc.with_route.limit(10)
end
end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index e5cba774dcb..b0c4c31cffc 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -10,9 +10,8 @@ class Admin::DeployKeysController < Admin::ApplicationController
end
def create
- @deploy_key = deploy_keys.new(create_params.merge(user: current_user))
-
- if @deploy_key.save
+ @deploy_key = DeployKeys::CreateService.new(current_user, create_params.merge(public: true)).execute
+ if @deploy_key.persisted?
redirect_to admin_deploy_keys_path
else
render 'new'
@@ -51,10 +50,10 @@ class Admin::DeployKeysController < Admin::ApplicationController
end
def create_params
- params.require(:deploy_key).permit(:key, :title, :can_push)
+ params.require(:deploy_key).permit(:key, :title)
end
def update_params
- params.require(:deploy_key).permit(:title, :can_push)
+ params.require(:deploy_key).permit(:title)
end
end
diff --git a/app/controllers/admin/gitaly_servers_controller.rb b/app/controllers/admin/gitaly_servers_controller.rb
new file mode 100644
index 00000000000..11c4dfe3d8d
--- /dev/null
+++ b/app/controllers/admin/gitaly_servers_controller.rb
@@ -0,0 +1,5 @@
+class Admin::GitalyServersController < Admin::ApplicationController
+ def index
+ @gitaly_servers = Gitaly::Server.all
+ end
+end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 2ce26de1768..cc38608eda5 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -1,4 +1,6 @@
class Admin::GroupsController < Admin::ApplicationController
+ include MembersPresentation
+
before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update]
def index
@@ -10,8 +12,10 @@ class Admin::GroupsController < Admin::ApplicationController
def show
@group = Group.with_statistics.joins(:route).group('routes.path').find_by_full_path(params[:id])
- @members = @group.members.order("access_level DESC").page(params[:members_page])
- @requesters = AccessRequestsFinder.new(@group).execute(current_user)
+ @members = present_members(
+ @group.members.order("access_level DESC").page(params[:members_page]))
+ @requesters = present_members(
+ AccessRequestsFinder.new(@group).execute(current_user))
@projects = @group.projects.with_statistics.page(params[:projects_page])
end
@@ -44,7 +48,7 @@ class Admin::GroupsController < Admin::ApplicationController
def members_update
member_params = params.permit(:user_ids, :access_level, :expires_at)
- result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute
+ result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group)
if result[:status] == :success
redirect_to [:admin, @group], notice: 'Users were successfully added.'
diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb
index caf4c138da8..61247b280b3 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -1,5 +1,12 @@
class Admin::HealthCheckController < Admin::ApplicationController
def show
@errors = HealthCheck::Utils.process_checks(['standard'])
+ @failing_storage_statuses = Gitlab::Git::Storage::Health.for_failing_storages
+ end
+
+ def reset_storage_health
+ Gitlab::Git::Storage::FailureInfo.reset_all!
+ redirect_to admin_health_check_path,
+ notice: _('Git storage health information has been reset')
end
end
diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb
index aa069b89563..3017f96c26f 100644
--- a/app/controllers/admin/hook_logs_controller.rb
+++ b/app/controllers/admin/hook_logs_controller.rb
@@ -10,9 +10,9 @@ class Admin::HookLogsController < Admin::ApplicationController
end
def retry
- status, message = hook.execute(hook_log.request_data, hook_log.trigger)
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
- set_hook_execution_notice(status, message)
+ set_hook_execution_notice(result)
redirect_to edit_admin_hook_path(@hook)
end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 054c3500b35..2b47819303e 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -38,9 +38,9 @@ class Admin::HooksController < Admin::ApplicationController
end
def test
- status, message = hook.execute(sample_hook_data, 'system_hooks')
+ result = TestHooks::SystemService.new(hook, current_user, params[:trigger]).execute
- set_hook_execution_notice(status, message)
+ set_hook_execution_notice(result)
redirect_back_or_default
end
@@ -59,22 +59,9 @@ class Admin::HooksController < Admin::ApplicationController
def hook_params
params.require(:hook).permit(
:enable_ssl_verification,
- :push_events,
- :tag_push_events,
- :repository_update_events,
:token,
- :url
+ :url,
+ *SystemHook.triggers.values
)
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/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index 07c8bf714fc..a7b562b1d8e 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -9,7 +9,6 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
@impersonation_token = finder.build(impersonation_token_params)
if @impersonation_token.save
- flash[:impersonation_token] = @impersonation_token.token
redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created."
else
set_index_vars
@@ -44,7 +43,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
end
def set_index_vars
- @scopes = Gitlab::Auth::API_SCOPES
+ @scopes = Gitlab::Auth.available_scopes(current_user)
@impersonation_token ||= finder.build
@inactive_impersonation_tokens = finder(state: 'inactive').execute
diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb
index 5162273ef8a..ae7a7f6279c 100644
--- a/app/controllers/admin/jobs_controller.rb
+++ b/app/controllers/admin/jobs_controller.rb
@@ -20,6 +20,6 @@ class Admin::JobsController < Admin::ApplicationController
def cancel_all
Ci::Build.running_or_pending.each(&:cancel)
- redirect_to admin_jobs_path
+ redirect_to admin_jobs_path, status: 303
end
end
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index cbc7a14ae83..7eb8f758807 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -29,7 +29,7 @@ class Admin::LabelsController < Admin::ApplicationController
@label = Labels::UpdateService.new(label_params).execute(@label)
if @label.valid?
- redirect_to admin_labels_path, notice: 'label was successfully updated.'
+ redirect_to admin_labels_path, notice: 'Label was successfully updated.'
else
render :edit
end
diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb
index b999018dde4..12a27cede75 100644
--- a/app/controllers/admin/logs_controller.rb
+++ b/app/controllers/admin/logs_controller.rb
@@ -1,2 +1,18 @@
class Admin::LogsController < Admin::ApplicationController
+ before_action :loggers
+
+ def show
+ end
+
+ private
+
+ def loggers
+ @loggers ||= [
+ Gitlab::AppLogger,
+ Gitlab::GitLogger,
+ Gitlab::EnvironmentLogger,
+ Gitlab::SidekiqLogger,
+ Gitlab::RepositoryCheckLogger
+ ]
+ end
end
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index a1975c0e341..3afe66c3566 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -1,20 +1,13 @@
class Admin::ProjectsController < Admin::ApplicationController
+ include MembersPresentation
+
before_action :project, only: [:show, :transfer, :repository_check]
before_action :group, only: [:show, :transfer]
def index
params[:sort] ||= 'latest_activity_desc'
- @projects = Project.with_statistics
- @projects = @projects.in_namespace(params[:namespace_id]) if params[:namespace_id].present?
- @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present?
- @projects = @projects.with_push if params[:with_push].present?
- @projects = @projects.abandoned if params[:abandoned].present?
- @projects = @projects.where(last_repository_check_failed: true) if params[:last_repository_check_failed].present?
- @projects = @projects.non_archived unless params[:archived].present?
- @projects = @projects.personal(current_user) if params[:personal].present?
- @projects = @projects.search(params[:name]) if params[:name].present?
- @projects = @projects.sort(@sort = params[:sort])
- @projects = @projects.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page])
+ @sort = params[:sort]
+ @projects = Admin::ProjectsFinder.new(params: params, current_user: current_user).execute
respond_to do |format|
format.html
@@ -28,11 +21,14 @@ class Admin::ProjectsController < Admin::ApplicationController
def show
if @group
- @group_members = @group.members.order("access_level DESC").page(params[:group_members_page])
+ @group_members = present_members(
+ @group.members.order("access_level DESC").page(params[:group_members_page]))
end
- @project_members = @project.members.page(params[:project_members_page])
- @requesters = AccessRequestsFinder.new(@project).execute(current_user)
+ @project_members = present_members(
+ @project.members.page(params[:project_members_page]))
+ @requesters = present_members(
+ AccessRequestsFinder.new(@project).execute(current_user))
end
def transfer
@@ -40,14 +36,14 @@ class Admin::ProjectsController < Admin::ApplicationController
::Projects::TransferService.new(@project, current_user, params.dup).execute(namespace)
@project.reload
- redirect_to admin_namespace_project_path(@project.namespace, @project)
+ redirect_to admin_project_path(@project)
end
def repository_check
RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id)
redirect_to(
- admin_namespace_project_path(@project.namespace, @project),
+ admin_project_path(@project),
notice: 'Repository check was triggered.'
)
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 719893c0bc8..4b01904f2a1 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -2,7 +2,8 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: :index
def index
- @runners = Ci::Runner.order('id DESC')
+ sort = params[:sort] == 'contacted_asc' ? { contacted_at: :asc } : { id: :desc }
+ @runners = Ci::Runner.order(sort)
@runners = @runners.search(params[:search]) if params[:search].present?
@runners = @runners.page(params[:page]).per(30)
@active_runners_cnt = Ci::Runner.online.count
@@ -64,6 +65,7 @@ class Admin::RunnersController < Admin::ApplicationController
else
Project.all
end
+
@projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any?
@projects = @projects.page(params[:page]).per(30)
end
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 4c3d336b3af..a7025b62ad7 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -1,6 +1,7 @@
class Admin::ServicesController < Admin::ApplicationController
include ServiceParams
+ before_action :whitelist_query_limiting, only: [:index]
before_action :service, only: [:edit, :update]
def index
@@ -37,4 +38,8 @@ class Admin::ServicesController < Admin::ApplicationController
def service
@service ||= Service.where(id: params[:id], template: true).first
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42430')
+ end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index fa1bc72560e..156a8e2c515 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -17,7 +17,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def keys
- @keys = user.keys
+ @keys = user.keys.order_id_desc
end
def new
@@ -117,15 +117,18 @@ class Admin::UsersController < Admin::ApplicationController
user_params_with_pass = user_params.dup
if params[:user][:password].present?
- user_params_with_pass.merge!(
+ password_params = {
password: params[:user][:password],
- password_confirmation: params[:user][:password_confirmation],
- password_expires_at: Time.now
- )
+ password_confirmation: params[:user][:password_confirmation]
+ }
+
+ password_params[:password_expires_at] = Time.now unless changing_own_password?
+
+ user_params_with_pass.merge!(password_params)
end
respond_to do |format|
- result = Users::UpdateService.new(user, user_params_with_pass).execute do |user|
+ result = Users::UpdateService.new(current_user, user_params_with_pass.merge(user: user)).execute do |user|
user.skip_reconfirmation!
end
@@ -152,7 +155,7 @@ class Admin::UsersController < Admin::ApplicationController
def remove_email
email = user.emails.find(params[:email_id])
- success = Emails::DestroyService.new(user, email: email.email).execute
+ success = Emails::DestroyService.new(current_user, user: user).execute(email)
respond_to do |format|
if success
@@ -167,6 +170,10 @@ class Admin::UsersController < Admin::ApplicationController
protected
+ def changing_own_password?
+ user == current_user
+ end
+
def user
@user ||= User.find_by!(username: params[:id])
end
@@ -204,6 +211,7 @@ class Admin::UsersController < Admin::ApplicationController
:provider,
:remember_me,
:skype,
+ :theme_id,
:twitter,
:username,
:website_url
@@ -211,7 +219,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def update_user(&block)
- result = Users::UpdateService.new(user).execute(&block)
+ result = Users::UpdateService.new(current_user, user: user).execute(&block)
result[:status] == :success
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 824ce845706..7f83bd10e93 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -2,17 +2,15 @@ require 'gon'
require 'fogbugz'
class ApplicationController < ActionController::Base
- include Gitlab::CurrentSettings
include Gitlab::GonHelper
include GitlabRoutingHelper
include PageLayoutHelper
include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
- include Peek::Rblineprof::CustomControllerHelpers
+ include WithPerformanceBar
- before_action :authenticate_user_from_private_token!
- before_action :authenticate_user_from_rss_token!
+ before_action :authenticate_sessionless_user!
before_action :authenticate_user!
before_action :validate_user_service_ticket!
before_action :check_password_expiration
@@ -25,9 +23,11 @@ class ApplicationController < ActionController::Base
around_action :set_locale
+ after_action :set_page_title_header, if: -> { request.format == :json }
+
protect_from_forgery with: :exception
- helper_method :can?, :current_application_settings
+ helper_method :can?
helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?
rescue_from Encoding::CompatibilityError do |exception|
@@ -52,6 +52,15 @@ class ApplicationController < ActionController::Base
head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window
end
+ rescue_from Gitlab::Git::Storage::Inaccessible, GRPC::Unavailable, Gitlab::Git::CommandError do |exception|
+ Raven.capture_exception(exception) if sentry_enabled?
+ log_exception(exception)
+
+ headers['Retry-After'] = exception.retry_after if exception.respond_to?(:retry_after)
+
+ render_503
+ end
+
def redirect_back_or_default(default: root_path, options: {})
redirect_to request.referer.present? ? :back : default, options
end
@@ -68,50 +77,40 @@ class ApplicationController < ActionController::Base
end
end
- def peek_enabled?
- return false unless Gitlab::PerformanceBar.enabled?
- return false unless current_user
-
- if RequestStore.active?
- if RequestStore.store.key?(:peek_enabled)
- RequestStore.store[:peek_enabled]
- else
- RequestStore.store[:peek_enabled] = cookies[:perf_bar_enabled].present?
- end
- else
- cookies[:perf_bar_enabled].present?
- end
- end
-
protected
- # This filter handles both private tokens and personal access tokens
- def authenticate_user_from_private_token!
- token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence
+ def append_info_to_payload(payload)
+ super
+ payload[:remote_ip] = request.remote_ip
- return unless token.present?
+ logged_user = auth_user
- user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token)
-
- sessionless_sign_in(user)
+ if logged_user.present?
+ payload[:user_id] = logged_user.try(:id)
+ payload[:username] = logged_user.try(:username)
+ end
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
+ # Controllers such as GitHttpController may use alternative methods
+ # (e.g. tokens) to authenticate the user, whereas Devise sets current_user
+ def auth_user
+ return current_user if current_user.present?
- return unless token.present?
+ return try(:authenticated_user)
+ end
- user = User.find_by_rss_token(token)
+ # This filter handles personal access tokens, and atom requests with rss tokens
+ def authenticate_sessionless_user!
+ user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user
- sessionless_sign_in(user)
+ sessionless_sign_in(user) if user
end
def log_exception(exception)
+ Raven.capture_exception(exception) if sentry_enabled?
+
application_trace = ActionDispatch::ExceptionWrapper.new(env, exception).application_trace
- application_trace.map!{ |t| " #{t}\n" }
+ application_trace.map! { |t| " #{t}\n" }
logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}"
end
@@ -120,17 +119,22 @@ class ApplicationController < ActionController::Base
end
def after_sign_out_path_for(resource)
- current_application_settings.after_sign_out_path.presence || new_user_session_path
+ Gitlab::CurrentSettings.after_sign_out_path.presence || new_user_session_path
end
def can?(object, action, subject = :global)
Ability.allowed?(object, action, subject)
end
- def access_denied!
+ def access_denied!(message = nil)
respond_to do |format|
- format.json { head :not_found }
- format.any { render "errors/access_denied", layout: "errors", status: 404 }
+ format.any { head :not_found }
+ format.html do
+ render "errors/access_denied",
+ layout: "errors",
+ status: 404,
+ locals: { message: message }
+ end
end
end
@@ -147,6 +151,8 @@ class ApplicationController < ActionController::Base
format.html do
render file: Rails.root.join("public", "404"), layout: false, status: "404"
end
+ # Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file
+ format.js { render json: '', status: :not_found, content_type: 'application/json' }
format.any { head :not_found }
end
end
@@ -155,6 +161,19 @@ class ApplicationController < ActionController::Base
head :unprocessable_entity
end
+ def render_503
+ respond_to do |format|
+ format.html do
+ render(
+ file: Rails.root.join("public", "503"),
+ layout: false,
+ status: :service_unavailable
+ )
+ end
+ format.any { head :service_unavailable }
+ end
+ end
+
def no_cache_headers
response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
response.headers["Pragma"] = "no-cache"
@@ -172,7 +191,7 @@ class ApplicationController < ActionController::Base
return unless signed_in? && session[:service_tickets]
valid = session[:service_tickets].all? do |provider, ticket|
- Gitlab::OAuth::Session.valid?(provider, ticket)
+ Gitlab::Auth::OAuth::Session.valid?(provider, ticket)
end
unless valid
@@ -183,7 +202,11 @@ class ApplicationController < ActionController::Base
end
def check_password_expiration
- if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user?
+ return if session[:impersonator_id] || !current_user&.allow_password_authentication?
+
+ password_expires_at = current_user&.password_expires_at
+
+ if password_expires_at && password_expires_at < Time.now
return redirect_to new_profile_password_path
end
end
@@ -192,7 +215,7 @@ class ApplicationController < ActionController::Base
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
- unless Gitlab::LDAP::Access.allowed?(current_user)
+ unless Gitlab::Auth::LDAP::Access.allowed?(current_user)
sign_out current_user
flash[:alert] = "Access denied for your LDAP account."
redirect_to new_user_session_path
@@ -207,7 +230,7 @@ class ApplicationController < ActionController::Base
end
def gitlab_ldap_access(&block)
- Gitlab::LDAP::Access.open { |access| yield(access) }
+ Gitlab::Auth::LDAP::Access.open { |access| yield(access) }
end
# JSON for infinite scroll via Pager object
@@ -249,51 +272,51 @@ class ApplicationController < ActionController::Base
end
def import_sources_enabled?
- !current_application_settings.import_sources.empty?
+ !Gitlab::CurrentSettings.import_sources.empty?
end
def github_import_enabled?
- current_application_settings.import_sources.include?('github')
+ Gitlab::CurrentSettings.import_sources.include?('github')
end
def gitea_import_enabled?
- current_application_settings.import_sources.include?('gitea')
+ Gitlab::CurrentSettings.import_sources.include?('gitea')
end
def github_import_configured?
- Gitlab::OAuth::Provider.enabled?(:github)
+ Gitlab::Auth::OAuth::Provider.enabled?(:github)
end
def gitlab_import_enabled?
- request.host != 'gitlab.com' && current_application_settings.import_sources.include?('gitlab')
+ request.host != 'gitlab.com' && Gitlab::CurrentSettings.import_sources.include?('gitlab')
end
def gitlab_import_configured?
- Gitlab::OAuth::Provider.enabled?(:gitlab)
+ Gitlab::Auth::OAuth::Provider.enabled?(:gitlab)
end
def bitbucket_import_enabled?
- current_application_settings.import_sources.include?('bitbucket')
+ Gitlab::CurrentSettings.import_sources.include?('bitbucket')
end
def bitbucket_import_configured?
- Gitlab::OAuth::Provider.enabled?(:bitbucket)
+ Gitlab::Auth::OAuth::Provider.enabled?(:bitbucket)
end
def google_code_import_enabled?
- current_application_settings.import_sources.include?('google_code')
+ Gitlab::CurrentSettings.import_sources.include?('google_code')
end
def fogbugz_import_enabled?
- current_application_settings.import_sources.include?('fogbugz')
+ Gitlab::CurrentSettings.import_sources.include?('fogbugz')
end
def git_import_enabled?
- current_application_settings.import_sources.include?('git')
+ Gitlab::CurrentSettings.import_sources.include?('git')
end
def gitlab_project_import_enabled?
- current_application_settings.import_sources.include?('gitlab_project')
+ Gitlab::CurrentSettings.import_sources.include?('gitlab_project')
end
# U2F (universal 2nd factor) devices need a unique identifier for the application
@@ -316,4 +339,9 @@ class ApplicationController < ActionController::Base
sign_in user, store: false
end
end
+
+ def set_page_title_header
+ # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8
+ response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
+ end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index fe331a883c1..86bade49ec9 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -1,75 +1,53 @@
class AutocompleteController < ApplicationController
- skip_before_action :authenticate_user!, only: [:users]
+ AWARD_EMOJI_MAX = 100
+
+ skip_before_action :authenticate_user!, only: [:users, :award_emojis]
before_action :load_project, only: [:users]
- before_action :find_users, only: [:users]
+ before_action :load_group, only: [:users]
def users
- @users ||= User.none
- @users = @users.search(params[:search]) if params[:search].present?
- @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]).per(params[:per_page])
-
- if params[:todo_filter].present? && current_user
- @users = @users.todo_authors(current_user.id, params[:todo_state_filter])
- end
-
- if params[:search].blank?
- # Include current user if available to filter by "Me"
- if params[:current_user].present? && current_user
- @users = [current_user, *@users].uniq
- end
-
- if params[:author_id].present? && current_user
- author = User.find_by_id(params[:author_id])
- @users = [author, *@users].uniq if author
- end
- end
+ @users = AutocompleteUsersFinder.new(params: params, current_user: current_user, project: @project, group: @group).execute
- render json: @users, only: [:name, :username, :id], methods: [:avatar_url]
+ render json: UserSerializer.new.represent(@users)
end
def user
@user = User.find(params[:id])
- render json: @user, only: [:name, :username, :id], methods: [:avatar_url]
+ render json: UserSerializer.new.represent(@user)
end
def projects
project = Project.find_by_id(params[:project_id])
projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
- no_project = {
- id: 0,
- name_with_namespace: 'No project'
- }
- projects.unshift(no_project) unless params[:offset_id].present?
-
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end
- private
-
- def find_users
- @users =
- if @project
- user_ids = @project.team.users.pluck(:id)
+ def award_emojis
+ emoji_with_count = AwardEmoji
+ .limit(AWARD_EMOJI_MAX)
+ .where(user: current_user)
+ .group(:name)
+ .order('count_all DESC, name ASC')
+ .count
+
+ # Transform from hash to array to guarantee json order
+ # e.g. { 'thumbsup' => 2, 'thumbsdown' = 1 }
+ # => [{ name: 'thumbsup' }, { name: 'thumbsdown' }]
+ render json: emoji_with_count.map { |k, v| { name: k } }
+ end
- if params[:author_id].present?
- user_ids << params[:author_id]
- end
+ private
- User.where(id: user_ids)
- elsif params[:group_id].present?
+ def load_group
+ @group ||= begin
+ if @project.blank? && params[:group_id].present?
group = Group.find(params[:group_id])
return render_404 unless can?(current_user, :read_group, group)
- group.users
- elsif current_user
- User.all
- else
- User.none
+ group
end
+ end
end
def load_project
@@ -77,6 +55,7 @@ class AutocompleteController < ApplicationController
if params[:project_id].present?
project = Project.find(params[:project_id])
return render_404 unless can?(current_user, :read_project, project)
+
project
end
end
diff --git a/app/controllers/boards/application_controller.rb b/app/controllers/boards/application_controller.rb
new file mode 100644
index 00000000000..b2675025fc0
--- /dev/null
+++ b/app/controllers/boards/application_controller.rb
@@ -0,0 +1,21 @@
+module Boards
+ class ApplicationController < ::ApplicationController
+ respond_to :json
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ private
+
+ def board
+ @board ||= Board.find(params[:board_id])
+ end
+
+ def board_parent
+ @board_parent ||= board.parent
+ end
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+ end
+end
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
new file mode 100644
index 00000000000..19dbee84c11
--- /dev/null
+++ b/app/controllers/boards/issues_controller.rb
@@ -0,0 +1,113 @@
+module Boards
+ class IssuesController < Boards::ApplicationController
+ include BoardsResponses
+ include ControllerWithCrossProjectAccessCheck
+
+ requires_cross_project_access if: -> { board&.group_board? }
+
+ before_action :whitelist_query_limiting, only: [:index, :update]
+ before_action :authorize_read_issue, only: [:index]
+ before_action :authorize_create_issue, only: [:create]
+ before_action :authorize_update_issue, only: [:update]
+ skip_before_action :authenticate_user!, only: [:index]
+
+ def index
+ issues = Boards::Issues::ListService.new(board_parent, current_user, filter_params).execute
+ issues = issues.page(params[:page]).per(params[:per] || 20)
+ make_sure_position_is_set(issues) if Gitlab::Database.read_write?
+ issues = issues.preload(:project,
+ :milestone,
+ :assignees,
+ labels: [:priorities],
+ notes: [:award_emoji, :author]
+ )
+
+ render json: {
+ issues: serialize_as_json(issues),
+ size: issues.total_count
+ }
+ end
+
+ def create
+ service = Boards::Issues::CreateService.new(board_parent, project, current_user, issue_params)
+ issue = service.execute
+
+ if issue.valid?
+ render json: serialize_as_json(issue)
+ else
+ render json: issue.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ service = Boards::Issues::MoveService.new(board_parent, current_user, move_params)
+
+ if service.execute(issue)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def make_sure_position_is_set(issues)
+ issues.each do |issue|
+ issue.move_to_end && issue.save unless issue.relative_position
+ end
+ end
+
+ def issue
+ @issue ||= issues_finder.find(params[:id])
+ end
+
+ def filter_params
+ params.merge(board_id: params[:board_id], id: params[:list_id])
+ .reject { |_, value| value.nil? }
+ end
+
+ def issues_finder
+ if board.group_board?
+ IssuesFinder.new(current_user, group_id: board_parent.id)
+ else
+ IssuesFinder.new(current_user, project_id: board_parent.id)
+ end
+ end
+
+ def project
+ @project ||= if board.group_board?
+ Project.find(issue_params[:project_id])
+ else
+ board_parent
+ end
+ end
+
+ def move_params
+ params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_id, :move_after_id)
+ end
+
+ def issue_params
+ params.require(:issue)
+ .permit(:title, :milestone_id, :project_id)
+ .merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(
+ only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
+ labels: true,
+ sidebar_endpoints: true,
+ include: {
+ project: { only: [:id, :path] },
+ assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
+ milestone: { only: [:id, :title] }
+ }
+ )
+ end
+
+ def whitelist_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42439
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42428')
+ end
+ end
+end
diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb
new file mode 100644
index 00000000000..381fd4d7508
--- /dev/null
+++ b/app/controllers/boards/lists_controller.rb
@@ -0,0 +1,75 @@
+module Boards
+ class ListsController < Boards::ApplicationController
+ include BoardsResponses
+
+ before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
+ before_action :authorize_read_list, only: [:index]
+ skip_before_action :authenticate_user!, only: [:index]
+
+ def index
+ lists = Boards::Lists::ListService.new(board.parent, current_user).execute(board)
+
+ render json: serialize_as_json(lists)
+ end
+
+ def create
+ list = Boards::Lists::CreateService.new(board.parent, current_user, list_params).execute(board)
+
+ if list.valid?
+ render json: serialize_as_json(list)
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ list = board.lists.movable.find(params[:id])
+ service = Boards::Lists::MoveService.new(board_parent, current_user, move_params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ list = board.lists.destroyable.find(params[:id])
+ service = Boards::Lists::DestroyService.new(board_parent, current_user)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = Boards::Lists::GenerateService.new(board_parent, current_user)
+
+ if service.execute(board)
+ render json: serialize_as_json(board.lists.movable)
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(
+ only: [:id, :list_type, :position],
+ methods: [:title],
+ label: true
+ )
+ end
+ end
+end
diff --git a/app/controllers/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb
index 3eb485de9db..e9bd1689a1e 100644
--- a/app/controllers/ci/lints_controller.rb
+++ b/app/controllers/ci/lints_controller.rb
@@ -7,19 +7,16 @@ module Ci
def create
@content = params[:content]
- @error = Ci::GitlabCiYamlProcessor.validation_message(@content)
+ @error = Gitlab::Ci::YamlProcessor.validation_message(@content)
@status = @error.blank?
if @error.blank?
- @config_processor = Ci::GitlabCiYamlProcessor.new(@content)
+ @config_processor = Gitlab::Ci::YamlProcessor.new(@content)
@stages = @config_processor.stages
@builds = @config_processor.builds
@jobs = @config_processor.jobs
end
- rescue
- @error = 'Undefined error'
- @status = false
- ensure
+
render :show
end
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index ea441b1736b..2753f83c3cf 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -56,9 +56,11 @@ module AuthenticatesWithTwoFactor
session.delete(:otp_user_id)
remember_me(user) if user_params[:remember_me] == '1'
+ user.save!
sign_in(user)
else
user.increment_failed_attempts!
+ Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
flash.now[:alert] = 'Invalid two-factor code.'
prompt_for_two_factor(user)
end
@@ -69,12 +71,13 @@ module AuthenticatesWithTwoFactor
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
# Remove any lingering user data from login
session.delete(:otp_user_id)
- session.delete(:challenges)
+ session.delete(:challenge)
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user)
else
user.increment_failed_attempts!
+ Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
flash.now[:alert] = 'Authentication via U2F device failed.'
prompt_for_two_factor(user)
end
diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb
new file mode 100644
index 00000000000..da830ec2cb1
--- /dev/null
+++ b/app/controllers/concerns/boards_responses.rb
@@ -0,0 +1,82 @@
+module BoardsResponses
+ include Gitlab::Utils::StrongMemoize
+
+ def board_params
+ params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
+ end
+
+ def parent
+ strong_memoize(:parent) do
+ group? ? group : project
+ end
+ end
+
+ def boards_path
+ if group?
+ group_boards_path(parent)
+ else
+ project_boards_path(parent)
+ end
+ end
+
+ def board_path(board)
+ if group?
+ group_board_path(parent, board)
+ else
+ project_board_path(parent, board)
+ end
+ end
+
+ def group?
+ instance_variable_defined?(:@group)
+ end
+
+ def authorize_read_list
+ ability = board.group_board? ? :read_group : :read_list
+
+ authorize_action_for!(board.parent, ability)
+ end
+
+ def authorize_read_issue
+ ability = board.group_board? ? :read_group : :read_issue
+
+ authorize_action_for!(board.parent, ability)
+ end
+
+ def authorize_update_issue
+ authorize_action_for!(issue, :admin_issue)
+ end
+
+ def authorize_create_issue
+ authorize_action_for!(project, :admin_issue)
+ end
+
+ def authorize_admin_list
+ authorize_action_for!(board.parent, :admin_list)
+ end
+
+ def authorize_action_for!(resource, ability)
+ return render_403 unless can?(current_user, ability, resource)
+ end
+
+ def respond_with_boards
+ respond_with(@boards) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def respond_with_board
+ respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(only: [:id])
+ end
+
+ def respond_with(resource)
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: serialize_as_json(resource)
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/controller_with_cross_project_access_check.rb b/app/controllers/concerns/controller_with_cross_project_access_check.rb
new file mode 100644
index 00000000000..a45c3384578
--- /dev/null
+++ b/app/controllers/concerns/controller_with_cross_project_access_check.rb
@@ -0,0 +1,24 @@
+module ControllerWithCrossProjectAccessCheck
+ extend ActiveSupport::Concern
+
+ included do
+ extend Gitlab::CrossProjectAccess::ClassMethods
+ before_action :cross_project_check
+ end
+
+ def cross_project_check
+ if Gitlab::CrossProjectAccess.find_check(self)&.should_run?(self)
+ authorize_cross_project_page!
+ end
+ end
+
+ def authorize_cross_project_page!
+ return if can?(current_user, :read_cross_project)
+
+ rejection_message = _(
+ "This page is unavailable because you are not allowed to read information "\
+ "across multiple projects."
+ )
+ access_denied!(rejection_message)
+ end
+end
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index 1a9904bbe57..b26a76d2b62 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -1,8 +1,10 @@
module CreatesCommit
extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil)
- if can?(current_user, :push_code, @project)
+ if user_access(@project).can_push_to_branch?(branch_name_or_ref)
@project_to_commit_into = @project
@branch_name ||= @ref
else
@@ -45,9 +47,10 @@ module CreatesCommit
end
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def authorize_edit_tree!
- return if can_collaborate_with_project?
+ return if can_collaborate_with_project?(project, ref: branch_name_or_ref)
access_denied!
end
@@ -77,9 +80,9 @@ module CreatesCommit
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def new_merge_request_path
- new_namespace_project_merge_request_path(
- @project_to_commit_into.namespace,
+ project_new_merge_request_path(
@project_to_commit_into,
merge_request: {
source_project_id: @project_to_commit_into.id,
@@ -89,20 +92,28 @@ module CreatesCommit
}
)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def existing_merge_request_path
- namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ project_merge_request_path(@project, @merge_request) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def merge_request_exists?
- return @merge_request if defined?(@merge_request)
-
- @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
- .find_by(source_project_id: @project_to_commit_into, source_branch: @branch_name, target_branch: @start_branch)
+ strong_memoize(:merge_request) do
+ MergeRequestsFinder.new(current_user, project_id: @project.id)
+ .execute
+ .opened
+ .find_by(
+ source_project_id: @project_to_commit_into,
+ source_branch: @branch_name,
+ target_branch: @start_branch)
+ end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def different_project?
- @project_to_commit_into != @project
+ @project_to_commit_into != @project # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def create_merge_request?
@@ -110,6 +121,10 @@ module CreatesCommit
# as the target branch in the same project,
# we don't want to create a merge request.
params[:create_merge_request].present? &&
- (different_project? || @start_branch != @branch_name)
+ (different_project? || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def branch_name_or_ref
+ @branch_name || @ref # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
end
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index 52e06f4945a..1ab107168c0 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -6,6 +6,13 @@ module CycleAnalyticsParams
end
def start_date(params)
- params[:start_date] == '30' ? 30.days.ago : 90.days.ago
+ case params[:start_date]
+ when '7'
+ 7.days.ago
+ when '30'
+ 30.days.ago
+ else
+ 90.days.ago
+ end
end
end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 688e8bd4a37..997af4ab9e9 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -20,13 +20,13 @@ module EnforcesTwoFactorAuthentication
end
def two_factor_authentication_required?
- current_application_settings.require_two_factor_authentication? ||
+ Gitlab::CurrentSettings.require_two_factor_authentication? ||
current_user.try(:require_two_factor_authentication_from_group?)
end
def two_factor_authentication_reason(global: -> {}, group: -> {})
if two_factor_authentication_required?
- if current_application_settings.require_two_factor_authentication?
+ if Gitlab::CurrentSettings.require_two_factor_authentication?
global.call
else
groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
@@ -36,7 +36,7 @@ module EnforcesTwoFactorAuthentication
end
def two_factor_grace_period
- periods = [current_application_settings.two_factor_grace_period]
+ periods = [Gitlab::CurrentSettings.two_factor_grace_period]
periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
periods.min
end
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
new file mode 100644
index 00000000000..fafb10090ca
--- /dev/null
+++ b/app/controllers/concerns/group_tree.rb
@@ -0,0 +1,31 @@
+module GroupTree
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def render_group_tree(groups)
+ @groups = if params[:filter].present?
+ # We find the ancestors by ID of the search results here.
+ # Otherwise the ancestors would also have filters applied,
+ # which would cause them not to be preloaded.
+ group_ids = groups.search(params[:filter]).select(:id)
+ Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
+ .base_and_ancestors
+ else
+ # Only show root groups if no parent-id is given
+ groups.where(parent_id: params[:parent_id])
+ end
+
+ @groups = @groups.with_selects_for_list(archived: params[:archived])
+ .sort(@sort = params[:sort])
+ .page(params[:page])
+
+ respond_to do |format|
+ format.html
+ format.json do
+ serializer = GroupChildSerializer.new(current_user: current_user)
+ .with_pagination(request, response)
+ serializer.expand_hierarchy if params[:filter].present?
+ render json: serializer.represent(@groups)
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+ end
+end
diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb
index 846cd60518f..a22e46b4860 100644
--- a/app/controllers/concerns/hooks_execution.rb
+++ b/app/controllers/concerns/hooks_execution.rb
@@ -3,11 +3,14 @@ module HooksExecution
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}"
+ def set_hook_execution_notice(result)
+ http_status = result[:http_status]
+ message = result[:message]
+
+ if http_status && http_status >= 200 && http_status < 400
+ flash[:notice] = "Hook executed successfully: HTTP #{http_status}"
+ elsif http_status
+ flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}"
else
flash[:alert] = "Hook execution failed: #{message}"
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 0c3b68a7ac3..a21e658fda1 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -7,14 +7,58 @@ module IssuableActions
before_action :authorize_admin_issuable!, only: :bulk_update
end
+ def show
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: serializer.represent(issuable, serializer: params[:serializer])
+ end
+ end
+ end
+
+ def update
+ @issuable = update_service.execute(issuable) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ respond_to do |format|
+ format.html do
+ recaptcha_check_if_spammable { render :edit }
+ end
+
+ format.json do
+ recaptcha_check_if_spammable(false) { render_entity_json }
+ end
+ end
+
+ rescue ActiveRecord::StaleObjectError
+ render_conflict_response
+ end
+
+ def realtime_changes
+ Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+ response = {
+ title: view_context.markdown_field(issuable, :title),
+ title_text: issuable.title,
+ description: view_context.markdown_field(issuable, :description),
+ description_text: issuable.description,
+ task_status: issuable.task_status
+ }
+
+ if issuable.edited?
+ response[:updated_at] = issuable.last_edited_at.to_time.iso8601
+ response[:updated_by_name] = issuable.last_edited_by.name
+ response[:updated_by_path] = user_path(issuable.last_edited_by)
+ end
+
+ render json: response
+ end
+
def destroy
- issuable.destroy
- destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym
- TodoService.new.public_send(destroy_method, issuable, current_user)
+ Issuable::DestroyService.new(issuable.project, current_user).execute(issuable)
name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted."
- index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
+ index_path = polymorphic_path([parent, issuable.class])
respond_to do |format|
format.html { redirect_to index_path }
@@ -33,12 +77,32 @@ module IssuableActions
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
end
+ def discussions
+ notes = issuable.notes
+ .inc_relations_for_view
+ .includes(:noteable)
+ .fresh
+
+ notes = prepare_notes_for_rendering(notes)
+ notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
+
+ discussions = Discussion.build_collection(notes, issuable)
+
+ render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self)
+ end
+
private
+ def recaptcha_check_if_spammable(should_redirect = true, &block)
+ return yield unless issuable.is_a? Spammable
+
+ recaptcha_check_with_fallback(should_redirect, &block)
+ end
+
def render_conflict_response
respond_to do |format|
format.html do
- @conflict = true
+ @conflict = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
render :edit
end
@@ -53,7 +117,7 @@ module IssuableActions
end
def labels
- @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
+ @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def authorize_destroy_issuable!
@@ -63,11 +127,15 @@ module IssuableActions
end
def authorize_admin_issuable!
- unless can?(current_user, :"admin_#{resource_name}", @project)
+ unless can?(current_user, :"admin_#{resource_name}", @project) # rubocop:disable Gitlab/ModuleWithInstanceVariables
return access_denied!
end
end
+ def authorize_update_issuable!
+ render_404 unless can?(current_user, :"update_#{resource_name}", issuable)
+ end
+
def bulk_update_params
permitted_keys = [
:issuable_ids,
@@ -92,4 +160,26 @@ module IssuableActions
def resource_name
@resource_name ||= controller_name.singularize
end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def render_entity_json
+ if @issuable.valid?
+ render json: serializer.represent(@issuable)
+ else
+ render json: { errors: @issuable.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def serializer
+ raise NotImplementedError
+ end
+
+ def update_service
+ raise NotImplementedError
+ end
+
+ def parent
+ @project || @group # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 650ec1e326a..4114ca6bf7c 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -1,73 +1,91 @@
module IssuableCollections
extend ActiveSupport::Concern
include SortingHelper
+ include Gitlab::IssuableMetadata
+ include Gitlab::Utils::StrongMemoize
included do
- helper_method :issues_finder
- helper_method :merge_requests_finder
+ helper_method :finder
end
private
- def issuable_meta_data(issuable_collection, collection_type)
- # map has to be used here since using pluck or select will
- # throw an error when ordering issuables by priority which inserts
- # a new order into the collection.
- # We cannot use reorder to not mess up the paginated collection.
- issuable_ids = issuable_collection.map(&:id)
-
- return {} if issuable_ids.empty?
-
- issuable_note_count = Note.count_for_collection(issuable_ids, @collection_type)
- issuable_votes_count = AwardEmoji.votes_for_collection(issuable_ids, @collection_type)
- issuable_merge_requests_count =
- if collection_type == 'Issue'
- MergeRequestsClosingIssues.count_for_collection(issuable_ids)
- else
- []
- end
-
- issuable_ids.each_with_object({}) do |id, issuable_meta|
- downvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.downvote? }
- upvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.upvote? }
- notes = issuable_note_count.find { |notes| notes.noteable_id == id }
- merge_requests = issuable_merge_requests_count.find { |mr| mr.first == id }
-
- issuable_meta[id] = Issuable::IssuableMeta.new(
- upvotes.try(:count).to_i,
- downvotes.try(:count).to_i,
- notes.try(:count).to_i,
- merge_requests.try(:last).to_i
- )
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def set_issuables_index
+ @issuables = issuables_collection
+
+ set_pagination
+ return if redirect_out_of_range(@total_pages)
+
+ if params[:label_name].present? && @project
+ labels_params = { project_id: @project.id, title: params[:label_name] }
+ @labels = LabelsFinder.new(current_user, labels_params).execute
+ end
+
+ @users = []
+ if params[:assignee_id].present?
+ assignee = User.find_by_id(params[:assignee_id])
+ @users.push(assignee) if assignee
end
+
+ if params[:author_id].present?
+ author = User.find_by_id(params[:author_id])
+ @users.push(author) if author
+ end
+ end
+
+ def set_pagination
+ return if pagination_disabled?
+
+ @issuables = @issuables.page(params[:page])
+ @issuable_meta_data = issuable_meta_data(@issuables, collection_type)
+ @total_pages = issuable_page_count
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
- def issues_collection
- issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
+ def pagination_disabled?
+ false
end
- def merge_requests_collection
- merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, :head_pipeline, target_project: :namespace)
+ def issuables_collection
+ finder.execute.preload(preload_for_collection)
end
- def issues_finder
- @issues_finder ||= issuable_finder_for(IssuesFinder)
+ def redirect_out_of_range(total_pages)
+ return false if total_pages.nil? || total_pages.zero?
+
+ out_of_range = @issuables.current_page > total_pages # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ if out_of_range
+ redirect_to(url_for(params.merge(page: total_pages, only_path: true)))
+ end
+
+ out_of_range
end
- def merge_requests_finder
- @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder)
+ def issuable_page_count
+ page_count_for_relation(@issuables, finder.row_count) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def page_count_for_relation(relation, row_count)
+ limit = relation.limit_value.to_f
+
+ return 1 if limit.zero?
+
+ (row_count.to_f / limit).ceil
end
def issuable_finder_for(finder_class)
finder_class.new(current_user, filter_params)
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def filter_params
set_sort_order_from_cookie
- set_default_scope
set_default_state
- @filter_params = params.dup
+ # Skip irrelevant Rails routing params
+ @filter_params = params.dup.except(:controller, :action, :namespace_id)
@filter_params[:sort] ||= default_sort_order
@sort = @filter_params[:sort]
@@ -76,6 +94,7 @@ module IssuableCollections
@filter_params[:project_id] = @project.id
elsif @group
@filter_params[:group_id] = @group.id
+ @filter_params[:include_subgroups] = true
else
# TODO: this filter ignore issues/mr created in public or
# internal repos where you are not a member. Enable this filter
@@ -84,12 +103,9 @@ module IssuableCollections
# @filter_params[:authorized_only] = true
end
- @filter_params
- end
-
- def set_default_scope
- params[:scope] = 'all' if params[:scope].blank?
+ @filter_params.permit(finder_type.valid_params)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def set_default_state
params[:state] = 'opened' if params[:state].blank?
@@ -99,19 +115,59 @@ module IssuableCollections
key = 'issuable_sort'
cookies[key] = params[:sort] if params[:sort].present?
-
- # id_desc and id_asc are old values for these two.
- cookies[key] = sort_value_recently_created if cookies[key] == 'id_desc'
- cookies[key] = sort_value_oldest_created if cookies[key] == 'id_asc'
-
+ cookies[key] = update_cookie_value(cookies[key])
params[:sort] = cookies[key]
end
def default_sort_order
case params[:state]
- when 'opened', 'all' then sort_value_recently_created
+ when 'opened', 'all' then sort_value_created_date
when 'merged', 'closed' then sort_value_recently_updated
- else sort_value_recently_created
+ else sort_value_created_date
+ end
+ end
+
+ # Update old values to the actual ones.
+ def update_cookie_value(value)
+ case value
+ when 'id_asc' then sort_value_oldest_created
+ when 'id_desc' then sort_value_recently_created
+ when 'created_asc' then sort_value_created_date
+ when 'created_desc' then sort_value_created_date
+ when 'due_date_asc' then sort_value_due_date
+ when 'due_date_desc' then sort_value_due_date
+ when 'milestone_due_asc' then sort_value_milestone
+ when 'milestone_due_desc' then sort_value_milestone
+ when 'downvotes_asc' then sort_value_popularity
+ when 'downvotes_desc' then sort_value_popularity
+ else value
end
end
+
+ def finder
+ strong_memoize(:finder) do
+ issuable_finder_for(finder_type)
+ end
+ end
+
+ def collection_type
+ @collection_type ||= case finder
+ when IssuesFinder
+ 'Issue'
+ when MergeRequestsFinder
+ 'MergeRequest'
+ end
+ end
+
+ def preload_for_collection
+ @preload_for_collection ||= case collection_type
+ when 'Issue'
+ [:project, :author, :assignees, :labels, :milestone, project: :namespace]
+ when 'MergeRequest'
+ [
+ :source_project, :target_project, :author, :assignee, :labels, :milestone,
+ head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
+ ]
+ end
+ end
end
diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb
index 404559c8707..3b11a373368 100644
--- a/app/controllers/concerns/issues_action.rb
+++ b/app/controllers/concerns/issues_action.rb
@@ -2,19 +2,25 @@ module IssuesAction
extend ActiveSupport::Concern
include IssuableCollections
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def issues
- @label = issues_finder.labels.first
-
- @issues = issues_collection
+ @issues = issuables_collection
.non_archived
.page(params[:page])
- @collection_type = "Issue"
- @issuable_meta_data = issuable_meta_data(@issues, @collection_type)
+ @issuable_meta_data = issuable_meta_data(@issues, collection_type)
respond_to do |format|
format.html
format.atom { render layout: 'xml.atom' }
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ private
+
+ def finder_type
+ (super if defined?(super)) ||
+ (IssuesFinder if action_name == 'issues')
+ end
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 2b6afaa6233..5e4e8a87153 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -10,6 +10,8 @@
module LfsRequest
extend ActiveSupport::Concern
+ CONTENT_TYPE = 'application/vnd.git-lfs+json'.freeze
+
included do
before_action :require_lfs_enabled!
before_action :lfs_check_access!
@@ -50,7 +52,7 @@ module LfsRequest
message: 'Access forbidden. Check your access level.',
documentation_url: help_url
},
- content_type: "application/vnd.git-lfs+json",
+ content_type: CONTENT_TYPE,
status: 403
)
end
@@ -61,7 +63,7 @@ module LfsRequest
message: 'Not found.',
documentation_url: help_url
},
- content_type: "application/vnd.git-lfs+json",
+ content_type: CONTENT_TYPE,
status: 404
)
end
@@ -74,8 +76,9 @@ module LfsRequest
def lfs_upload_access?
return false unless project.lfs_enabled?
+ return false unless has_authentication_ability?(:push_code)
- has_authentication_ability?(:push_code) && can?(user, :push_code, project)
+ lfs_deploy_token? || can?(user, :push_code, project)
end
def lfs_deploy_token?
@@ -91,16 +94,7 @@ module LfsRequest
end
def storage_project
- @storage_project ||= begin
- result = project
-
- loop do
- break unless result.forked?
- result = result.forked_from_project
- end
-
- result
- end
+ @storage_project ||= project.lfs_storage_project
end
def objects
diff --git a/app/controllers/concerns/members_presentation.rb b/app/controllers/concerns/members_presentation.rb
new file mode 100644
index 00000000000..c0622516fd3
--- /dev/null
+++ b/app/controllers/concerns/members_presentation.rb
@@ -0,0 +1,11 @@
+module MembersPresentation
+ extend ActiveSupport::Concern
+
+ def present_members(members)
+ Gitlab::View::Presenter::Factory.new(
+ members,
+ current_user: current_user,
+ presenter_class: MembersPresenter
+ ).fabricate!
+ end
+end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 47d9ae350ae..7a6a00b8e13 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -3,20 +3,31 @@ module MembershipActions
def create
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
+ result = Members::CreateService.new(current_user, create_params).execute(membershipable)
if result[:status] == :success
- redirect_to redirect_url, notice: 'Users were successfully added.'
+ redirect_to members_page_url, notice: 'Users were successfully added.'
else
- redirect_to redirect_url, alert: result[:message]
+ redirect_to members_page_url, alert: result[:message]
+ end
+ end
+
+ def update
+ update_params = params.require(root_params_key).permit(:access_level, :expires_at)
+ member = membershipable.members_and_requesters.find(params[:id])
+ member = Members::UpdateService
+ .new(current_user, update_params)
+ .execute(member)
+ .present(current_user: current_user)
+
+ respond_to do |format|
+ format.js { render 'shared/members/update', locals: { member: member } }
end
end
def destroy
- Members::DestroyService.new(membershipable, current_user, params)
- .execute(:all)
+ member = membershipable.members_and_requesters.find(params[:id])
+ Members::DestroyService.new(current_user).execute(member)
respond_to do |format|
format.html do
@@ -36,14 +47,17 @@ module MembershipActions
end
def approve_access_request
- Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute
+ access_requester = membershipable.requesters.find(params[:id])
+ Members::ApproveAccessRequestService
+ .new(current_user, params)
+ .execute(access_requester)
redirect_to members_page_url
end
def leave
- member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id)
- .execute(:all)
+ member = membershipable.members_and_requesters.find_by!(user_id: current_user.id)
+ Members::DestroyService.new(current_user).execute(member)
notice =
if member.request?
@@ -62,17 +76,43 @@ module MembershipActions
end
end
+ def resend_invite
+ member = membershipable.members.find(params[:id])
+
+ if member.invite?
+ member.resend_invite
+
+ redirect_to members_page_url, notice: 'The invitation was successfully resent.'
+ else
+ redirect_to members_page_url, alert: 'The invitation has already been accepted.'
+ end
+ end
+
protected
def membershipable
raise NotImplementedError
end
- def members_page_url
- if membershipable.is_a?(Project)
- project_settings_members_path(membershipable)
+ def root_params_key
+ case membershipable
+ when Namespace
+ :group_member
+ when Project
+ :project_member
else
+ raise "Unknown membershipable type: #{membershipable}!"
+ end
+ end
+
+ def members_page_url
+ case membershipable
+ when Namespace
polymorphic_url([membershipable, :members])
+ when Project
+ project_project_members_path(membershipable)
+ else
+ raise "Unknown membershipable type: #{membershipable}!"
end
end
diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb
index d3c8e4888bc..b70db99b157 100644
--- a/app/controllers/concerns/merge_requests_action.rb
+++ b/app/controllers/concerns/merge_requests_action.rb
@@ -2,18 +2,21 @@ module MergeRequestsAction
extend ActiveSupport::Concern
include IssuableCollections
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def merge_requests
- @label = merge_requests_finder.labels.first
+ @merge_requests = issuables_collection.page(params[:page])
- @merge_requests = merge_requests_collection
- .page(params[:page])
-
- @collection_type = "MergeRequest"
- @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
+ @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
private
+ def finder_type
+ (super if defined?(super)) ||
+ (MergeRequestsFinder if action_name == 'merge_requests')
+ end
+
def filter_params
super.merge(non_archived: true)
end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index 1ff785ac2ca..d92cf8b4894 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -6,7 +6,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_merge_requests_tab", {
- merge_requests: @milestone.sorted_merge_requests,
+ merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables
show_project_name: true
})
end
@@ -18,7 +18,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_participants_tab", {
- users: @milestone.participants
+ users: @milestone.participants # rubocop:disable Gitlab/ModuleWithInstanceVariables
})
end
end
@@ -29,7 +29,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_labels_tab", {
- labels: @milestone.labels
+ labels: @milestone.labels # rubocop:disable Gitlab/ModuleWithInstanceVariables
})
end
end
@@ -43,13 +43,15 @@ module MilestoneActions
}
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def milestone_redirect_path
if @project
- namespace_project_milestone_path(@project.namespace, @project, @milestone)
+ project_milestone_path(@project, @milestone)
elsif @group
group_milestone_path(@group, @milestone.safe_title, title: @milestone.title)
else
dashboard_milestone_path(@milestone.safe_title, title: @milestone.title)
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index a57d9e6e6c0..03ed5b5310b 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -1,9 +1,13 @@
module NotesActions
include RendersNotes
+ include Gitlab::Utils::StrongMemoize
extend ActiveSupport::Concern
included do
+ before_action :set_polling_interval_header, only: [:index]
+ before_action :require_noteable!, only: [:index, :create]
before_action :authorize_admin_note!, only: [:update, :destroy]
+ before_action :note_project, only: [:create]
end
def index
@@ -11,27 +15,33 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at }
- @notes = notes_finder.execute.inc_relations_for_view
- @notes = prepare_notes_for_rendering(@notes)
+ notes = notes_finder.execute
+ .inc_relations_for_view
- @notes.each do |note|
- next if note.cross_reference_not_visible_for?(current_user)
+ notes = prepare_notes_for_rendering(notes)
+ notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
- notes_json[:notes] << note_json(note)
- end
+ notes_json[:notes] =
+ if use_note_serializer?
+ note_serializer.represent(notes)
+ else
+ notes.map { |note| note_json(note) }
+ end
render json: notes_json
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def create
create_params = note_params.merge(
merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
in_reply_to_discussion_id: params[:in_reply_to_discussion_id]
)
- @note = Notes::CreateService.new(project, current_user, create_params).execute
+
+ @note = Notes::CreateService.new(note_project, current_user, create_params).execute
if @note.is_a?(Note)
- Banzai::NoteRenderer.render([@note], @project, current_user)
+ Notes::RenderService.new(current_user).execute([@note], @project)
end
respond_to do |format|
@@ -39,12 +49,14 @@ module NotesActions
format.html { redirect_back_or_default }
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def update
@note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
if @note.is_a?(Note)
- Banzai::NoteRenderer.render([@note], @project, current_user)
+ Notes::RenderService.new(current_user).execute([@note], @project)
end
respond_to do |format|
@@ -52,6 +64,7 @@ module NotesActions
format.html { redirect_back_or_default }
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def destroy
if note.editable?
@@ -80,22 +93,30 @@ module NotesActions
}
if note.persisted?
- attrs.merge!(
- valid: true,
- id: note.id,
- discussion_id: note.discussion_id(noteable),
- html: note_html(note),
- note: note.note
- )
+ attrs[:valid] = true
- discussion = note.to_discussion(noteable)
- unless discussion.individual_note?
+ if use_note_serializer?
+ attrs.merge!(note_serializer.represent(note))
+ else
attrs.merge!(
- discussion_resolvable: discussion.resolvable?,
-
- diff_discussion_html: diff_discussion_html(discussion),
- discussion_html: discussion_html(discussion)
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
+ html: note_html(note),
+ note: note.note,
+ on_image: note.try(:on_image?)
)
+
+ discussion = note.to_discussion(noteable)
+ unless discussion.individual_note?
+ attrs.merge!(
+ discussion_resolvable: discussion.resolvable?,
+
+ diff_discussion_html: diff_discussion_html(discussion),
+ discussion_html: discussion_html(discussion)
+ )
+
+ attrs[:discussion_line_code] = discussion.line_code if discussion.diff_discussion?
+ end
end
else
attrs.merge!(
@@ -110,7 +131,9 @@ module NotesActions
def diff_discussion_html(discussion)
return unless discussion.diff_discussion?
- if params[:view] == 'parallel'
+ on_image = discussion.on_image?
+
+ if params[:view] == 'parallel' && !on_image
template = "discussions/_parallel_diff_discussion"
locals =
if params[:line_type] == 'old'
@@ -120,7 +143,9 @@ module NotesActions
end
else
template = "discussions/_diff_discussion"
- locals = { discussions: [discussion] }
+ @fresh_discussion = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ locals = { discussions: [discussion], on_image: on_image }
end
render_to_string(
@@ -166,8 +191,16 @@ module NotesActions
)
end
+ def set_polling_interval_header
+ Gitlab::PollingInterval.set_header(response, interval: 6_000)
+ end
+
def noteable
- @noteable ||= notes_finder.target
+ @noteable ||= notes_finder.target || @note&.noteable # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def require_noteable!
+ render_404 unless noteable
end
def last_fetched_at
@@ -177,4 +210,37 @@ module NotesActions
def notes_finder
@notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end
+
+ def note_serializer
+ NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
+ end
+
+ def note_project
+ strong_memoize(:note_project) do
+ return nil unless project
+
+ note_project_id = params[:note_project_id]
+
+ the_project =
+ if note_project_id.present?
+ Project.find(note_project_id)
+ else
+ project
+ end
+
+ return access_denied! unless can?(current_user, :create_note, the_project)
+
+ the_project
+ end
+ end
+
+ def use_note_serializer?
+ return false if params['html']
+
+ if noteable.is_a?(MergeRequest)
+ cookies[:vue_mr_discussions] == 'true'
+ else
+ noteable.discussions_rendered_on_frontend?
+ end
+ end
end
diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb
index 9849aa93fa6..f0a68f23566 100644
--- a/app/controllers/concerns/oauth_applications.rb
+++ b/app/controllers/concerns/oauth_applications.rb
@@ -14,6 +14,6 @@ module OauthApplications
end
def load_scopes
- @scopes = Doorkeeper.configuration.scopes
+ @scopes ||= Doorkeeper.configuration.scopes
end
end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
new file mode 100644
index 00000000000..90bb7a87b45
--- /dev/null
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -0,0 +1,25 @@
+module PreviewMarkdown
+ extend ActiveSupport::Concern
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def preview_markdown
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ markdown_params =
+ case controller_name
+ when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
+ when 'snippets' then { skip_project_check: true }
+ when 'groups' then { group: group }
+ else {}
+ end
+
+ render json: {
+ body: view_context.markdown(result[:text], markdown_params),
+ references: {
+ users: result[:users],
+ commands: view_context.markdown(result[:commands])
+ }
+ }
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+end
diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb
index 54dcd7c61ce..ba7adcfea86 100644
--- a/app/controllers/concerns/renders_blob.rb
+++ b/app/controllers/concerns/renders_blob.rb
@@ -1,7 +1,7 @@
module RendersBlob
extend ActiveSupport::Concern
- def render_blob_json(blob)
+ def blob_json(blob)
viewer =
case params[:viewer]
when 'rich'
@@ -11,13 +11,21 @@ module RendersBlob
else
blob.simple_viewer
end
- return render_404 unless viewer
- render json: {
+ return unless viewer
+
+ {
html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false)
}
end
+ def render_blob_json(blob)
+ json = blob_json(blob)
+ return render_404 unless json
+
+ render json: json
+ end
+
def conditionally_expand_blob(blob)
blob.expand! if params[:expanded] == 'true'
end
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
new file mode 100644
index 00000000000..fb41dc1e8a8
--- /dev/null
+++ b/app/controllers/concerns/renders_commits.rb
@@ -0,0 +1,7 @@
+module RendersCommits
+ def prepare_commits_for_rendering(commits)
+ Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ commits
+ end
+end
diff --git a/app/controllers/concerns/renders_member_access.rb b/app/controllers/concerns/renders_member_access.rb
new file mode 100644
index 00000000000..d640378c24d
--- /dev/null
+++ b/app/controllers/concerns/renders_member_access.rb
@@ -0,0 +1,23 @@
+module RendersMemberAccess
+ def prepare_groups_for_rendering(groups)
+ preload_max_member_access_for_collection(Group, groups)
+
+ groups
+ end
+
+ def prepare_projects_for_rendering(projects)
+ preload_max_member_access_for_collection(Project, projects)
+
+ projects
+ end
+
+ private
+
+ def preload_max_member_access_for_collection(klass, collection)
+ return if !current_user || collection.blank?
+
+ method_name = "max_member_access_for_#{klass.name.underscore}_ids"
+
+ current_user.public_send(method_name, collection.ids) # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index 41c3114ad1e..e7ef297879f 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -1,11 +1,14 @@
module RendersNotes
- def prepare_notes_for_rendering(notes)
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def prepare_notes_for_rendering(notes, noteable = nil)
preload_noteable_for_regular_notes(notes)
preload_max_access_for_authors(notes, @project)
- Banzai::NoteRenderer.render(notes, @project, current_user)
+ preload_first_time_contribution_for_authors(noteable, notes)
+ Notes::RenderService.new(current_user).execute(notes, @project)
notes
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
private
@@ -19,4 +22,10 @@ module RendersNotes
def preload_noteable_for_regular_notes(notes)
ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable)
end
+
+ def preload_first_time_contribution_for_authors(noteable, notes)
+ return unless noteable.is_a?(Issuable) && noteable.first_contribution?
+
+ notes.each {|n| n.specialize_for_first_contribution!(noteable)}
+ end
end
diff --git a/app/controllers/concerns/repository_settings_redirect.rb b/app/controllers/concerns/repository_settings_redirect.rb
index 0854c73a02f..0576f0e6e70 100644
--- a/app/controllers/concerns/repository_settings_redirect.rb
+++ b/app/controllers/concerns/repository_settings_redirect.rb
@@ -2,6 +2,6 @@ module RepositorySettingsRedirect
extend ActiveSupport::Concern
def redirect_to_repository_settings(project)
- redirect_to namespace_project_settings_repository_path(project.namespace, project)
+ redirect_to project_settings_repository_path(project)
end
end
diff --git a/app/controllers/concerns/requires_health_token.rb b/app/controllers/concerns/requires_health_token.rb
deleted file mode 100644
index 34ab1a97649..00000000000
--- a/app/controllers/concerns/requires_health_token.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-module RequiresHealthToken
- extend ActiveSupport::Concern
- included do
- before_action :validate_health_check_access!
- end
-
- private
-
- def validate_health_check_access!
- render_404 unless token_valid?
- end
-
- def token_valid?
- token = params[:token].presence || request.headers['TOKEN']
- token.present? &&
- ActiveSupport::SecurityUtils.variable_size_secure_compare(
- token,
- current_application_settings.health_check_access_token
- )
- end
-
- def render_404
- render file: Rails.root.join('public', '404'), layout: false, status: '404'
- end
-end
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
new file mode 100644
index 00000000000..88d1b34bb06
--- /dev/null
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -0,0 +1,34 @@
+module RequiresWhitelistedMonitoringClient
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :validate_ip_whitelisted_or_valid_token!
+ end
+
+ private
+
+ def validate_ip_whitelisted_or_valid_token!
+ render_404 unless client_ip_whitelisted? || valid_token?
+ end
+
+ def client_ip_whitelisted?
+ ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) }
+ end
+
+ def ip_whitelist
+ @ip_whitelist ||= Settings.monitoring.ip_whitelist.map(&IPAddr.method(:new))
+ end
+
+ def valid_token?
+ token = params[:token].presence || request.headers['TOKEN']
+ token.present? &&
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(
+ token,
+ Gitlab::CurrentSettings.health_check_access_token
+ )
+ end
+
+ def render_404
+ render file: Rails.root.join('public', '404'), layout: false, status: '404'
+ end
+end
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
index 4199da9cdf5..0931bdf4c04 100644
--- a/app/controllers/concerns/routable_actions.rb
+++ b/app/controllers/concerns/routable_actions.rb
@@ -3,16 +3,20 @@ module RoutableActions
def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
-
if routable_authorized?(routable, extra_authorization_proc)
ensure_canonical_path(routable, requested_full_path)
routable
else
- route_not_found
+ handle_not_found_or_authorized(routable)
nil
end
end
+ # This is overridden in gitlab-ee.
+ def handle_not_found_or_authorized(_routable)
+ route_not_found
+ end
+
def routable_authorized?(routable, extra_authorization_proc)
action = :"read_#{routable.class.to_s.underscore}"
return false unless can?(current_user, action, routable)
@@ -32,6 +36,7 @@ module RoutableActions
if canonical_path.casecmp(requested_full_path) != 0
flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path."
end
+
redirect_to build_canonical_path(routable)
end
end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index be2e6c7f193..c1acb50b76c 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -32,6 +32,7 @@ module ServiceParams
:issues_events,
:issues_url,
:jira_issue_transition_id,
+ :manual_configuration,
:merge_requests_events,
:mock_service_url,
:namespace,
@@ -66,7 +67,7 @@ module ServiceParams
FILTER_BLANK_PARAMS = [:password].freeze
def service_params
- dynamic_params = @service.event_channel_names + @service.event_names
+ dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables
service_params = params.permit(:id, service: ALLOWED_PARAMS_CE + dynamic_params)
if service_params[:service].is_a?(Hash)
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index ffea712a833..9095cc7f783 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -4,6 +4,7 @@ module SnippetsActions
def edit
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def raw
disposition = params[:inline] == 'false' ? 'attachment' : 'inline'
@@ -14,6 +15,7 @@ module SnippetsActions
filename: @snippet.sanitized_file_name
)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
private
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index b68d76aeff0..922aa58a00f 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -2,6 +2,7 @@ module SpammableActions
extend ActiveSupport::Concern
include Recaptcha::Verify
+ include Gitlab::Utils::StrongMemoize
included do
before_action :authorize_submit_spammable!, only: :mark_as_spam
@@ -9,23 +10,23 @@ module SpammableActions
def mark_as_spam
if SpamService.new(spammable).mark_as_spam!
- redirect_to spammable, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully."
+ redirect_to spammable_path, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully."
else
- redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.'
+ redirect_to spammable_path, alert: 'Error with Akismet. Please check the logs for more info.'
end
end
private
def ensure_spam_config_loaded!
- return @spam_config_loaded if defined?(@spam_config_loaded)
-
- @spam_config_loaded = Gitlab::Recaptcha.load_configurations!
+ strong_memoize(:spam_config_loaded) do
+ Gitlab::Recaptcha.load_configurations!
+ end
end
- def recaptcha_check_with_fallback(&fallback)
- if spammable.valid?
- redirect_to spammable
+ def recaptcha_check_with_fallback(should_redirect = true, &fallback)
+ if should_redirect && spammable.valid?
+ redirect_to spammable_path
elsif render_recaptcha?
ensure_spam_config_loaded!
@@ -33,7 +34,18 @@ module SpammableActions
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
end
- render :verify
+ respond_to do |format|
+ format.html do
+ render :verify
+ end
+
+ format.json do
+ locals = { spammable: spammable, script: false, has_submit: false }
+ recaptcha_html = render_to_string(partial: 'shared/recaptcha_form', formats: :html, locals: locals)
+
+ render json: { recaptcha_html: recaptcha_html }
+ end
+ end
else
yield
end
@@ -56,6 +68,10 @@ module SpammableActions
raise NotImplementedError, "#{self.class} does not implement #{__method__}"
end
+ def spammable_path
+ raise NotImplementedError, "#{self.class} does not implement #{__method__}"
+ end
+
def authorize_submit_spammable!
access_denied! unless current_user.admin?
end
diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb
index 92cb534343e..776583579e8 100644
--- a/app/controllers/concerns/toggle_subscription_action.rb
+++ b/app/controllers/concerns/toggle_subscription_action.rb
@@ -12,7 +12,7 @@ module ToggleSubscriptionAction
private
def subscribable_project
- @project || raise(NotImplementedError)
+ @project ||= raise(NotImplementedError)
end
def subscribable_resource
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index dec2e27335a..3dbfabcae8a 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -1,4 +1,8 @@
module UploadsActions
+ include Gitlab::Utils::StrongMemoize
+
+ UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo).freeze
+
def create
link_to_file = UploadService.new(model, params[:file], uploader_class).execute
@@ -15,13 +19,74 @@ module UploadsActions
end
end
+ # This should either
+ # - send the file directly
+ # - or redirect to its URL
+ #
def show
- return render_404 unless uploader.exists?
+ return render_404 unless uploader&.exists?
+
+ if uploader.file_storage?
+ disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+ expires_in 0.seconds, must_revalidate: true, private: true
+
+ send_file uploader.file.path, disposition: disposition
+ else
+ redirect_to uploader.url
+ end
+ end
+
+ private
- disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+ def uploader_class
+ raise NotImplementedError
+ end
+
+ def upload_mount
+ mounted_as = params[:mounted_as]
+ mounted_as if UPLOAD_MOUNTS.include?(mounted_as)
+ end
- expires_in 0.seconds, must_revalidate: true, private: true
+ def uploader_mounted?
+ upload_model_class < CarrierWave::Mount::Extension && !upload_mount.nil?
+ end
+
+ def uploader
+ strong_memoize(:uploader) do
+ if uploader_mounted?
+ model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend
+ else
+ build_uploader_from_upload || build_uploader_from_params
+ end
+ end
+ end
+
+ def build_uploader_from_upload
+ return nil unless params[:secret] && params[:filename]
+
+ upload_path = uploader_class.upload_path(params[:secret], params[:filename])
+ upload = Upload.find_by(uploader: uploader_class.to_s, path: upload_path)
+ upload&.build_uploader
+ end
+
+ def build_uploader_from_params
+ uploader = uploader_class.new(model, secret: params[:secret])
+
+ return nil unless uploader.model_valid?
+
+ uploader.retrieve_from_store!(params[:filename])
+ uploader
+ end
+
+ def image_or_video?
+ uploader && uploader.exists? && uploader.image_or_video?
+ end
+
+ def find_model
+ nil
+ end
- send_file uploader.file.path, disposition: disposition
+ def model
+ strong_memoize(:model) { find_model }
end
end
diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb
new file mode 100644
index 00000000000..6a8b1a4de7b
--- /dev/null
+++ b/app/controllers/concerns/with_performance_bar.rb
@@ -0,0 +1,27 @@
+module WithPerformanceBar
+ extend ActiveSupport::Concern
+
+ included do
+ include Peek::Rblineprof::CustomControllerHelpers
+ end
+
+ def peek_enabled?
+ return false unless Gitlab::PerformanceBar.enabled?(current_user)
+
+ if RequestStore.active?
+ RequestStore.fetch(:peek_enabled) { cookie_or_default_value }
+ else
+ cookie_or_default_value
+ end
+ end
+
+ private
+
+ def cookie_or_default_value
+ if cookies[:perf_bar_enabled].present?
+ cookies[:perf_bar_enabled] == 'true'
+ else
+ cookies[:perf_bar_enabled] = 'true' if Rails.env.development?
+ end
+ end
+end
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 306afb65f10..6d9c38d9581 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -11,11 +11,17 @@ class ConfirmationsController < Devise::ConfirmationsController
end
def after_confirmation_path_for(resource_name, resource)
- if signed_in?(resource_name)
- after_sign_in_path_for(resource)
+ # incoming resource can either be a :user or an :email
+ if signed_in?(:user)
+ after_sign_in(resource)
else
+ Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}")
flash[:notice] += " Please sign in."
- new_session_path(resource_name)
+ new_session_path(:user, anchor: 'login-pane')
end
end
+
+ def after_sign_in(resource)
+ after_sign_in_path_for(resource)
+ end
end
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index 9d3d1c23c28..9fb5c525425 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -1,6 +1,10 @@
class Dashboard::ApplicationController < ApplicationController
+ include ControllerWithCrossProjectAccessCheck
+
layout 'dashboard'
+ requires_cross_project_access
+
private
def projects
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index 742157d113d..79f563bef86 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,31 +1,10 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
- def index
- @groups =
- if params[:parent_id] && Group.supports_nested_groups?
- parent = Group.find_by(id: params[:parent_id])
-
- if can?(current_user, :read_group, parent)
- GroupsFinder.new(current_user, parent: parent).execute
- else
- Group.none
- end
- else
- current_user.groups
- end
+ include GroupTree
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.includes(:route)
- @groups = @groups.sort(@sort = params[:sort])
- @groups = @groups.page(params[:page])
+ skip_cross_project_access_check :index
- respond_to do |format|
- format.html
- format.json do
- render json: GroupSerializer
- .new(current_user: @current_user)
- .with_pagination(request, response)
- .represent(@groups)
- end
- end
+ def index
+ groups = GroupsFinder.new(current_user, all_available: false).execute
+ render_group_tree(groups)
end
end
diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb
index dd1d46a68c7..9dcb3a0eb6d 100644
--- a/app/controllers/dashboard/labels_controller.rb
+++ b/app/controllers/dashboard/labels_controller.rb
@@ -1,9 +1,14 @@
class Dashboard::LabelsController < Dashboard::ApplicationController
def index
- labels = LabelsFinder.new(current_user).execute
-
respond_to do |format|
format.json { render json: LabelSerializer.new.represent_appearance(labels) }
end
end
+
+ def labels
+ finder_params = { project_ids: projects.select(:id) }
+ labels = LabelsFinder.new(current_user, finder_params).execute
+
+ GlobalLabel.build_collection(labels)
+ end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 91c1e4dff79..4d4ac025f8c 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -1,8 +1,10 @@
class Dashboard::ProjectsController < Dashboard::ApplicationController
include ParamsBackwardCompatibility
+ include RendersMemberAccess
before_action :set_non_archived_param
before_action :default_sorting
+ skip_cross_project_access_check :index, :starred
def index
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
@@ -45,13 +47,21 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def load_projects(finder_params)
- ProjectsFinder.new(params: finder_params, current_user: current_user)
- .execute.includes(:route, namespace: :route)
+ projects = ProjectsFinder
+ .new(params: finder_params, current_user: current_user)
+ .execute
+ .includes(:route, :creator, namespace: [:route, :owner])
+
+ prepare_projects_for_rendering(projects)
end
def load_events
- @events = Event.in_projects(load_projects(params.merge(non_public: true)))
- @events = event_filter.apply_filter(@events).with_associations
- @events = @events.limit(20).offset(params[:offset] || 0)
+ projects = load_projects(params.merge(non_public: true))
+
+ @events = EventCollection
+ .new(projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
+
+ Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
end
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index 8dd91264451..0ba97e4fd59 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -1,4 +1,6 @@
class Dashboard::SnippetsController < Dashboard::ApplicationController
+ skip_cross_project_access_check :index
+
def index
@snippets = SnippetsFinder.new(
current_user,
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 28c90548cc1..e89eaf7edda 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -1,18 +1,18 @@
class Dashboard::TodosController < Dashboard::ApplicationController
include ActionView::Helpers::NumberHelper
+ before_action :authorize_read_project!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
def index
@sort = params[:sort]
@todos = @todos.page(params[:page])
- if @todos.out_of_range? && @todos.total_pages != 0
- redirect_to url_for(params.merge(page: @todos.total_pages, only_path: true))
- end
+
+ return if redirect_out_of_range(@todos)
end
def destroy
- TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
+ TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
respond_to do |format|
format.html do
@@ -36,7 +36,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def restore
- TodoService.new.mark_todos_as_pending_by_ids([params[:id]], current_user)
+ TodoService.new.mark_todos_as_pending_by_ids(params[:id], current_user)
render json: todos_counts
end
@@ -49,8 +49,17 @@ class Dashboard::TodosController < Dashboard::ApplicationController
private
+ def authorize_read_project!
+ project_id = params[:project_id]
+
+ if project_id.present?
+ project = Project.find(project_id)
+ render_404 unless can?(current_user, :read_project, project)
+ end
+ end
+
def find_todos
- @todos ||= TodosFinder.new(current_user, params).execute
+ @todos ||= TodosFinder.new(current_user, todo_params).execute
end
def todos_counts
@@ -59,4 +68,27 @@ class Dashboard::TodosController < Dashboard::ApplicationController
done_count: number_with_delimiter(current_user.todos_done_count)
}
end
+
+ def todo_params
+ params.permit(:action_id, :author_id, :project_id, :type, :sort, :state)
+ end
+
+ def redirect_out_of_range(todos)
+ total_pages =
+ if todo_params.except(:sort, :page).empty?
+ (current_user.todos_pending_count.to_f / todos.limit_value).ceil
+ else
+ todos.total_pages
+ end
+
+ return false if total_pages.zero?
+
+ out_of_range = todos.current_page > total_pages
+
+ if out_of_range
+ redirect_to url_for(params.merge(page: total_pages, only_path: true))
+ end
+
+ out_of_range
+ end
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index f9c31920302..280ed93faf8 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -29,9 +29,11 @@ class DashboardController < Dashboard::ApplicationController
current_user.authorized_projects
end
- @events = Event.in_projects(projects)
- @events = @event_filter.apply_filter(@events).with_associations
- @events = @events.limit(20).offset(params[:offset] || 0)
+ @events = EventCollection
+ .new(projects, offset: params[:offset].to_i, filter: @event_filter)
+ .to_a
+
+ Events::RenderService.new(current_user).execute(@events)
end
def set_show_full_reference
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 81883c543ba..fa0a0f68fbc 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -1,17 +1,7 @@
class Explore::GroupsController < Explore::ApplicationController
- def index
- @groups = GroupsFinder.new(current_user).execute
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.sort(@sort = params[:sort])
- @groups = @groups.page(params[:page])
+ include GroupTree
- respond_to do |format|
- format.html
- format.json do
- render json: {
- html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
- }
- end
- end
+ def index
+ render_group_tree GroupsFinder.new(current_user).execute
end
end
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 741879dee35..c7273606a85 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -1,12 +1,13 @@
class Explore::ProjectsController < Explore::ApplicationController
include ParamsBackwardCompatibility
+ include RendersMemberAccess
before_action :set_non_archived_param
def index
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
- @projects = load_projects.page(params[:page])
+ @projects = load_projects
respond_to do |format|
format.html
@@ -21,7 +22,7 @@ class Explore::ProjectsController < Explore::ApplicationController
def trending
params[:trending] = true
@sort = params[:sort]
- @projects = load_projects.page(params[:page])
+ @projects = load_projects
respond_to do |format|
format.html
@@ -34,7 +35,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def starred
- @projects = load_projects.reorder('star_count DESC').page(params[:page])
+ @projects = load_projects.reorder('star_count DESC')
respond_to do |format|
format.html
@@ -49,7 +50,12 @@ class Explore::ProjectsController < Explore::ApplicationController
private
def load_projects
- ProjectsFinder.new(current_user: current_user, params: params)
- .execute.includes(:route, namespace: :route)
+ projects = ProjectsFinder.new(current_user: current_user, params: params)
+ .execute
+ .includes(:route, namespace: :route)
+ .page(params[:page])
+ .without_count
+
+ prepare_projects_for_rendering(projects)
end
end
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
new file mode 100644
index 00000000000..5551057ff55
--- /dev/null
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -0,0 +1,29 @@
+module GoogleApi
+ class AuthorizationsController < ApplicationController
+ def callback
+ token, expires_at = GoogleApi::CloudPlatform::Client
+ .new(nil, callback_google_api_auth_url)
+ .get_token(params[:code])
+
+ session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
+ session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
+ expires_at.to_s
+
+ state_redirect_uri = redirect_uri_from_session_key(params[:state])
+
+ if state_redirect_uri
+ redirect_to state_redirect_uri
+ else
+ redirect_to root_path
+ end
+ end
+
+ private
+
+ def redirect_uri_from_session_key(state)
+ key = GoogleApi::CloudPlatform::Client
+ .session_key_for_redirect_uri(params[:state])
+ session[key] if key
+ end
+ end
+end
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index c0ac47e363d..9f3bb60b4cc 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -1,10 +1,12 @@
class Groups::ApplicationController < ApplicationController
include RoutableActions
+ include ControllerWithCrossProjectAccessCheck
layout 'group'
skip_before_action :authenticate_user!
before_action :group
+ requires_cross_project_access
private
@@ -16,10 +18,6 @@ class Groups::ApplicationController < ApplicationController
@projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end
- def group_merge_requests
- @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute
- end
-
def authorize_admin_group!
unless can?(current_user, :admin_group, group)
return render_404
@@ -34,7 +32,7 @@ class Groups::ApplicationController < ApplicationController
def build_canonical_path(group)
params[:group_id] = group.to_param
-
+
url_for(params)
end
end
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index 735915abdaa..cc5ba5878f8 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -1,6 +1,8 @@
class Groups::AvatarsController < Groups::ApplicationController
before_action :authorize_admin_group!
+ skip_cross_project_access_check :destroy
+
def destroy
@group.remove_avatar!
@group.save
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
new file mode 100644
index 00000000000..7c2016f0326
--- /dev/null
+++ b/app/controllers/groups/boards_controller.rb
@@ -0,0 +1,27 @@
+class Groups::BoardsController < Groups::ApplicationController
+ include BoardsResponses
+
+ before_action :assign_endpoint_vars
+
+ def index
+ @boards = Boards::ListService.new(group, current_user).execute
+
+ respond_with_boards
+ end
+
+ def show
+ @board = group.boards.find(params[:id])
+
+ respond_with_board
+ end
+
+ def assign_endpoint_vars
+ @boards_endpoint = group_boards_url(group)
+ @namespace_path = group.to_param
+ @labels_endpoint = group_labels_url(group)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(only: [:id])
+ end
+end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
new file mode 100644
index 00000000000..0e8125d6113
--- /dev/null
+++ b/app/controllers/groups/children_controller.rb
@@ -0,0 +1,40 @@
+module Groups
+ class ChildrenController < Groups::ApplicationController
+ before_action :group
+ skip_cross_project_access_check :index
+
+ def index
+ parent = if params[:parent_id].present?
+ GroupFinder.new(current_user).execute(id: params[:parent_id])
+ else
+ @group
+ end
+
+ if parent.nil?
+ render_404
+ return
+ end
+
+ setup_children(parent)
+
+ respond_to do |format|
+ format.json do
+ serializer = GroupChildSerializer
+ .new(current_user: current_user)
+ .with_pagination(request, response)
+ serializer.expand_hierarchy(parent) if params[:filter].present?
+ render json: serializer.represent(@children)
+ end
+ end
+ end
+
+ protected
+
+ def setup_children(parent)
+ @children = GroupDescendantsFinder.new(current_user: current_user,
+ parent_group: parent,
+ params: params).execute
+ @children = @children.page(params[:page])
+ end
+ end
+end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 8fc234a62b1..f210434b2d7 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -1,10 +1,15 @@
class Groups::GroupMembersController < Groups::ApplicationController
include MembershipActions
+ include MembersPresentation
include SortingHelper
# Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access]
+ skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
+ :approve_access_request, :leave, :resend_invite,
+ :override
+
def index
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id]
@@ -14,41 +19,14 @@ class Groups::GroupMembersController < Groups::ApplicationController
@members = @members.search(params[:search]) if params[:search].present?
@members = @members.sort(@sort)
@members = @members.page(params[:page]).per(50)
- @members.includes(:user)
+ @members = present_members(@members.includes(:user))
- @requesters = AccessRequestsFinder.new(@group).execute(current_user)
+ @requesters = present_members(
+ AccessRequestsFinder.new(@group).execute(current_user))
@group_member = @group.group_members.new
end
- def update
- @group_member = @group.group_members.find(params[:id])
-
- return render_403 unless can?(current_user, :update_group_member, @group_member)
-
- @group_member.update_attributes(member_params)
- end
-
- def resend_invite
- redirect_path = group_group_members_path(@group)
-
- @group_member = @group.group_members.find(params[:id])
-
- if @group_member.invite?
- @group_member.resend_invite
-
- redirect_to redirect_path, notice: 'The invitation was successfully resent.'
- else
- redirect_to redirect_path, alert: 'The invitation has already been accepted.'
- end
- end
-
- protected
-
- def member_params
- params.require(:group_member).permit(:access_level, :user_id, :expires_at)
- end
-
# MembershipActions concern
alias_method :membershipable, :group
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index dda59262483..58be330f466 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -14,7 +14,14 @@ class Groups::LabelsController < Groups::ApplicationController
end
format.json do
- available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
+ available_labels = LabelsFinder.new(
+ current_user,
+ group_id: @group.id,
+ only_group_labels: params[:only_group_labels],
+ include_ancestor_groups: params[:include_ancestor_groups],
+ include_descendant_groups: params[:include_descendant_groups]
+ ).execute
+
render json: LabelSerializer.new.represent_appearance(available_labels)
end
end
@@ -28,10 +35,18 @@ class Groups::LabelsController < Groups::ApplicationController
def create
@label = Labels::CreateService.new(label_params).execute(group: group)
- if @label.valid?
- redirect_to group_labels_path(@group)
- else
- render :new
+ respond_to do |format|
+ format.html do
+ if @label.valid?
+ redirect_to group_labels_path(@group)
+ else
+ render :new
+ end
+ end
+
+ format.json do
+ render json: LabelSerializer.new.represent_appearance(@label)
+ end
end
end
@@ -54,7 +69,7 @@ class Groups::LabelsController < Groups::ApplicationController
respond_to do |format|
format.html do
- redirect_to group_labels_path(@group), status: 302, notice: 'Label was removed'
+ redirect_to group_labels_path(@group), status: 302, notice: "#{@label.name} deleted permanently"
end
format.js
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index e52fa766044..acf6aaf57f4 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -2,15 +2,18 @@ class Groups::MilestonesController < Groups::ApplicationController
include MilestoneActions
before_action :group_projects
- before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels]
- before_action :authorize_admin_milestones!, only: [:new, :create, :update]
+ before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels]
+ before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update]
def index
respond_to do |format|
format.html do
- @milestone_states = GlobalMilestone.states_count(@projects)
+ @milestone_states = GlobalMilestone.states_count(group_projects, group)
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
+ format.json do
+ render json: milestones.map { |m| m.for_display.slice(:title, :name) }
+ end
end
end
@@ -19,49 +22,41 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def create
- project_ids = params[:milestone][:project_ids].reject(&:blank?)
- title = milestone_params[:title]
+ @milestone = Milestones::CreateService.new(group, current_user, milestone_params).execute
- if create_milestones(project_ids)
- redirect_to milestone_path(title)
+ if @milestone.persisted?
+ redirect_to milestone_path
else
- render_new_with_error(project_ids.empty?)
+ render "new"
end
end
def show
end
- def update
- @milestone.milestones.each do |milestone|
- Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
- end
-
- redirect_back_or_default(default: milestone_path(@milestone.title))
+ def edit
+ render_404 if @milestone.legacy_group_milestone?
end
- private
-
- def create_milestones(project_ids)
- return false unless project_ids.present?
+ def update
+ # Keep this compatible with legacy group milestones where we have to update
+ # all projects milestones states at once.
+ if @milestone.legacy_group_milestone?
+ update_params = milestone_params.select { |key| key == "state_event" }
+ milestones = @milestone.milestones
+ else
+ update_params = milestone_params
+ milestones = [@milestone]
+ end
- ActiveRecord::Base.transaction do
- @projects.where(id: project_ids).each do |project|
- Milestones::CreateService.new(project, current_user, milestone_params).execute
- end
+ milestones.each do |milestone|
+ Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone)
end
- true
- rescue ActiveRecord::ActiveRecordError => e
- flash.now[:alert] = "An error occurred while creating the milestone: #{e.message}"
- false
+ redirect_to milestone_path
end
- def render_new_with_error(empty_project_ids)
- @milestone = Milestone.new(milestone_params)
- @milestone.errors.add(:base, "Please select at least one project.") if empty_project_ids
- render :new
- end
+ private
def authorize_admin_milestones!
return render_404 unless can?(current_user, :admin_milestones, group)
@@ -71,16 +66,34 @@ class Groups::MilestonesController < Groups::ApplicationController
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
- def milestone_path(title)
- group_milestone_path(@group, title.to_slug.to_s, title: title)
+ def milestone_path
+ if @milestone.legacy_group_milestone?
+ group_milestone_path(group, @milestone.safe_title, title: @milestone.title)
+ else
+ group_milestone_path(group, @milestone.iid)
+ end
end
def milestones
- @milestones = GroupMilestone.build_collection(@group, @projects, params)
+ milestones = MilestonesFinder.new(search_params).execute
+ legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
+
+ @sort = params[:sort] || 'due_date_asc'
+ MilestoneArray.sort(milestones + legacy_milestones, @sort)
end
def milestone
- @milestone = GroupMilestone.build(@group, @projects, params[:title])
+ @milestone =
+ if params[:title]
+ GroupMilestone.build(group, group_projects, params[:title])
+ else
+ group.milestones.find_by_iid(params[:id])
+ end
+
render_404 unless @milestone
end
+
+ def search_params
+ params.permit(:state).merge(group_ids: group.id)
+ end
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
new file mode 100644
index 00000000000..4bf6a2a3ad1
--- /dev/null
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -0,0 +1,25 @@
+module Groups
+ module Settings
+ class CiCdController < Groups::ApplicationController
+ skip_cross_project_access_check :show
+ before_action :authorize_admin_pipeline!
+
+ def show
+ define_secret_variables
+ end
+
+ private
+
+ def define_secret_variables
+ @variable = Ci::GroupVariable.new(group: group)
+ .present(current_user: current_user)
+ @variables = group.variables.order_key_asc
+ .map { |variable| variable.present(current_user: current_user) }
+ end
+
+ def authorize_admin_pipeline!
+ return render_404 unless can?(current_user, :admin_pipeline, group)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
new file mode 100644
index 00000000000..f1578f75e88
--- /dev/null
+++ b/app/controllers/groups/uploads_controller.rb
@@ -0,0 +1,29 @@
+class Groups::UploadsController < Groups::ApplicationController
+ include UploadsActions
+
+ skip_before_action :group, if: -> { action_name == 'show' && image_or_video? }
+
+ before_action :authorize_upload_file!, only: [:create]
+
+ private
+
+ def upload_model_class
+ Group
+ end
+
+ def uploader_class
+ NamespaceFileUploader
+ end
+
+ def find_model
+ return @group if @group
+
+ group_id = params[:group_id]
+
+ Group.find_by_full_path(group_id)
+ end
+
+ def authorize_upload_file!
+ render_404 unless can?(current_user, :upload_file, group)
+ end
+end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
new file mode 100644
index 00000000000..6142e75b4c1
--- /dev/null
+++ b/app/controllers/groups/variables_controller.rb
@@ -0,0 +1,49 @@
+module Groups
+ class VariablesController < Groups::ApplicationController
+ before_action :authorize_admin_build!
+
+ skip_cross_project_access_check :show, :update
+
+ def show
+ respond_to do |format|
+ format.json do
+ render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
+ end
+ end
+ end
+
+ def update
+ if @group.update(group_variables_params)
+ respond_to do |format|
+ format.json { return render_group_variables }
+ end
+ else
+ respond_to do |format|
+ format.json { render_error }
+ end
+ end
+ end
+
+ private
+
+ def render_group_variables
+ render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
+ end
+
+ def render_error
+ render status: :bad_request, json: @group.errors.full_messages
+ end
+
+ def group_variables_params
+ params.permit(variables_attributes: [*variable_params_attributes])
+ end
+
+ def variable_params_attributes
+ %i[id key secret_value protected _destroy]
+ end
+
+ def authorize_admin_build!
+ return render_404 unless can?(current_user, :admin_build, group)
+ end
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 27137ffde54..283c3e5f1e0 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -2,6 +2,7 @@ class GroupsController < Groups::ApplicationController
include IssuesAction
include MergeRequestsAction
include ParamsBackwardCompatibility
+ include PreviewMarkdown
respond_to :html
@@ -9,15 +10,20 @@ class GroupsController < Groups::ApplicationController
before_action :group, except: [:index, :new, :create]
# Authorize
- before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects]
- before_action :authorize_create_group!, only: [:new, :create]
+ before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects, :transfer]
+ before_action :authorize_create_group!, only: [:new]
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
- before_action :group_merge_requests, only: [:merge_requests]
before_action :event_filter, only: [:activity]
before_action :user_actions, only: [:show, :subgroups]
+ skip_cross_project_access_check :index, :new, :create, :edit, :update,
+ :destroy, :projects
+ # When loading show as an atom feed, we render events that could leak cross
+ # project information
+ skip_cross_project_access_check :show, if: -> { request.format.html? }
+
layout :determine_layout
def index
@@ -25,7 +31,7 @@ class GroupsController < Groups::ApplicationController
end
def new
- @group = Group.new
+ @group = Group.new(params.permit(:parent_id))
end
def create
@@ -45,15 +51,11 @@ class GroupsController < Groups::ApplicationController
end
def show
- setup_projects
-
respond_to do |format|
- format.html
-
- format.json do
- render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
- }
+ format.html do
+ @has_children = GroupDescendantsFinder.new(current_user: current_user,
+ parent_group: @group,
+ params: params).has_children?
end
format.atom do
@@ -63,13 +65,6 @@ class GroupsController < Groups::ApplicationController
end
end
- def subgroups
- return not_found unless Group.supports_nested_groups?
-
- @nested_groups = GroupsFinder.new(current_user, parent: group).execute
- @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
- end
-
def activity
respond_to do |format|
format.html
@@ -104,26 +99,30 @@ class GroupsController < Groups::ApplicationController
redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion."
end
- protected
-
- def setup_projects
- set_non_archived_param
- params[:sort] ||= 'latest_activity_desc'
- @sort = params[:sort]
-
- options = {}
- options[:only_owned] = true if params[:shared] == '0'
- options[:only_shared] = true if params[:shared] == '1'
+ def transfer
+ parent_group = Group.find_by(id: params[:new_parent_group_id])
+ service = ::Groups::TransferService.new(@group, current_user)
- @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
- @projects = @projects.includes(:namespace)
- @projects = @projects.page(params[:page]) if params[:name].blank?
+ if service.execute(parent_group)
+ flash[:notice] = "Group '#{@group.name}' was successfully transferred."
+ redirect_to group_path(@group)
+ else
+ flash.now[:alert] = service.error
+ render :edit
+ end
end
+ protected
+
def authorize_create_group!
- unless can?(current_user, :create_group)
- return render_404
- end
+ allowed = if params[:parent_id].present?
+ parent = Group.find_by(id: params[:parent_id])
+ can?(current_user, :create_subgroup, parent)
+ else
+ can?(current_user, :create_group)
+ end
+
+ render_404 unless allowed
end
def determine_layout
@@ -137,10 +136,10 @@ class GroupsController < Groups::ApplicationController
end
def group_params
- params.require(:group).permit(group_params_ce)
+ params.require(:group).permit(group_params_attributes)
end
- def group_params_ce
+ def group_params_attributes
[
:avatar,
:description,
@@ -160,9 +159,21 @@ class GroupsController < Groups::ApplicationController
end
def load_events
- @events = Event.in_projects(@projects)
- @events = event_filter.apply_filter(@events).with_associations
- @events = @events.limit(20).offset(params[:offset] || 0)
+ params[:sort] ||= 'latest_activity_desc'
+
+ options = {}
+ options[:only_owned] = true if params[:shared] == '0'
+ options[:only_shared] = true if params[:shared] == '1'
+
+ @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user)
+ .execute
+ .includes(:namespace)
+
+ @events = EventCollection
+ .new(@projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
+
+ Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
def user_actions
diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb
index 5d3109b7187..c3d18991fd4 100644
--- a/app/controllers/health_check_controller.rb
+++ b/app/controllers/health_check_controller.rb
@@ -1,3 +1,3 @@
class HealthCheckController < HealthCheck::HealthCheckController
- include RequiresHealthToken
+ include RequiresWhitelistedMonitoringClient
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index abc832e6ddc..16abf7bab7e 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -1,11 +1,15 @@
class HealthController < ActionController::Base
- protect_from_forgery with: :exception
- include RequiresHealthToken
+ protect_from_forgery with: :exception, except: :storage_check
+ include RequiresWhitelistedMonitoringClient
CHECKS = [
Gitlab::HealthChecks::DbCheck,
- Gitlab::HealthChecks::RedisCheck,
- Gitlab::HealthChecks::FsShardsCheck
+ Gitlab::HealthChecks::Redis::RedisCheck,
+ Gitlab::HealthChecks::Redis::CacheCheck,
+ Gitlab::HealthChecks::Redis::QueuesCheck,
+ Gitlab::HealthChecks::Redis::SharedStateCheck,
+ Gitlab::HealthChecks::FsShardsCheck,
+ Gitlab::HealthChecks::GitalyCheck
].freeze
def readiness
@@ -20,6 +24,15 @@ class HealthController < ActionController::Base
render_check_results(results)
end
+ def storage_check
+ results = Gitlab::Git::Storage::Checker.check_all
+
+ render json: {
+ check_interval: Gitlab::CurrentSettings.current_application_settings.circuitbreaker_check_interval,
+ results: results
+ }
+ end
+
private
def render_check_results(results)
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index 87c0f8905ff..a394521698c 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -3,8 +3,13 @@ class HelpController < ApplicationController
layout 'help'
+ # Taken from Jekyll
+ # https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13
+ YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
+
def index
- @help_index = File.read(Rails.root.join('doc', 'README.md'))
+ # Remove YAML frontmatter so that it doesn't look weird
+ @help_index = File.read(Rails.root.join('doc', 'README.md')).sub(YAML_FRONT_MATTER_REGEXP, '')
# Prefix Markdown links with `help/` unless they are external links
# See http://rubular.com/r/X3baHTbPO2
@@ -22,7 +27,8 @@ class HelpController < ApplicationController
path = File.join(Rails.root, 'doc', "#{@path}.md")
if File.exist?(path)
- @markdown = File.read(path)
+ # Remove YAML frontmatter so that it doesn't look weird
+ @markdown = File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '')
render 'show.html.haml'
else
@@ -51,6 +57,10 @@ class HelpController < ApplicationController
def shortcuts
end
+ def instance_configuration
+ @instance_configuration = InstanceConfiguration.new
+ end
+
def ui
@user = User.new(id: 0, name: 'John Doe', username: '@johndoe')
end
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
new file mode 100644
index 00000000000..1ff25a45398
--- /dev/null
+++ b/app/controllers/ide_controller.rb
@@ -0,0 +1,6 @@
+class IdeController < ApplicationController
+ layout 'nav_only'
+
+ def index
+ end
+end
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 9de0297ecfd..c84fc2d305d 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -2,26 +2,16 @@ class Import::BaseController < ApplicationController
private
def find_or_create_namespace(names, owner)
- return current_user.namespace if names == owner
- return current_user.namespace unless current_user.can_create_group?
-
names = params[:target_namespace].presence || names
- full_path_namespace = Namespace.find_by_full_path(names)
- return full_path_namespace if full_path_namespace
+ return current_user.namespace if names == owner
+
+ group = Groups::NestedCreateService.new(current_user, group_path: names).execute
- names.split('/').inject(nil) do |parent, name|
- begin
- namespace = Group.create!(name: name,
- path: name,
- owner: current_user,
- parent: parent)
- namespace.add_owner(current_user)
+ group.errors.any? ? current_user.namespace : group
+ rescue => e
+ Gitlab::AppLogger.error(e)
- namespace
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
- Namespace.where(parent: parent).find_by_path_or_name(name)
- end
- end
+ current_user.namespace
end
end
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 5ad1e116e4e..61d81ad8a71 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -37,24 +37,30 @@ class Import::BitbucketController < Import::BaseController
def create
bitbucket_client = Bitbucket::Client.new(credentials)
- @repo_id = params[:repo_id].to_s
- name = @repo_id.gsub('___', '/')
+ repo_id = params[:repo_id].to_s
+ name = repo_id.gsub('___', '/')
repo = bitbucket_client.repo(name)
- @project_name = params[:new_name].presence || repo.name
+ project_name = params[:new_name].presence || repo.name
repo_owner = repo.owner
repo_owner = current_user.username if repo_owner == bitbucket_client.user.username
namespace_path = params[:new_namespace].presence || repo_owner
+ target_namespace = find_or_create_namespace(namespace_path, current_user)
- @target_namespace = find_or_create_namespace(namespace_path, current_user)
-
- if current_user.can?(:create_projects, @target_namespace)
+ if current_user.can?(:create_projects, target_namespace)
# The token in a session can be expired, we need to get most recent one because
# Bitbucket::Connection class refreshes it.
session[:bitbucket_token] = bitbucket_client.connection.token
- @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, credentials).execute
+
+ project = Gitlab::BitbucketImport::ProjectCreator.new(repo, project_name, target_namespace, current_user, credentials).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
else
- render 'unauthorized'
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
end
end
@@ -65,7 +71,7 @@ class Import::BitbucketController < Import::BaseController
end
def provider
- Gitlab::OAuth::Provider.config_for('bitbucket')
+ Gitlab::Auth::OAuth::Provider.config_for('bitbucket')
end
def options
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 5df6bd34185..669eb31a995 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -58,17 +58,17 @@ class Import::FogbugzController < Import::BaseController
end
def create
- @repo_id = params[:repo_id]
- repo = client.repo(@repo_id)
+ repo = client.repo(params[:repo_id])
fb_session = { uri: session[:fogbugz_uri], token: session[:fogbugz_token] }
- @target_namespace = current_user.namespace
- @project_name = repo.name
-
- namespace = @target_namespace
-
umap = session[:fogbugz_user_map] || client.user_map
- @project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, namespace, current_user, umap).execute
+ project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, current_user.namespace, current_user, umap).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
end
private
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 53a5981e564..eb7d5fca367 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -36,23 +36,30 @@ class Import::GithubController < Import::BaseController
end
def create
- @repo_id = params[:repo_id].to_i
- repo = client.repo(@repo_id)
- @project_name = params[:new_name].presence || repo.name
+ repo = client.repo(params[:repo_id].to_i)
+ project_name = params[:new_name].presence || repo.name
namespace_path = params[:target_namespace].presence || current_user.namespace_path
- @target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path)
-
- if can?(current_user, :create_projects, @target_namespace)
- @project = Gitlab::GithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params, type: provider).execute
+ target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path)
+
+ if can?(current_user, :create_projects, target_namespace)
+ project = Gitlab::LegacyGithubImport::ProjectCreator
+ .new(repo, project_name, target_namespace, current_user, access_params, type: provider)
+ .execute(extra_project_attrs)
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
else
- render 'unauthorized'
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
end
end
private
def client
- @client ||= Gitlab::GithubImport::Client.new(session[access_token_key], client_options)
+ @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options)
end
def verify_import_enabled
@@ -64,19 +71,19 @@ class Import::GithubController < Import::BaseController
end
def import_enabled?
- __send__("#{provider}_import_enabled?")
+ __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
end
def new_import_url
- public_send("new_import_#{provider}_url")
+ public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def status_import_url
- public_send("status_import_#{provider}_url")
+ public_send("status_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def callback_import_url
- public_send("callback_import_#{provider}_url")
+ public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
@@ -111,4 +118,12 @@ class Import::GithubController < Import::BaseController
def client_options
{}
end
+
+ def extra_project_attrs
+ {}
+ end
+
+ def extra_import_params
+ {}
+ end
end
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 73837ffbe67..18f1d20f5a9 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -15,7 +15,7 @@ class Import::GitlabController < Import::BaseController
@already_added_projects = current_user.created_projects.where(import_type: "gitlab")
already_added_projects_names = @already_added_projects.pluck(:import_source)
- @repos = @repos.to_a.reject{ |repo| already_added_projects_names.include? repo["path_with_namespace"] }
+ @repos = @repos.to_a.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] }
end
def jobs
@@ -24,15 +24,19 @@ class Import::GitlabController < Import::BaseController
end
def create
- @repo_id = params[:repo_id].to_i
- repo = client.project(@repo_id)
- @project_name = repo['name']
- @target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username'])
+ repo = client.project(params[:repo_id].to_i)
+ target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username'])
- if current_user.can?(:create_projects, @target_namespace)
- @project = Gitlab::GitlabImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute
+ if current_user.can?(:create_projects, target_namespace)
+ project = Gitlab::GitlabImport::ProjectCreator.new(repo, target_namespace, current_user, access_params).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
else
- render 'unauthorized'
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 36d246d185b..f22df992fe9 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -1,9 +1,11 @@
class Import::GitlabProjectsController < Import::BaseController
+ before_action :whitelist_query_limiting, only: [:create]
before_action :verify_gitlab_project_import_enabled
def new
@namespace = Namespace.find(project_params[:namespace_id])
return render_404 unless current_user.can?(:create_projects, @namespace)
+
@path = project_params[:path]
end
@@ -12,15 +14,7 @@ class Import::GitlabProjectsController < Import::BaseController
return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." })
end
- import_upload_path = Gitlab::ImportExport.import_upload_path(filename: project_params[:file].original_filename)
-
- FileUtils.mkdir_p(File.dirname(import_upload_path))
- FileUtils.copy_entry(project_params[:file].path, import_upload_path)
-
- @project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id],
- current_user,
- import_upload_path,
- project_params[:path]).execute
+ @project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute
if @project.saved?
redirect_to(
@@ -47,4 +41,8 @@ class Import::GitlabProjectsController < Import::BaseController
:path, :namespace_id, :file
)
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42437')
+ end
end
diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb
index 7d7f13ce5d5..baa19fb383d 100644
--- a/app/controllers/import/google_code_controller.rb
+++ b/app/controllers/import/google_code_controller.rb
@@ -85,16 +85,16 @@ class Import::GoogleCodeController < Import::BaseController
end
def create
- @repo_id = params[:repo_id]
- repo = client.repo(@repo_id)
- @target_namespace = current_user.namespace
- @project_name = repo.name
-
- namespace = @target_namespace
-
+ repo = client.repo(params[:repo_id])
user_map = session[:google_code_user_map]
- @project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, current_user, user_map).execute
+ project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, current_user.namespace, current_user, user_map).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
end
private
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 7625187c7be..025d8270b7c 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -51,7 +51,7 @@ class InvitesController < ApplicationController
return if current_user
notice = "To accept this invitation, sign in"
- notice << " or create an account" if current_application_settings.signup_enabled?
+ notice << " or create an account" if Gitlab::CurrentSettings.allow_signup?
notice << "."
store_location_for :user, request.fullpath
@@ -62,8 +62,8 @@ class InvitesController < ApplicationController
case source
when Project
project = member.source
- label = "project #{project.name_with_namespace}"
- path = namespace_project_path(project.namespace, project)
+ label = "project #{project.full_name}"
+ path = project_path(project)
when Group
group = member.source
label = "group #{group.name}"
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 4bceb1d67a3..7d6fe6a0232 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -30,11 +30,11 @@ class JwtController < ApplicationController
render_unauthorized
end
end
- rescue Gitlab::Auth::MissingPersonalTokenError
- render_missing_personal_token
+ rescue Gitlab::Auth::MissingPersonalAccessTokenError
+ render_missing_personal_access_token
end
- def render_missing_personal_token
+ def render_missing_personal_access_token
render json: {
errors: [
{ code: 'UNAUTHORIZED',
diff --git a/app/controllers/koding_controller.rb b/app/controllers/koding_controller.rb
index 6b1e64ce819..745abf3c0f5 100644
--- a/app/controllers/koding_controller.rb
+++ b/app/controllers/koding_controller.rb
@@ -10,6 +10,6 @@ class KodingController < ApplicationController
private
def check_integration!
- render_404 unless current_application_settings.koding_enabled?
+ render_404 unless Gitlab::CurrentSettings.koding_enabled?
end
end
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index 0e9a19c0b6f..33b682d2859 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -1,12 +1,19 @@
class MetricsController < ActionController::Base
- include RequiresHealthToken
+ include RequiresWhitelistedMonitoringClient
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'
+ response = if Gitlab::Metrics.prometheus_metrics_enabled?
+ metrics_service.metrics_text
+ else
+ help_page = help_page_url('administration/monitoring/prometheus/gitlab_metrics',
+ anchor: 'gitlab-prometheus-metrics'
+ )
+ "# Metrics are disabled, see: #{help_page}\n"
+ end
+
+ render text: response, content_type: 'text/plain; version=0.0.4'
end
private
@@ -14,8 +21,4 @@ class MetricsController < ActionController::Base
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/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 2ae4785b12c..a1fe02dc852 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -1,6 +1,6 @@
class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
- include Gitlab::CurrentSettings
include Gitlab::GonHelper
+ include Gitlab::Allowable
include PageLayoutHelper
include OauthApplications
@@ -9,6 +9,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit]
+ helper_method :can?
+
layout 'profile'
def index
@@ -16,12 +18,11 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
end
def create
- @application = Doorkeeper::Application.new(application_params)
-
- @application.owner = current_user
+ @application = Applications::CreateService.new(current_user, create_application_params).execute(request)
- if @application.save
+ if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
+
redirect_to oauth_application_url(@application)
else
set_index_vars
@@ -32,7 +33,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
private
def verify_user_oauth_applications_enabled
- return if current_application_settings.user_oauth_applications?
+ return if Gitlab::CurrentSettings.user_oauth_applications?
redirect_to profile_path
end
@@ -55,4 +56,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
rescue_from ActiveRecord::RecordNotFound do |exception|
render "errors/not_found", layout: "errors", status: 404
end
+
+ def create_application_params
+ application_params.tap do |params|
+ params[:owner] = current_user
+ end
+ end
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index b82681b197e..5e6676ea513 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -1,5 +1,6 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
+ include Devise::Controllers::Rememberable
protect_from_forgery except: [:kerberos, :saml, :cas3]
@@ -9,6 +10,26 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
+ if Gitlab::Auth::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.available_servers.each do |server|
+ define_method server['provider_name'] do
+ ldap
+ end
+ end
+ end
+
+ # Extend the standard implementation to also increment
+ # the number of failed sign in attempts
+ def failure
+ if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name)
+ user = User.by_login(params[:username])
+
+ user&.increment_failed_attempts!
+ end
+
+ super
+ end
+
# Extend the standard message generation to accept our custom exception
def failure_message
exception = env["omniauth.error"]
@@ -22,7 +43,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# We only find ourselves here
# if the authentication to LDAP was successful.
def ldap
- ldap_user = Gitlab::LDAP::User.new(oauth)
+ ldap_user = Gitlab::Auth::LDAP::User.new(oauth)
ldap_user.save if ldap_user.changed? # will also save new users
@user = ldap_user.gl_user
@@ -33,12 +54,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if @user.two_factor_enabled?
prompt_for_two_factor(@user)
else
- log_audit_event(@user, with: :ldap)
+ log_audit_event(@user, with: oauth['provider'])
sign_in_and_redirect(@user)
end
else
- flash[:alert] = "Access denied for your LDAP account."
- redirect_to new_user_session_path
+ fail_ldap_login
end
end
@@ -46,7 +66,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if current_user
log_audit_event(current_user, with: :saml)
# Update SAML identity if data has changed.
- identity = current_user.identities.find_by(extern_uid: oauth['uid'], provider: :saml)
+ identity = current_user.identities.with_extern_uid(:saml, oauth['uid']).take
if identity.nil?
current_user.identities.create(extern_uid: oauth['uid'], provider: :saml)
redirect_to profile_account_path, notice: 'Authentication method updated'
@@ -54,13 +74,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
redirect_to after_sign_in_path_for(current_user)
end
else
- saml_user = Gitlab::Saml::User.new(oauth)
+ saml_user = Gitlab::Auth::Saml::User.new(oauth)
saml_user.save if saml_user.changed?
@user = saml_user.gl_user
continue_login_process
end
- rescue Gitlab::OAuth::SignupDisabledError
+ rescue Gitlab::Auth::OAuth::User::SignupDisabledError
handle_signup_error
end
@@ -75,6 +95,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if ticket
handle_service_ticket oauth['provider'], ticket
end
+
handle_omniauth
end
@@ -82,30 +103,43 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if params['sid']
handle_service_ticket oauth['provider'], params['sid']
end
+
handle_omniauth
end
+ def auth0
+ if oauth['uid'].blank?
+ fail_auth0_login
+ else
+ handle_omniauth
+ end
+ end
+
private
def handle_omniauth
if current_user
# Add new authentication method
- current_user.identities.find_or_create_by(extern_uid: oauth['uid'], provider: oauth['provider'])
+ current_user.identities
+ .with_extern_uid(oauth['provider'], oauth['uid'])
+ .first_or_create(extern_uid: oauth['uid'])
log_audit_event(current_user, with: oauth['provider'])
redirect_to profile_account_path, notice: 'Authentication method updated'
else
- oauth_user = Gitlab::OAuth::User.new(oauth)
+ oauth_user = Gitlab::Auth::OAuth::User.new(oauth)
oauth_user.save
@user = oauth_user.gl_user
continue_login_process
end
- rescue Gitlab::OAuth::SignupDisabledError
+ rescue Gitlab::Auth::OAuth::User::SigninDisabledForProviderError
+ handle_disabled_provider
+ rescue Gitlab::Auth::OAuth::User::SignupDisabledError
handle_signup_error
end
def handle_service_ticket(provider, ticket)
- Gitlab::OAuth::Session.create provider, ticket
+ Gitlab::Auth::OAuth::Session.create provider, ticket
session[:service_tickets] ||= {}
session[:service_tickets][provider] = ticket
end
@@ -114,23 +148,24 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# Only allow properly saved users to login.
if @user.persisted? && @user.valid?
log_audit_event(@user, with: oauth['provider'])
+
if @user.two_factor_enabled?
+ params[:remember_me] = '1' if remember_me?
prompt_for_two_factor(@user)
else
+ remember_me(@user) if remember_me?
sign_in_and_redirect(@user)
end
else
- error_message = @user.errors.full_messages.to_sentence
-
- return redirect_to omniauth_error_path(oauth['provider'], error: error_message)
+ fail_login
end
end
def handle_signup_error
- label = Gitlab::OAuth::Provider.label_for(oauth['provider'])
+ label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
message = "Signing in using your #{label} account without a pre-existing GitLab account is not allowed."
- if current_application_settings.signup_enabled?
+ if Gitlab::CurrentSettings.allow_signup?
message << " Create a GitLab account first, and then connect it to your #{label} account."
end
@@ -143,8 +178,38 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
@oauth ||= request.env['omniauth.auth']
end
+ def fail_login
+ error_message = @user.errors.full_messages.to_sentence
+
+ return redirect_to omniauth_error_path(oauth['provider'], error: error_message)
+ end
+
+ def fail_ldap_login
+ flash[:alert] = 'Access denied for your LDAP account.'
+
+ redirect_to new_user_session_path
+ end
+
+ def fail_auth0_login
+ flash[:alert] = 'Wrong extern UID provided. Make sure Auth0 is configured correctly.'
+
+ redirect_to new_user_session_path
+ end
+
+ def handle_disabled_provider
+ label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
+ flash[:alert] = "Signing in using #{label} has been disabled"
+
+ redirect_to new_user_session_path
+ end
+
def log_audit_event(user, options = {})
AuditEventService.new(user, user, options)
.for_authentication.security_event
end
+
+ def remember_me?
+ request_params = request.env['omniauth.params']
+ (request_params['remember_me'] == '1') if request_params.present?
+ end
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index a8575e037e4..331583c49e6 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -1,6 +1,8 @@
class PasswordsController < Devise::PasswordsController
+ skip_before_action :require_no_authentication, only: [:edit, :update]
+
before_action :resource_from_email, only: [:create]
- before_action :prevent_ldap_reset, only: [:create]
+ before_action :check_password_authentication_available, only: [:create]
before_action :throttle_reset, only: [:create]
def edit
@@ -25,7 +27,7 @@ class PasswordsController < Devise::PasswordsController
def update
super do |resource|
- if resource.valid? && resource.require_password?
+ if resource.valid? && resource.password_automatically_set?
resource.update_attribute(:password_automatically_set, false)
end
end
@@ -38,11 +40,15 @@ class PasswordsController < Devise::PasswordsController
self.resource = resource_class.find_by_email(email)
end
- def prevent_ldap_reset
- return unless resource && resource.ldap_user?
+ def check_password_authentication_available
+ if resource
+ return if resource.allow_password_authentication?
+ else
+ return if Gitlab::CurrentSettings.password_authentication_enabled?
+ end
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
- alert: "Cannot reset password for LDAP user."
+ alert: "Password authentication is unavailable."
end
def throttle_reset
diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb
index 408650aac54..39b9f8a84d1 100644
--- a/app/controllers/profiles/avatars_controller.rb
+++ b/app/controllers/profiles/avatars_controller.rb
@@ -2,7 +2,7 @@ class Profiles::AvatarsController < Profiles::ApplicationController
def destroy
@user = current_user
- Users::UpdateService.new(@user).execute { |user| user.remove_avatar! }
+ Users::UpdateService.new(current_user, user: @user).execute { |user| user.remove_avatar! }
redirect_to profile_path, status: 302
end
diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index 17b66df43e7..bbd7ba49d77 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -1,15 +1,14 @@
class Profiles::EmailsController < Profiles::ApplicationController
+ before_action :find_email, only: [:destroy, :resend_confirmation_instructions]
+
def index
- @primary = current_user.email
- @emails = current_user.emails
+ @primary_email = current_user.email
+ @emails = current_user.emails.order_id_desc
end
def create
- @email = Emails::CreateService.new(current_user, email_params).execute
-
- if @email.errors.blank?
- NotificationService.new.new_email(@email)
- else
+ @email = Emails::CreateService.new(current_user, email_params.merge(user: current_user)).execute
+ unless @email.errors.blank?
flash[:alert] = @email.errors.full_messages.first
end
@@ -17,9 +16,7 @@ class Profiles::EmailsController < Profiles::ApplicationController
end
def destroy
- @email = current_user.emails.find(params[:id])
-
- Emails::DestroyService.new(current_user, email: @email.email).execute
+ Emails::DestroyService.new(current_user, user: current_user).execute(@email)
respond_to do |format|
format.html { redirect_to profile_emails_url, status: 302 }
@@ -27,9 +24,23 @@ class Profiles::EmailsController < Profiles::ApplicationController
end
end
+ def resend_confirmation_instructions
+ if Emails::ConfirmService.new(current_user, user: current_user).execute(@email)
+ flash[:notice] = "Confirmation email sent to #{@email.email}"
+ else
+ flash[:alert] = "There was a problem sending the confirmation email"
+ end
+
+ redirect_to profile_emails_url
+ end
+
private
def email_params
params.require(:email).permit(:email)
end
+
+ def find_email
+ @email = current_user.emails.find(params[:id])
+ end
end
diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb
new file mode 100644
index 00000000000..38e3eacd229
--- /dev/null
+++ b/app/controllers/profiles/gpg_keys_controller.rb
@@ -0,0 +1,47 @@
+class Profiles::GpgKeysController < Profiles::ApplicationController
+ before_action :set_gpg_key, only: [:destroy, :revoke]
+
+ def index
+ @gpg_keys = current_user.gpg_keys.with_subkeys
+ @gpg_key = GpgKey.new
+ end
+
+ def create
+ @gpg_key = GpgKeys::CreateService.new(current_user, gpg_key_params).execute
+
+ if @gpg_key.persisted?
+ redirect_to profile_gpg_keys_path
+ else
+ @gpg_keys = current_user.gpg_keys.select(&:persisted?)
+ render :index
+ end
+ end
+
+ def destroy
+ @gpg_key.destroy
+
+ respond_to do |format|
+ format.html { redirect_to profile_gpg_keys_url, status: 302 }
+ format.js { head :ok }
+ end
+ end
+
+ def revoke
+ @gpg_key.revoke
+
+ respond_to do |format|
+ format.html { redirect_to profile_gpg_keys_url, status: 302 }
+ format.js { head :ok }
+ end
+ end
+
+ private
+
+ def gpg_key_params
+ params.require(:gpg_key).permit(:key)
+ end
+
+ def set_gpg_key
+ @gpg_key = current_user.gpg_keys.find(params[:id])
+ end
+end
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index 88f49da555a..f0e5d2aa94e 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -2,7 +2,7 @@ class Profiles::KeysController < Profiles::ApplicationController
skip_before_action :authenticate_user!, only: [:get_keys]
def index
- @keys = current_user.keys
+ @keys = current_user.keys.order_id_desc
@key = Key.new
end
@@ -11,9 +11,9 @@ class Profiles::KeysController < Profiles::ApplicationController
end
def create
- @key = current_user.keys.new(key_params)
+ @key = Keys::CreateService.new(current_user, key_params.merge(ip_address: request.remote_ip)).execute
- if @key.save
+ if @key.persisted?
redirect_to profile_key_path(@key)
else
@keys = current_user.keys.select(&:persisted?)
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 960b7512602..8a38ba65d4c 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -7,7 +7,7 @@ class Profiles::NotificationsController < Profiles::ApplicationController
end
def update
- result = Users::UpdateService.new(current_user, user_params).execute
+ result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute
if result[:status] == :success
flash[:notice] = "Notification settings saved"
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index 10145bae0d3..b8ccc6e3c99 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -1,5 +1,6 @@
class Profiles::PasswordsController < Profiles::ApplicationController
skip_before_action :check_password_expiration, only: [:new, :create]
+ skip_before_action :check_two_factor_requirement, only: [:new, :create]
before_action :set_user
before_action :authorize_change_password!
@@ -21,10 +22,10 @@ class Profiles::PasswordsController < Profiles::ApplicationController
password_automatically_set: false
}
- result = Users::UpdateService.new(@user, password_attributes).execute
+ result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute
if result[:status] == :success
- Users::UpdateService.new(@user, password_expires_at: nil).execute
+ Users::UpdateService.new(current_user, user: @user, password_expires_at: nil).execute
redirect_to root_path, notice: 'Password successfully changed'
else
@@ -46,7 +47,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
return
end
- result = Users::UpdateService.new(@user, password_attributes).execute
+ result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute
if result[:status] == :success
flash[:notice] = "Password was successfully updated. Please login with it"
@@ -77,7 +78,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def authorize_change_password!
- return render_404 if @user.ldap_user?
+ render_404 unless @user.allow_password_authentication?
end
def user_params
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index f748d191ef4..346eab4ba19 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -1,13 +1,14 @@
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def index
set_index_vars
+ @personal_access_token = finder.build
end
def create
@personal_access_token = finder.build(personal_access_token_params)
if @personal_access_token.save
- flash[:personal_access_token] = @personal_access_token.token
+ PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created."
else
set_index_vars
@@ -38,10 +39,11 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def set_index_vars
- @scopes = Gitlab::Auth::AVAILABLE_SCOPES
+ @scopes = Gitlab::Auth.available_scopes(current_user)
- @personal_access_token = finder.build
@inactive_personal_access_tokens = finder(state: 'inactive').execute
@active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at)
+
+ @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id)
end
end
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 1e557c47638..ed0f98179eb 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -6,7 +6,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
def update
begin
- result = Users::UpdateService.new(user, preferences_params).execute
+ result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute
if result[:status] == :success
flash[:notice] = 'Preferences saved.'
@@ -35,7 +35,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:color_scheme_id,
:layout,
:dashboard,
- :project_view
+ :project_view,
+ :theme_id
)
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 1a4f77639e7..aa9789f8a0f 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -10,7 +10,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.otp_grace_period_started_at = Time.current
end
- Users::UpdateService.new(current_user).execute!
+ Users::UpdateService.new(current_user, user: current_user).execute!
if two_factor_authentication_required? && !current_user.two_factor_enabled?
two_factor_authentication_reason(
@@ -41,7 +41,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def create
if current_user.validate_and_consume_otp!(params[:pin_code])
- Users::UpdateService.new(current_user, otp_required_for_login: true).execute! do |user|
+ Users::UpdateService.new(current_user, user: current_user, otp_required_for_login: true).execute! do |user|
@codes = user.generate_otp_backup_codes!
end
@@ -70,7 +70,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end
def codes
- Users::UpdateService.new(current_user).execute! do |user|
+ Users::UpdateService.new(current_user, user: current_user).execute! do |user|
@codes = user.generate_otp_backup_codes!
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 076076fd1b3..dbf61a17724 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -9,10 +9,8 @@ class ProfilesController < Profiles::ApplicationController
end
def update
- user_params.except!(:email) if @user.external_email?
-
respond_to do |format|
- result = Users::UpdateService.new(@user, user_params).execute
+ result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute
if result[:status] == :success
message = "Profile was successfully updated"
@@ -26,34 +24,24 @@ class ProfilesController < Profiles::ApplicationController
end
end
- def reset_private_token
- Users::UpdateService.new(@user).execute! do |user|
- user.reset_authentication_token!
- end
-
- flash[:notice] = "Private token was successfully reset"
-
- redirect_to profile_account_path
- end
-
def reset_incoming_email_token
- Users::UpdateService.new(@user).execute! do |user|
+ Users::UpdateService.new(current_user, user: @user).execute! do |user|
user.reset_incoming_email_token!
end
flash[:notice] = "Incoming email token was successfully reset"
- redirect_to profile_account_path
+ redirect_to profile_personal_access_tokens_path
end
def reset_rss_token
- Users::UpdateService.new(@user).execute! do |user|
+ Users::UpdateService.new(current_user, user: @user).execute! do |user|
user.reset_rss_token!
end
flash[:notice] = "RSS token was successfully reset"
- redirect_to profile_account_path
+ redirect_to profile_personal_access_tokens_path
end
def audit_log
@@ -63,7 +51,7 @@ class ProfilesController < Profiles::ApplicationController
end
def update_username
- result = Users::UpdateService.new(@user, username: user_params[:username]).execute
+ result = Users::UpdateService.new(current_user, user: @user, username: user_params[:username]).execute
options = if result[:status] == :success
{ notice: "Username successfully changed" }
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 3d7ce4f0222..6d9b42a2c04 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -2,26 +2,17 @@ class Projects::ApplicationController < ApplicationController
include RoutableActions
skip_before_action :authenticate_user!
- before_action :redirect_git_extension
before_action :project
before_action :repository
layout 'project'
- helper_method :repository, :can_collaborate_with_project?
+ helper_method :repository, :can_collaborate_with_project?, :user_access
private
- def redirect_git_extension
- # Redirect from
- # localhost/group/project.git
- # to
- # localhost/group/project
- #
- redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git'
- end
-
def project
return @project if @project
+ return nil unless params[:project_id] || params[:id]
path = File.join(params[:namespace_id], params[:project_id] || params[:id])
auth_proc = ->(project) { !project.pending_delete? }
@@ -40,11 +31,12 @@ class Projects::ApplicationController < ApplicationController
@repository ||= project.repository
end
- def can_collaborate_with_project?(project = nil)
+ def can_collaborate_with_project?(project = nil, ref: nil)
project ||= @project
can?(current_user, :push_code, project) ||
- (current_user && current_user.already_forked?(project))
+ (current_user && current_user.already_forked?(project)) ||
+ user_access(project).can_push_to_branch?(ref)
end
def authorize_action!(action)
@@ -76,13 +68,13 @@ class Projects::ApplicationController < ApplicationController
def require_non_empty_project
# Be sure to return status code 303 to avoid a double DELETE:
# http://api.rubyonrails.org/classes/ActionController/Redirecting.html
- redirect_to namespace_project_path(@project.namespace, @project), status: 303 if @project.empty_repo?
+ redirect_to project_path(@project), status: 303 if @project.empty_repo?
end
def require_branch_head
unless @repository.branch_exists?(@ref)
redirect_to(
- namespace_project_tree_path(@project.namespace, @project, @ref),
+ project_tree_path(@project, @ref),
notice: "This action is not allowed unless you are on a branch"
)
end
@@ -93,6 +85,15 @@ class Projects::ApplicationController < ApplicationController
end
def require_pages_enabled!
- not_found unless Gitlab.config.pages.enabled
+ not_found unless @project.pages_available?
+ end
+
+ def check_issues_available!
+ return render_404 unless @project.feature_available?(:issues, current_user)
+ end
+
+ def user_access(project)
+ @user_access ||= {}
+ @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project)
end
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index ea036b1f705..0837451cc49 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -7,7 +7,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :authorize_update_build!, only: [:keep]
before_action :extract_ref_name_and_path
before_action :validate_artifacts!
- before_action :set_path_and_entry, only: [:file, :raw]
+ before_action :entry, only: [:file]
def download
if artifacts_file.file_storage?
@@ -29,24 +29,31 @@ class Projects::ArtifactsController < Projects::ApplicationController
blob = @entry.blob
conditionally_expand_blob(blob)
- respond_to do |format|
- format.html do
- render 'file'
- end
-
- format.json do
- render_blob_json(blob)
+ if blob.external_link?(build)
+ redirect_to blob.external_url(@project, build)
+ else
+ respond_to do |format|
+ format.html do
+ render 'file'
+ end
+
+ format.json do
+ render_blob_json(blob)
+ end
end
end
end
def raw
- send_artifacts_entry(build, @entry)
+ path = Gitlab::Ci::Build::Artifacts::Path
+ .new(params[:path])
+
+ send_artifacts_entry(build, path)
end
def keep
build.keep_artifacts!
- redirect_to namespace_project_job_path(project.namespace, project, build)
+ redirect_to project_job_path(project, build)
end
def latest_succeeded
@@ -93,9 +100,8 @@ class Projects::ArtifactsController < Projects::ApplicationController
@artifacts_file ||= build.artifacts_file
end
- def set_path_and_entry
- @path = params[:path]
- @entry = build.artifacts_metadata_entry(@path)
+ def entry
+ @entry = build.artifacts_metadata_entry(params[:path])
render_404 unless @entry.exists?
end
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index ffb54390965..992c8ea6992 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -2,7 +2,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :load_autocomplete_service, except: [:members]
def members
- render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
+ render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
end
def issues
@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end
def labels
- render json: @autocomplete_service.labels
+ render json: @autocomplete_service.labels(target)
end
def milestones
@@ -22,7 +22,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end
def commands
- render json: @autocomplete_service.commands(noteable, params[:type])
+ render json: @autocomplete_service.commands(target, params[:type])
end
private
@@ -31,13 +31,13 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
@autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user)
end
- def noteable
- case params[:type]
- when 'Issue'
- IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
- when 'MergeRequest'
- MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
- when 'Commit'
+ def target
+ case params[:type]&.downcase
+ when 'issue'
+ IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:type_id])
+ when 'mergerequest'
+ MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:type_id])
+ when 'commit'
@project.commit(params[:type_id])
end
end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index 6c25cd83a24..06ba73d8e8d 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -3,11 +3,11 @@ class Projects::BadgesController < Projects::ApplicationController
before_action :authorize_admin_project!, only: [:index]
before_action :no_cache_headers, except: [:index]
- def build
- build_status = Gitlab::Badge::Build::Status
+ def pipeline
+ pipeline_status = Gitlab::Badge::Pipeline::Status
.new(project, params[:ref])
- render_badge build_status
+ render_badge pipeline_status
end
def coverage
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index a82d6fd5a4a..0c1c286a0a4 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -5,15 +5,16 @@ class Projects::BlobController < Projects::ApplicationController
include RendersBlob
include ActionView::Helpers::SanitizeHelper
- # Raised when given an invalid file path
- InvalidPathError = Class.new(StandardError)
-
prepend_before_action :authenticate_user!, only: [:edit]
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
- before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
+
+ # We need to assign the blob vars before `authorize_edit_tree!` so we can
+ # validate access to a specific ref.
before_action :assign_blob_vars
+ before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
+
before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create]
before_action :require_branch_head, only: [:edit, :update]
@@ -27,9 +28,9 @@ class Projects::BlobController < Projects::ApplicationController
def create
create_commit(Files::CreateService, success_notice: "The file has been successfully created.",
- success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @file_path)) },
+ success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
failure_view: :new,
- failure_path: namespace_project_new_blob_path(@project.namespace, @project, @ref))
+ failure_path: project_new_blob_path(@project, @ref))
end
def show
@@ -37,22 +38,19 @@ class Projects::BlobController < Projects::ApplicationController
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'
+ show_html
end
format.json do
- render_blob_json(@blob)
+ page_title @blob.path, @ref, @project.full_name
+
+ show_json
end
end
end
def edit
- if can_collaborate_with_project?
+ if can_collaborate_with_project?(project, ref: @ref)
blob.load_all_data!
else
redirect_to action: 'show'
@@ -63,8 +61,7 @@ class Projects::BlobController < Projects::ApplicationController
@path = params[:file_path] if params[:file_path].present?
create_commit(Files::UpdateService, success_path: -> { after_edit_path },
failure_view: :edit,
- failure_path: namespace_project_blob_path(@project.namespace, @project, @id))
-
+ failure_path: project_blob_path(@project, @id))
rescue Files::UpdateService::FileChangedError
@conflict = true
render :edit
@@ -83,9 +80,9 @@ class Projects::BlobController < Projects::ApplicationController
def destroy
create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.",
- success_path: -> { namespace_project_tree_path(@project.namespace, @project, @branch_name) },
+ success_path: -> { project_tree_path(@project, @branch_name) },
failure_view: :show,
- failure_path: namespace_project_blob_path(@project.namespace, @project, @id))
+ failure_path: project_blob_path(@project, @id))
end
def diff
@@ -118,7 +115,7 @@ class Projects::BlobController < Projects::ApplicationController
else
if tree = @repository.tree(@commit.id, @path)
if tree.entries.any?
- return redirect_to namespace_project_tree_path(@project.namespace, @project, File.join(@ref, @path))
+ return redirect_to project_tree_path(@project, File.join(@ref, @path))
end
end
@@ -135,18 +132,17 @@ class Projects::BlobController < Projects::ApplicationController
def assign_blob_vars
@id = params[:id]
@ref, @path = extract_ref(@id)
-
rescue InvalidPathError
render_404
end
def after_edit_path
- from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:from_merge_request_iid])
+ from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid])
if from_merge_request && @branch_name == @ref
- diffs_namespace_project_merge_request_path(from_merge_request.target_project.namespace, from_merge_request.target_project, from_merge_request) +
+ diffs_project_merge_request_path(from_merge_request.target_project, from_merge_request) +
"##{hexdigest(@path)}"
else
- namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @path))
+ project_blob_path(@project, File.join(@branch_name, @path))
end
end
@@ -158,6 +154,7 @@ class Projects::BlobController < Projects::ApplicationController
if params[:file].present?
params[:file_name] = params[:file].original_filename
end
+
File.join(@path, params[:file_name])
elsif params[:file_path].present?
params[:file_path]
@@ -190,4 +187,40 @@ class Projects::BlobController < Projects::ApplicationController
@last_commit_sha = Gitlab::Git::Commit
.last_for_path(@repository, @ref, @path).sha
end
+
+ def show_html
+ 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
+
+ def show_json
+ json = blob_json(@blob)
+ return render_404 unless json
+
+ path_segments = @path.split('/')
+ path_segments.pop
+ tree_path = path_segments.join('/')
+
+ render json: json.merge(
+ id: @blob.id,
+ path: blob.path,
+ name: blob.name,
+ extension: blob.extension,
+ size: blob.raw_size,
+ mime_type: blob.mime_type,
+ binary: blob.raw_binary?,
+ simple_viewer: blob.simple_viewer&.class&.partial_name,
+ rich_viewer: blob.rich_viewer&.class&.partial_name,
+ show_viewer_switcher: !!blob.show_viewer_switcher?,
+ render_error: blob.simple_viewer&.render_error || blob.rich_viewer&.render_error,
+ raw_path: project_raw_path(project, @id),
+ blame_path: project_blame_path(project, @id),
+ commits_path: project_commits_path(project, @id),
+ tree_path: project_tree_path(project, File.join(@ref, tree_path)),
+ permalink: project_blob_path(project, File.join(@commit.id, @path))
+ )
+ end
end
diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb
deleted file mode 100644
index dad38fff6b9..00000000000
--- a/app/controllers/projects/boards/application_controller.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-module Projects
- module Boards
- class ApplicationController < Projects::ApplicationController
- respond_to :json
-
- rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
-
- private
-
- def record_not_found(exception)
- render json: { error: exception.message }, status: :not_found
- end
- end
- end
-end
diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
deleted file mode 100644
index da9b789d617..00000000000
--- a/app/controllers/projects/boards/issues_controller.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-module Projects
- module Boards
- class IssuesController < Boards::ApplicationController
- before_action :authorize_read_issue!, only: [:index]
- before_action :authorize_create_issue!, only: [:create]
- before_action :authorize_update_issue!, only: [:update]
-
- def index
- issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
- issues = issues.page(params[:page]).per(params[:per] || 20)
- make_sure_position_is_set(issues)
-
- render json: {
- issues: serialize_as_json(issues),
- size: issues.total_count
- }
- end
-
- def create
- service = ::Boards::Issues::CreateService.new(project, current_user, issue_params)
- issue = service.execute
-
- if issue.valid?
- render json: serialize_as_json(issue)
- else
- render json: issue.errors, status: :unprocessable_entity
- end
- end
-
- def update
- service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
-
- if service.execute(issue)
- head :ok
- else
- head :unprocessable_entity
- end
- end
-
- private
-
- def make_sure_position_is_set(issues)
- issues.each do |issue|
- issue.move_to_end && issue.save unless issue.relative_position
- end
- end
-
- def issue
- @issue ||=
- IssuesFinder.new(current_user, project_id: project.id)
- .execute
- .where(iid: params[:id])
- .first!
- end
-
- def authorize_read_issue!
- return render_403 unless can?(current_user, :read_issue, project)
- end
-
- def authorize_create_issue!
- return render_403 unless can?(current_user, :admin_issue, project)
- end
-
- def authorize_update_issue!
- return render_403 unless can?(current_user, :update_issue, issue)
- end
-
- def filter_params
- params.merge(board_id: params[:board_id], id: params[:list_id]).compact
- end
-
- def move_params
- params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_iid, :move_after_iid)
- end
-
- def issue_params
- params.require(:issue).permit(:title).merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
- end
-
- def serialize_as_json(resource)
- resource.as_json(
- labels: true,
- only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
- include: {
- assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
- milestone: { only: [:id, :title] }
- },
- user: current_user
- )
- end
- end
- end
-end
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
deleted file mode 100644
index ad53bb749a0..00000000000
--- a/app/controllers/projects/boards/lists_controller.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-module Projects
- module Boards
- class ListsController < Boards::ApplicationController
- before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
- before_action :authorize_read_list!, only: [:index]
-
- def index
- lists = ::Boards::Lists::ListService.new(project, current_user).execute(board)
-
- render json: serialize_as_json(lists)
- end
-
- def create
- list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute(board)
-
- if list.valid?
- render json: serialize_as_json(list)
- else
- render json: list.errors, status: :unprocessable_entity
- end
- end
-
- def update
- list = board.lists.movable.find(params[:id])
- service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
-
- if service.execute(list)
- head :ok
- else
- head :unprocessable_entity
- end
- end
-
- def destroy
- list = board.lists.destroyable.find(params[:id])
- service = ::Boards::Lists::DestroyService.new(project, current_user)
-
- if service.execute(list)
- head :ok
- else
- head :unprocessable_entity
- end
- end
-
- def generate
- service = ::Boards::Lists::GenerateService.new(project, current_user)
-
- if service.execute(board)
- render json: serialize_as_json(board.lists.movable)
- else
- head :unprocessable_entity
- end
- end
-
- private
-
- def authorize_admin_list!
- return render_403 unless can?(current_user, :admin_list, project)
- end
-
- def authorize_read_list!
- return render_403 unless can?(current_user, :read_list, project)
- end
-
- def board
- @board ||= project.boards.find(params[:board_id])
- end
-
- def list_params
- params.require(:list).permit(:label_id)
- end
-
- def move_params
- params.require(:list).permit(:position)
- end
-
- def serialize_as_json(resource)
- resource.as_json(
- only: [:id, :list_type, :position],
- methods: [:title],
- label: true
- )
- end
- end
- end
-end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 808affa4f98..949e54ff819 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -1,32 +1,32 @@
class Projects::BoardsController < Projects::ApplicationController
+ include BoardsResponses
include IssuableCollections
+ before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show]
+ before_action :assign_endpoint_vars
def index
- @boards = ::Boards::ListService.new(project, current_user).execute
-
- respond_to do |format|
- format.html
- format.json do
- render json: serialize_as_json(@boards)
- end
- end
+ @boards = Boards::ListService.new(project, current_user).execute
+
+ respond_with_boards
end
def show
@board = project.boards.find(params[:id])
- respond_to do |format|
- format.html
- format.json do
- render json: serialize_as_json(@board)
- end
- end
+ respond_with_board
end
private
+ def assign_endpoint_vars
+ @boards_endpoint = project_boards_path(project)
+ @bulk_issues_path = bulk_update_project_issues_path(project)
+ @namespace_path = project.namespace.full_path
+ @labels_endpoint = project_labels_path(project)
+ end
+
def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project)
end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 94a752c21eb..965cece600e 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -7,22 +7,36 @@ class Projects::BranchesController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_push_code!, only: [:new, :create, :destroy, :destroy_all_merged]
- def index
- @sort = params[:sort].presence || sort_value_name
- @branches = BranchesFinder.new(@repository, params).execute
- @branches = Kaminari.paginate_array(@branches).page(params[:page])
+ # Support legacy URLs
+ before_action :redirect_for_legacy_index_sort_or_search, only: [:index]
+ def index
respond_to do |format|
format.html do
+ @sort = params[:sort].presence || sort_value_recently_updated
+ @mode = params[:state].presence || 'overview'
+ @overview_max_branches = 5
+
+ # Fetch branches for the specified mode
+ fetch_branches_by_mode
+
@refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name))
+ @merged_branch_names =
+ repository.merged_branch_names(@branches.map(&:name))
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37429
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ @max_commits = @branches.reduce(0) do |memo, branch|
+ diverging_commit_counts = repository.diverging_commit_counts(branch)
+ [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max
+ end
- @max_commits = @branches.reduce(0) do |memo, branch|
- diverging_commit_counts = repository.diverging_commit_counts(branch)
- [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max
+ render
end
end
format.json do
- render json: @branches.map(&:name)
+ branches = BranchesFinder.new(@repository, params).execute
+ branches = Kaminari.paginate_array(branches).page(params[:page])
+ render json: branches.map(&:name)
end
end
end
@@ -35,24 +49,26 @@ class Projects::BranchesController < Projects::ApplicationController
branch_name = sanitize(strip_tags(params[:branch_name]))
branch_name = Addressable::URI.unescape(branch_name)
- redirect_to_autodeploy = project.empty_repo? && project.deployment_services.present?
+ redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present?
result = CreateBranchService.new(project, current_user)
.execute(branch_name, ref)
- if params[:issue_iid]
+ success = (result[:status] == :success)
+
+ if params[:issue_iid] && success
issue = IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:issue_iid])
SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue
end
respond_to do |format|
format.html do
- if result[:status] == :success
+ if success
if redirect_to_autodeploy
redirect_to url_to_autodeploy_setup(project, branch_name),
notice: view_context.autodeploy_flash_notice(branch_name)
else
- redirect_to namespace_project_tree_path(@project.namespace, @project, branch_name)
+ redirect_to project_tree_path(@project, branch_name)
end
else
@error = result[:message]
@@ -61,8 +77,8 @@ class Projects::BranchesController < Projects::ApplicationController
end
format.json do
- if result[:status] == :success
- render json: { name: branch_name, url: namespace_project_tree_url(@project.namespace, @project, branch_name) }
+ if success
+ render json: { name: branch_name, url: project_tree_url(@project, branch_name) }
else
render json: result[:messsage], status: :unprocessable_entity
end
@@ -79,7 +95,7 @@ class Projects::BranchesController < Projects::ApplicationController
flash_type = result[:status] == :error ? :alert : :notice
flash[flash_type] = result[:message]
- redirect_to namespace_project_branches_path(@project.namespace, @project), status: 303
+ redirect_to project_branches_path(@project), status: 303
end
format.js { render nothing: true, status: result[:return_code] }
@@ -90,7 +106,7 @@ class Projects::BranchesController < Projects::ApplicationController
def destroy_all_merged
DeleteMergedBranchesService.new(@project, current_user).async_execute
- redirect_to namespace_project_branches_path(@project.namespace, @project),
+ redirect_to project_branches_path(@project),
notice: 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.'
end
@@ -106,8 +122,7 @@ class Projects::BranchesController < Projects::ApplicationController
end
def url_to_autodeploy_setup(project, branch_name)
- namespace_project_new_blob_path(
- project.namespace,
+ project_new_blob_path(
project,
branch_name,
file_name: '.gitlab-ci.yml',
@@ -116,4 +131,27 @@ class Projects::BranchesController < Projects::ApplicationController
context: 'autodeploy'
)
end
+
+ def redirect_for_legacy_index_sort_or_search
+ # Normalize a legacy URL with redirect
+ if request.format != :json && !params[:state].presence && [:sort, :search, :page].any? { |key| params[key].presence }
+ redirect_to project_branches_filtered_path(@project, state: 'all'), notice: 'Update your bookmarked URLs as filtered/sorted branches URL has been changed.'
+ end
+ end
+
+ def fetch_branches_by_mode
+ if @mode == 'overview'
+ # overview mode
+ @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?)
+ # Here we get one more branch to indicate if there are more data we're not showing
+ @active_branches = @active_branches.first(@overview_max_branches + 1)
+ @stale_branches = @stale_branches.first(@overview_max_branches + 1)
+ @branches = @active_branches + @stale_branches
+ else
+ # active/stale/all view mode
+ @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute
+ @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode)
+ @branches = Kaminari.paginate_array(@branches).page(params[:page])
+ end
+ end
end
diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb
index f34a198634e..b45e5d7ff43 100644
--- a/app/controllers/projects/build_artifacts_controller.rb
+++ b/app/controllers/projects/build_artifacts_controller.rb
@@ -7,23 +7,23 @@ class Projects::BuildArtifactsController < Projects::ApplicationController
before_action :validate_artifacts!
def download
- redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job)
+ redirect_to download_project_job_artifacts_path(project, job)
end
def browse
- redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
+ redirect_to browse_project_job_artifacts_path(project, job, path: params[:path])
end
def file
- redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
+ redirect_to file_project_job_artifacts_path(project, job, path: params[:path])
end
def raw
- redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
+ redirect_to raw_project_job_artifacts_path(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])
+ redirect_to latest_succeeded_project_artifacts_path(project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job])
end
private
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index 1334a231788..230b072dcea 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -2,15 +2,15 @@ class Projects::BuildsController < Projects::ApplicationController
before_action :authorize_read_build!
def index
- redirect_to namespace_project_jobs_path(project.namespace, project)
+ redirect_to project_jobs_path(project)
end
def show
- redirect_to namespace_project_job_path(project.namespace, project, job)
+ redirect_to project_job_path(project, job)
end
def raw
- redirect_to raw_namespace_project_job_path(project.namespace, project, job)
+ redirect_to raw_project_job_path(project, job)
end
private
diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb
new file mode 100644
index 00000000000..90c7fa62216
--- /dev/null
+++ b/app/controllers/projects/clusters/applications_controller.rb
@@ -0,0 +1,25 @@
+class Projects::Clusters::ApplicationsController < Projects::ApplicationController
+ before_action :cluster
+ before_action :application_class, only: [:create]
+ before_action :authorize_read_cluster!
+ before_action :authorize_create_cluster!, only: [:create]
+
+ def create
+ Clusters::Applications::ScheduleInstallationService.new(project, current_user,
+ application_class: @application_class,
+ cluster: @cluster).execute
+ head :no_content
+ rescue StandardError
+ head :bad_request
+ end
+
+ private
+
+ def cluster
+ @cluster ||= project.clusters.find(params[:id]) || render_404
+ end
+
+ def application_class
+ @application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404
+ end
+end
diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb
new file mode 100644
index 00000000000..6b0b22f8e73
--- /dev/null
+++ b/app/controllers/projects/clusters/gcp_controller.rb
@@ -0,0 +1,102 @@
+class Projects::Clusters::GcpController < Projects::ApplicationController
+ before_action :authorize_read_cluster!
+ before_action :authorize_google_api, except: [:login]
+ before_action :authorize_google_project_billing, only: [:new, :create]
+ before_action :authorize_create_cluster!, only: [:new, :create]
+ before_action :verify_billing, only: [:create]
+
+ def login
+ begin
+ state = generate_session_key_redirect(gcp_new_namespace_project_clusters_path.to_s)
+
+ @authorize_url = GoogleApi::CloudPlatform::Client.new(
+ nil, callback_google_api_auth_url,
+ state: state).authorize_url
+ rescue GoogleApi::Auth::ConfigMissingError
+ # no-op
+ end
+ end
+
+ def new
+ @cluster = ::Clusters::Cluster.new.tap do |cluster|
+ cluster.build_provider_gcp
+ end
+ end
+
+ def create
+ @cluster = ::Clusters::CreateService
+ .new(project, current_user, create_params)
+ .execute(token_in_session)
+
+ if @cluster.persisted?
+ redirect_to project_cluster_path(project, @cluster)
+ else
+ render :new
+ end
+ end
+
+ private
+
+ def verify_billing
+ case google_project_billing_status
+ when nil
+ flash.now[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
+ when false
+ flash.now[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" }
+ when true
+ return
+ end
+
+ @cluster = ::Clusters::Cluster.new(create_params)
+
+ render :new
+ end
+
+ def create_params
+ params.require(:cluster).permit(
+ :enabled,
+ :name,
+ :environment_scope,
+ provider_gcp_attributes: [
+ :gcp_project_id,
+ :zone,
+ :num_nodes,
+ :machine_type
+ ]).merge(
+ provider_type: :gcp,
+ platform_type: :kubernetes
+ )
+ end
+
+ def authorize_google_api
+ unless GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ .validate_token(expires_at_in_session)
+ redirect_to action: 'login'
+ end
+ end
+
+ def authorize_google_project_billing
+ redis_token_key = CheckGcpProjectBillingWorker.store_session_token(token_in_session)
+ CheckGcpProjectBillingWorker.perform_async(redis_token_key)
+ end
+
+ def google_project_billing_status
+ CheckGcpProjectBillingWorker.get_billing_state(token_in_session)
+ end
+
+ def token_in_session
+ @token_in_session ||=
+ session[GoogleApi::CloudPlatform::Client.session_key_for_token]
+ end
+
+ def expires_at_in_session
+ @expires_at_in_session ||=
+ session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
+ end
+
+ def generate_session_key_redirect(uri)
+ GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
+ session[key] = uri
+ end
+ end
+end
diff --git a/app/controllers/projects/clusters/user_controller.rb b/app/controllers/projects/clusters/user_controller.rb
new file mode 100644
index 00000000000..d0db64b2fa9
--- /dev/null
+++ b/app/controllers/projects/clusters/user_controller.rb
@@ -0,0 +1,40 @@
+class Projects::Clusters::UserController < Projects::ApplicationController
+ before_action :authorize_read_cluster!
+ before_action :authorize_create_cluster!, only: [:new, :create]
+
+ def new
+ @cluster = ::Clusters::Cluster.new.tap do |cluster|
+ cluster.build_platform_kubernetes
+ end
+ end
+
+ def create
+ @cluster = ::Clusters::CreateService
+ .new(project, current_user, create_params)
+ .execute
+
+ if @cluster.persisted?
+ redirect_to project_cluster_path(project, @cluster)
+ else
+ render :new
+ end
+ end
+
+ private
+
+ def create_params
+ params.require(:cluster).permit(
+ :enabled,
+ :name,
+ :environment_scope,
+ platform_kubernetes_attributes: [
+ :namespace,
+ :api_url,
+ :token,
+ :ca_cert
+ ]).merge(
+ provider_type: :user,
+ platform_type: :kubernetes
+ )
+ end
+end
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
new file mode 100644
index 00000000000..aeaba3a0acf
--- /dev/null
+++ b/app/controllers/projects/clusters_controller.rb
@@ -0,0 +1,122 @@
+class Projects::ClustersController < Projects::ApplicationController
+ before_action :cluster, except: [:index, :new]
+ before_action :authorize_read_cluster!
+ before_action :authorize_create_cluster!, only: [:new]
+ before_action :authorize_update_cluster!, only: [:update]
+ before_action :authorize_admin_cluster!, only: [:destroy]
+ before_action :update_applications_status, only: [:status]
+
+ STATUS_POLLING_INTERVAL = 10_000
+
+ def index
+ clusters = ClustersFinder.new(project, current_user, :all).execute
+ @clusters = clusters.page(params[:page]).per(20)
+ end
+
+ def new
+ end
+
+ def status
+ respond_to do |format|
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL)
+
+ render json: ClusterSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent_status(@cluster)
+ end
+ end
+ end
+
+ def show
+ end
+
+ def update
+ Clusters::UpdateService
+ .new(project, current_user, update_params)
+ .execute(cluster)
+
+ if cluster.valid?
+ respond_to do |format|
+ format.json do
+ head :no_content
+ end
+ format.html do
+ flash[:notice] = _('Kubernetes cluster was successfully updated.')
+ redirect_to project_cluster_path(project, cluster)
+ end
+ end
+ else
+ respond_to do |format|
+ format.json { head :bad_request }
+ format.html { render :show }
+ end
+ end
+ end
+
+ def destroy
+ if cluster.destroy
+ flash[:notice] = _('Kubernetes cluster integration was successfully removed.')
+ redirect_to project_clusters_path(project), status: 302
+ else
+ flash[:notice] = _('Kubernetes cluster integration was not removed.')
+ render :show
+ end
+ end
+
+ private
+
+ def cluster
+ @cluster ||= project.clusters.find(params[:id])
+ .present(current_user: current_user)
+ end
+
+ def create_params
+ params.require(:cluster).permit(
+ :enabled,
+ :name,
+ :provider_type,
+ provider_gcp_attributes: [
+ :gcp_project_id,
+ :zone,
+ :num_nodes,
+ :machine_type
+ ])
+ end
+
+ def update_params
+ if cluster.managed?
+ params.require(:cluster).permit(
+ :enabled,
+ :environment_scope,
+ platform_kubernetes_attributes: [
+ :namespace
+ ]
+ )
+ else
+ params.require(:cluster).permit(
+ :enabled,
+ :name,
+ :environment_scope,
+ platform_kubernetes_attributes: [
+ :api_url,
+ :token,
+ :ca_cert,
+ :namespace
+ ]
+ )
+ end
+ end
+
+ def authorize_update_cluster!
+ access_denied! unless can?(current_user, :update_cluster, cluster)
+ end
+
+ def authorize_admin_cluster!
+ access_denied! unless can?(current_user, :admin_cluster, cluster)
+ end
+
+ def update_applications_status
+ @cluster.applications.each(&:schedule_status_update)
+ end
+end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 7c3cce1c241..effb484ef0f 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -12,15 +12,17 @@ class Projects::CommitController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_read_pipeline!, only: [:pipelines]
before_action :commit
- before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines]
+ before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines, :merge_requests]
before_action :define_note_vars, only: [:show, :diff_for_path]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
+ BRANCH_SEARCH_LIMIT = 1000
+
def show
apply_diff_view_cookie!
respond_to do |format|
- format.html
+ format.html { render }
format.diff { render text: @commit.to_diff }
format.patch { render text: @commit.to_patch }
end
@@ -38,16 +40,39 @@ class Projects::CommitController < Projects::ApplicationController
format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .represent(@pipelines)
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipelines),
+ count: {
+ all: @pipelines.count
+ }
+ }
+ end
+ end
+ end
+
+ def merge_requests
+ @merge_requests = @commit.merge_requests.map do |mr|
+ { iid: mr.iid, path: merge_request_path(mr), title: mr.title }
+ end
+
+ respond_to do |format|
+ format.json do
+ render json: @merge_requests.to_json
end
end
end
def branches
- @branches = @project.repository.branch_names_contains(commit.id)
- @tags = @project.repository.tag_names_contains(commit.id)
+ # branch_names_contains/tag_names_contains can take a long time when there are thousands of
+ # branches/tags - each `git branch --contains xxx` request can consume a cpu core.
+ # so only do the query when there are a manageable number of branches/tags
+ @branches_limit_exceeded = @project.repository.branch_count > BRANCH_SEARCH_LIMIT
+ @branches = @branches_limit_exceeded ? [] : @project.repository.branch_names_contains(commit.id)
+
+ @tags_limit_exceeded = @project.repository.tag_count > BRANCH_SEARCH_LIMIT
+ @tags = @tags_limit_exceeded ? [] : @project.repository.tag_names_contains(commit.id)
render layout: false
end
@@ -80,21 +105,21 @@ class Projects::CommitController < Projects::ApplicationController
end
def successful_change_path
- referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @branch_name)
+ referenced_merge_request_url || project_commits_url(@project, @branch_name)
end
def failed_change_path
- referenced_merge_request_url || namespace_project_commit_url(@project.namespace, @project, params[:id])
+ referenced_merge_request_url || project_commit_url(@project, params[:id])
end
def referenced_merge_request_url
if merge_request = @commit.merged_merge_request(current_user)
- namespace_project_merge_request_url(merge_request.target_project.namespace, merge_request.target_project, merge_request)
+ project_merge_request_url(merge_request.target_project, merge_request)
end
end
def commit
- @noteable = @commit ||= @project.commit(params[:id])
+ @noteable = @commit ||= @project.commit_by(oid: params[:id])
end
def define_commit_vars
@@ -121,8 +146,25 @@ class Projects::CommitController < Projects::ApplicationController
@grouped_diff_discussions = commit.grouped_diff_discussions
@discussions = commit.discussions
+ if merge_request_iid = params[:merge_request_iid]
+ @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: merge_request_iid)
+
+ if @merge_request
+ @new_diff_note_attrs.merge!(
+ noteable_type: 'MergeRequest',
+ noteable_id: @merge_request.id
+ )
+
+ merge_request_commit_notes = @merge_request.notes.where(commit_id: @commit.id).inc_relations_for_view
+ merge_request_commit_diff_discussions = merge_request_commit_notes.grouped_diff_discussions(@commit.diff_refs)
+ @grouped_diff_discussions.merge!(merge_request_commit_diff_discussions) do |line_code, left, right|
+ left + right
+ end
+ end
+ end
+
@notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes)
- @notes = prepare_notes_for_rendering(@notes)
+ @notes = prepare_notes_for_rendering(@notes, @commit)
end
def assign_change_commit_vars
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 37b5a6e9d48..7b7cb52d7ed 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -2,25 +2,15 @@ require "base64"
class Projects::CommitsController < Projects::ApplicationController
include ExtractsPath
+ include RendersCommits
+ before_action :whitelist_query_limiting
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
+ before_action :set_commits
def show
- @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
- search = params[:search]
-
- @commits =
- if search.present?
- @repository.find_commits_by_message(search, @ref, @path, @limit, @offset)
- else
- @repository.commits(@ref, path: @path, limit: @limit, offset: @offset)
- end
-
- @note_counts = project.notes.where(commit_id: @commits.map(&:id))
- .group(:commit_id).count
-
@merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
.find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref)
@@ -37,4 +27,41 @@ class Projects::CommitsController < Projects::ApplicationController
end
end
end
+
+ def signatures
+ respond_to do |format|
+ format.json do
+ render json: {
+ signatures: @commits.select(&:has_signature?).map do |commit|
+ {
+ commit_sha: commit.sha,
+ html: view_to_html_string('projects/commit/_signature', signature: commit.signature)
+ }
+ end
+ }
+ end
+ end
+ end
+
+ private
+
+ def set_commits
+ render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
+ @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
+ search = params[:search]
+
+ @commits =
+ if search.present?
+ @repository.find_commits_by_message(search, @ref, @path, @limit, @offset)
+ else
+ @repository.commits(@ref, path: @path, limit: @limit, offset: @offset)
+ end
+
+ @commits = @commits.with_pipeline_status
+ @commits = prepare_commits_for_rendering(@commits)
+ end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42330')
+ end
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index ef400c4d745..2b0c2ca97c0 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -3,6 +3,7 @@ require 'addressable/uri'
class Projects::CompareController < Projects::ApplicationController
include DiffForPath
include DiffHelper
+ include RendersCommits
# Authorize
before_action :require_non_empty_project
@@ -16,6 +17,8 @@ class Projects::CompareController < Projects::ApplicationController
def show
apply_diff_view_cookie!
+
+ render
end
def diff_for_path
@@ -26,14 +29,14 @@ class Projects::CompareController < Projects::ApplicationController
def create
if params[:from].blank? || params[:to].blank?
- flash[:alert] = "You must select from and to branches"
+ flash[:alert] = "You must select a Source and a Target revision"
from_to_vars = {
from: params[:from].presence,
to: params[:to].presence
}
- redirect_to namespace_project_compare_index_path(@project.namespace, @project, from_to_vars)
+ redirect_to project_compare_index_path(@project, from_to_vars)
else
- redirect_to namespace_project_compare_path(@project.namespace, @project,
+ redirect_to project_compare_path(@project,
params[:from], params[:to])
end
end
@@ -50,7 +53,7 @@ class Projects::CompareController < Projects::ApplicationController
.execute(@project, @start_ref)
if @compare
- @commits = @compare.commits
+ @commits = prepare_commits_for_rendering(@compare.commits)
@diffs = @compare.diffs(diff_options)
environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit }
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index b69d46f2c41..26f3c114108 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -2,7 +2,7 @@ module Projects
module CycleAnalytics
class EventsController < Projects::ApplicationController
include CycleAnalyticsParams
-
+
before_action :authorize_read_cycle_analytics!
before_action :authorize_read_build!, only: [:test, :staging]
before_action :authorize_read_issue!, only: [:issue, :production]
@@ -11,33 +11,33 @@ module Projects
def issue
render_events(cycle_analytics[:issue].events)
end
-
+
def plan
render_events(cycle_analytics[:plan].events)
end
-
+
def code
render_events(cycle_analytics[:code].events)
end
-
+
def test
options(events_params)[:branch] = events_params[:branch_name]
-
+
render_events(cycle_analytics[:test].events)
end
-
+
def review
render_events(cycle_analytics[:review].events)
end
-
+
def staging
render_events(cycle_analytics[:staging].events)
end
-
+
def production
render_events(cycle_analytics[:production].events)
end
-
+
private
def render_events(events)
@@ -46,14 +46,14 @@ module Projects
format.json { render json: { events: events } }
end
end
-
+
def cycle_analytics
@cycle_analytics ||= ::CycleAnalytics.new(project, options(events_params))
end
-
+
def events_params
return {} unless params[:events].present?
-
+
params[:events].permit(:start_date, :branch_name)
end
end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 88ac3ad046b..d1b8fd80c4e 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -3,6 +3,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
+ before_action :whitelist_query_limiting, only: [:show]
before_action :authorize_read_cycle_analytics!
def show
@@ -31,4 +32,8 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
permissions: @cycle_analytics.permissions(user: current_user)
}
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42671')
+ end
end
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index c2e621fa190..f43ef2e5f2f 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -22,11 +22,12 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create
- @key = DeployKey.new(create_params.merge(user: current_user))
+ @key = DeployKeys::CreateService.new(current_user, create_params).execute
- unless @key.valid? && @project.deploy_keys << @key
+ unless @key.valid?
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
end
+
redirect_to_repository_settings(@project)
end
@@ -70,11 +71,14 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create_params
- params.require(:deploy_key).permit(:key, :title, :can_push)
+ create_params = params.require(:deploy_key)
+ .permit(:key, :title, deploy_keys_projects_attributes: [:can_push])
+ create_params.dig(:deploy_keys_projects_attributes, '0')&.merge!(project_id: @project.id)
+ create_params
end
def update_params
- params.require(:deploy_key).permit(:title, :can_push)
+ params.require(:deploy_key).permit(:title, deploy_keys_projects_attributes: [:id, :can_push])
end
def authorize_update_deploy_key!
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 47c312ffddf..b68cdc39cb8 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -12,6 +12,7 @@ class Projects::DeploymentsController < Projects::ApplicationController
def metrics
return render_404 unless deployment.has_metrics?
+
@metrics = deployment.metrics
if @metrics&.any?
render json: @metrics, status: :ok
@@ -23,7 +24,7 @@ class Projects::DeploymentsController < Projects::ApplicationController
end
def additional_metrics
- return render_404 unless deployment.has_additional_metrics?
+ return render_404 unless deployment.has_metrics?
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 2e6ab7903b8..cba9a53dc4b 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -1,4 +1,7 @@
class Projects::DiscussionsController < Projects::ApplicationController
+ include NotesHelper
+ include RendersNotes
+
before_action :check_merge_requests_available!
before_action :merge_request
before_action :discussion
@@ -7,22 +10,51 @@ class Projects::DiscussionsController < Projects::ApplicationController
def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
- render json: {
- resolved_by: discussion.resolved_by.try(:name),
- discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
- }
+ render_discussion
end
def unresolve
discussion.unresolve!
+ render_discussion
+ end
+
+ def show
render json: {
- discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
+ discussion_html: view_to_html_string('discussions/_diff_with_notes', discussion: discussion, expanded: true)
}
end
private
+ def render_discussion
+ if serialize_notes?
+ # TODO - It is not needed to serialize notes when resolving
+ # or unresolving discussions. We should remove this behavior
+ # passing a parameter to DiscussionEntity to return an empty array
+ # for notes.
+ # Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853
+ prepare_notes_for_rendering(discussion.notes, merge_request)
+ render_json_with_discussions_serializer
+ else
+ render_json_with_html
+ end
+ end
+
+ def render_json_with_discussions_serializer
+ render json:
+ DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user)
+ .represent(discussion, context: self)
+ end
+
+ # Legacy method used to render discussions notes when not using Vue on views.
+ def render_json_with_html
+ render json: {
+ resolved_by: discussion.resolved_by.try(:name),
+ discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
+ }
+ end
+
def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index f88a1ffd1e9..52d528e816e 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)
@@ -32,6 +34,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
folder_environments = project.environments.where(environment_type: params[:id])
@environments = folder_environments.with_state(params[:scope] || :available)
.order(:name)
+ @folder = params[:id]
respond_to do |format|
format.html
@@ -63,7 +66,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment = project.environments.create(environment_params)
if @environment.persisted?
- redirect_to namespace_project_environment_path(project.namespace, project, @environment)
+ redirect_to project_environment_path(project, @environment)
else
render :new
end
@@ -71,7 +74,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def update
if @environment.update(environment_params)
- redirect_to namespace_project_environment_path(project.namespace, project, @environment)
+ redirect_to project_environment_path(project, @environment)
else
render :edit
end
@@ -86,7 +89,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
if stop_action
polymorphic_url([project.namespace.becomes(Namespace), project, stop_action])
else
- namespace_project_environment_url(project.namespace, project, @environment)
+ project_environment_url(project, @environment)
end
respond_to do |format|
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 1eb3800e49d..f43bba18d81 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -2,6 +2,7 @@ class Projects::ForksController < Projects::ApplicationController
include ContinueParams
# Authorize
+ before_action :whitelist_query_limiting, only: [:create]
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authenticate_user!, only: [:new, :create]
@@ -9,14 +10,12 @@ class Projects::ForksController < Projects::ApplicationController
def index
base_query = project.forks.includes(:creator)
- @forks = base_query.merge(ProjectsFinder.new(current_user: current_user).execute)
+ forks = ForkProjectsFinder.new(project, params: params.merge(search: params[:filter_projects]), current_user: current_user).execute
@total_forks_count = base_query.size
- @private_forks_count = @total_forks_count - @forks.size
+ @private_forks_count = @total_forks_count - forks.size
@public_forks_count = @total_forks_count - @private_forks_count
- @sort = params[:sort] || 'id_desc'
- @forks = @forks.search(params[:filter_projects]) if params[:filter_projects].present?
- @forks = @forks.order_by(@sort).page(params[:page])
+ @forks = forks.page(params[:page])
respond_to do |format|
format.html
@@ -44,16 +43,20 @@ class Projects::ForksController < Projects::ApplicationController
if @forked_project.saved? && @forked_project.forked?
if @forked_project.import_in_progress?
- redirect_to namespace_project_import_path(@forked_project.namespace, @forked_project, continue: continue_params)
+ redirect_to project_import_path(@forked_project, continue: continue_params)
else
if continue_params
redirect_to continue_params[:to], notice: continue_params[:notice]
else
- redirect_to namespace_project_path(@forked_project.namespace, @forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
+ redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
end
end
else
render :error
end
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42335')
+ end
end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index 7d0e2b3e2ef..dd5e66f60e3 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -9,6 +9,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
alias_method :user, :actor
+ alias_method :authenticated_user, :actor
# Git clients will not know what authenticity token to send along
skip_before_action :verify_authenticity_token
@@ -52,8 +53,8 @@ class Projects::GitHttpClientController < Projects::ApplicationController
send_challenges
render plain: "HTTP Basic: Access denied\n", status: 401
- rescue Gitlab::Auth::MissingPersonalTokenError
- render_missing_personal_token
+ rescue Gitlab::Auth::MissingPersonalAccessTokenError
+ render_missing_personal_access_token
end
def basic_auth_provided?
@@ -77,7 +78,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
@project, @wiki, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
end
- def render_missing_personal_token
+ def render_missing_personal_access_token
render plain: "HTTP Basic: Access denied\n" \
"You must use a personal access token with 'api' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 71ae60cb8cd..45910a9be44 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -5,6 +5,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
+ rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
@@ -55,8 +56,15 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render plain: exception.message, status: :not_found
end
+ def render_422(exception)
+ render plain: exception.message, status: :unprocessable_entity
+ end
+
def access
- @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, redirected_path: redirected_path)
+ @access ||= access_klass.new(access_actor, project,
+ 'http', authentication_abilities: authentication_abilities,
+ namespace_path: params[:namespace_id], project_path: project_path,
+ redirected_path: redirected_path)
end
def access_actor
@@ -68,12 +76,17 @@ class Projects::GitHttpController < Projects::GitHttpClientController
# Use the magic string '_any' to indicate we do not know what the
# changes are. This is also what gitlab-shell does.
access.check(git_command, '_any')
+ @project ||= access.project
end
def access_klass
@access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
end
+ def project_path
+ @project_path ||= params[:project_id].sub(/\.git$/, '')
+ end
+
def log_user_activity
Users::ActivityService.new(user, 'pull').execute
end
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index df5221fe95f..475d4c86294 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -29,7 +29,7 @@ class Projects::GraphsController < Projects::ApplicationController
end
def ci
- redirect_to charts_namespace_project_pipelines_path(@project.namespace, @project)
+ redirect_to charts_project_pipelines_path(@project)
end
private
@@ -43,23 +43,7 @@ class Projects::GraphsController < Projects::ApplicationController
end
def get_languages
- @languages = Linguist::Repository.new(@repository.rugged, @repository.rugged.head.target_id).languages
- total = @languages.map(&:last).sum
-
- @languages = @languages.map do |language|
- name, share = language
- color = Linguist::Language[name].color || "##{Digest::SHA256.hexdigest(name)[0...6]}"
- {
- value: (share.to_f * 100 / total).round(2),
- label: name,
- color: color,
- highlight: color
- }
- end
-
- @languages.sort! do |x, y|
- y[:value] <=> x[:value]
- end
+ @languages = @project.repository.languages
end
def fetch_graph
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index deb33a2f0ff..f58ee3e9109 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -13,16 +13,12 @@ class Projects::GroupLinksController < Projects::ApplicationController
if group
return render_404 unless can?(current_user, :read_group, group)
- project.project_group_links.create(
- group: group,
- group_access: params[:link_group_access],
- expires_at: params[:expires_at]
- )
+ Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
else
flash[:alert] = 'Please select a group.'
end
- redirect_to namespace_project_settings_members_path(project.namespace, project)
+ redirect_to project_project_members_path(project)
end
def update
@@ -32,11 +28,13 @@ class Projects::GroupLinksController < Projects::ApplicationController
end
def destroy
- project.project_group_links.find(params[:id]).destroy
+ group_link = project.project_group_links.find(params[:id])
+
+ ::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link)
respond_to do |format|
format.html do
- redirect_to namespace_project_settings_members_path(project.namespace, project), status: 302
+ redirect_to project_project_members_path(project), status: 302
end
format.js { head :ok }
end
@@ -47,4 +45,8 @@ class Projects::GroupLinksController < Projects::ApplicationController
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
+
+ def group_link_create_params
+ params.permit(:link_group_access, :expires_at)
+ end
end
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
index 354f0d6db3a..745e89fc843 100644
--- a/app/controllers/projects/hook_logs_controller.rb
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -14,11 +14,11 @@ class Projects::HookLogsController < Projects::ApplicationController
end
def retry
- status, message = hook.execute(hook_log.request_data, hook_log.trigger)
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
- set_hook_execution_notice(status, message)
+ set_hook_execution_notice(result)
- redirect_to edit_namespace_project_hook_path(@project.namespace, @project, @hook)
+ redirect_to edit_project_hook_path(@project, @hook)
end
private
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index f5143280154..dd7aa1a67b9 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -9,6 +9,10 @@ class Projects::HooksController < Projects::ApplicationController
layout "project_settings"
+ def index
+ redirect_to project_settings_integrations_path(@project)
+ end
+
def create
@hook = @project.hooks.new(hook_params)
@hook.save
@@ -17,7 +21,8 @@ class Projects::HooksController < Projects::ApplicationController
@hooks = @project.hooks.select(&:persisted?)
flash[:alert] = @hook.errors.full_messages.join.html_safe
end
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+
+ redirect_to project_settings_integrations_path(@project)
end
def edit
@@ -26,20 +31,16 @@ class Projects::HooksController < Projects::ApplicationController
def update
if hook.update_attributes(hook_params)
flash[:notice] = 'Hook was successfully updated.'
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+ redirect_to project_settings_integrations_path(@project)
else
render 'edit'
end
end
def test
- if !@project.empty_repo?
- status, message = TestHookService.new.execute(hook, current_user)
+ result = TestHooks::ProjectService.new(hook, current_user, params[:trigger]).execute
- set_hook_execution_notice(status, message)
- else
- flash[:alert] = 'Hook execution failed. Ensure the project has commits.'
- end
+ set_hook_execution_notice(result)
redirect_back_or_default(default: { action: 'index' })
end
@@ -47,7 +48,7 @@ class Projects::HooksController < Projects::ApplicationController
def destroy
hook.destroy
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project), status: 302
+ redirect_to project_settings_integrations_path(@project), status: 302
end
private
@@ -63,18 +64,10 @@ class Projects::HooksController < Projects::ApplicationController
def hook_params
params.require(:hook).permit(
- :job_events,
- :pipeline_events,
:enable_ssl_verification,
- :issues_events,
- :confidential_issues_events,
- :merge_requests_events,
- :note_events,
- :push_events,
- :tag_push_events,
:token,
:url,
- :wiki_page_events
+ *ProjectHook.triggers.values
)
end
end
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index 4b143434ea5..49aa32119ef 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -17,7 +17,7 @@ class Projects::ImportsController < Projects::ApplicationController
@project.reload.import_schedule
end
- redirect_to namespace_project_import_path(@project.namespace, @project)
+ redirect_to project_import_path(@project)
end
def show
@@ -25,10 +25,10 @@ class Projects::ImportsController < Projects::ApplicationController
if continue_params
redirect_to continue_params[:to], notice: continue_params[:notice]
else
- redirect_to namespace_project_path(@project.namespace, @project), notice: finished_notice
+ redirect_to project_path(@project), notice: finished_notice
end
elsif @project.import_failed?
- redirect_to new_namespace_project_import_path(@project.namespace, @project)
+ redirect_to new_project_import_path(@project)
else
if continue_params && continue_params[:notice_now]
flash.now[:notice] = continue_params[:notice_now]
@@ -50,19 +50,19 @@ class Projects::ImportsController < Projects::ApplicationController
def require_no_repo
if @project.repository_exists?
- redirect_to namespace_project_path(@project.namespace, @project)
+ redirect_to project_path(@project)
end
end
def redirect_if_progress
if @project.import_in_progress?
- redirect_to namespace_project_import_path(@project.namespace, @project)
+ redirect_to project_import_path(@project)
end
end
def redirect_if_no_import
if @project.repository_exists? && @project.no_import?
- redirect_to namespace_project_path(@project.namespace, @project)
+ redirect_to project_path(@project)
end
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index dfc6baa34a4..b14939c4216 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -8,15 +8,16 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:new]
- before_action :redirect_to_external_issue_tracker, only: [:index, :new]
+ before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
before_action :issue, except: [:index, :new, :create, :bulk_update]
+ before_action :set_issuables_index, only: [:index]
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
# Allow modify issue
- before_action :authorize_update_issue!, only: [:edit, :update]
+ before_action :authorize_update_issuable!, only: [:edit, :update, :move]
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request!, only: [:create_merge_request]
@@ -24,30 +25,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to :html
def index
- @collection_type = "Issue"
- @issues = issues_collection
- @issues = @issues.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@issues, @collection_type)
-
- if @issues.out_of_range? && @issues.total_pages != 0
- return redirect_to url_for(params.merge(page: @issues.total_pages, only_path: true))
- end
-
- if params[:label_name].present?
- @labels = LabelsFinder.new(current_user, project_id: @project.id, title: params[:label_name]).execute
- end
-
- @users = []
-
- if params[:assignee_id].present?
- assignee = User.find_by_id(params[:assignee_id])
- @users.push(assignee) if assignee
- end
-
- if params[:author_id].present?
- author = User.find_by_id(params[:author_id])
- @users.push(author) if author
- end
+ @issues = @issuables
respond_to do |format|
format.html
@@ -82,21 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue)
end
- def show
- @noteable = @issue
- @note = @project.notes.new(noteable: @issue)
-
- @discussions = @issue.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
-
- respond_to do |format|
- format.html
- format.json do
- render json: IssueSerializer.new.represent(@issue)
- end
- end
- end
-
def create
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -124,30 +87,19 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
- def update
- update_params = issue_params.merge(spammable_params)
-
- @issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue)
+ def move
+ params.require(:move_to_project_id)
if params[:move_to_project_id].to_i > 0
new_project = Project.find(params[:move_to_project_id])
return render_404 unless issue.can_move?(current_user, new_project)
- move_service = Issues::MoveService.new(project, current_user)
- @issue = move_service.execute(@issue, new_project)
+ @issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue)
end
respond_to do |format|
- format.html do
- recaptcha_check_with_fallback { render :edit }
- end
-
format.json do
- if @issue.valid?
- render json: IssueSerializer.new.represent(@issue)
- else
- render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
- end
+ render_issue_json
end
end
@@ -156,8 +108,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def referenced_merge_requests
- @merge_requests = @issue.referenced_merge_requests(current_user)
- @closed_by_merge_requests = @issue.closed_by_merge_requests(current_user)
+ @merge_requests, @closed_by_merge_requests = ::Issues::FetchReferencedMergeRequestsService.new(project, current_user).execute(issue)
respond_to do |format|
format.json do
@@ -192,28 +143,9 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
- def realtime_changes
- Gitlab::PollingInterval.set_header(response, interval: 3_000)
-
- response = {
- title: view_context.markdown_field(@issue, :title),
- title_text: @issue.title,
- description: view_context.markdown_field(@issue, :description),
- description_text: @issue.description,
- task_status: @issue.task_status
- }
-
- if @issue.is_edited?
- response[:updated_at] = @issue.updated_at
- response[:updated_by_name] = @issue.last_edited_by.name
- response[:updated_by_path] = user_path(@issue.last_edited_by)
- end
-
- render json: response
- end
-
def create_merge_request
- result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
+ create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
+ result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
@@ -226,8 +158,10 @@ class Projects::IssuesController < Projects::ApplicationController
def issue
return @issue if defined?(@issue)
+
# The Sortable default scope causes performance issues when used with find_by
- @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take!
+ @issuable = @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take!
+ @note = @project.notes.new(noteable: @issuable)
return render_404 unless can?(current_user, :read_issue, @issue)
@@ -238,39 +172,40 @@ class Projects::IssuesController < Projects::ApplicationController
alias_method :awardable, :issue
alias_method :spammable, :issue
- def authorize_update_issue!
- return render_404 unless can?(current_user, :update_issue, @issue)
- end
-
- def authorize_admin_issues!
- return render_404 unless can?(current_user, :admin_issue, @project)
+ def spammable_path
+ project_issue_path(@project, @issue)
end
def authorize_create_merge_request!
- return render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
- end
-
- def check_issues_available!
- return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker?
+ render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
end
- def redirect_to_external_issue_tracker
- external = @project.external_issue_tracker
-
- return unless external
-
- if action_name == 'new'
- redirect_to external.new_issue_path
+ def render_issue_json
+ if @issue.valid?
+ render json: serializer.represent(@issue)
else
- redirect_to external.project_path
+ render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
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: []
- )
+ params.require(:issue).permit(*issue_params_attributes)
+ end
+
+ def issue_params_attributes
+ %i[
+ title
+ assignee_id
+ position
+ description
+ confidential
+ milestone_id
+ due_date
+ state_event
+ task_num
+ lock_version
+ discussion_locked
+ ] + [{ label_ids: [], assignee_ids: [] }]
end
def authenticate_user!
@@ -284,4 +219,26 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to new_user_session_path, notice: notice
end
+
+ def serializer
+ IssueSerializer.new(current_user: current_user, project: issue.project)
+ end
+
+ def update_service
+ update_params = issue_params.merge(spammable_params)
+ Issues::UpdateService.new(project, current_user, update_params)
+ end
+
+ def finder_type
+ IssuesFinder
+ end
+
+ def whitelist_query_limiting
+ # Also see the following issues:
+ #
+ # 1. https://gitlab.com/gitlab-org/gitlab-ce/issues/42423
+ # 2. https://gitlab.com/gitlab-org/gitlab-ce/issues/42424
+ # 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422')
+ end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index cb4f46388fd..8b54ba3ad7c 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -4,14 +4,15 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
- except: [:index, :show, :status, :raw, :trace, :cancel_all]
+ except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase]
+ before_action :authorize_erase_build!, only: [:erase]
layout 'project'
def index
@scope = params[:scope]
@all_builds = project.builds.relevant
- @builds = @all_builds.order('created_at DESC')
+ @builds = @all_builds.order('ci_builds.id DESC')
@builds =
case @scope
when 'pending'
@@ -28,7 +29,7 @@ class Projects::JobsController < Projects::ApplicationController
:project,
:tags
])
- @builds = @builds.page(params[:page]).per(30)
+ @builds = @builds.page(params[:page]).per(30).without_count
end
def cancel_all
@@ -38,7 +39,7 @@ class Projects::JobsController < Projects::ApplicationController
build.cancel if can?(current_user, :update_build, build)
end
- redirect_to namespace_project_jobs_path(project.namespace, project)
+ redirect_to project_jobs_path(project)
end
def show
@@ -108,8 +109,8 @@ class Projects::JobsController < Projects::ApplicationController
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!"
+ redirect_to project_job_path(project, @build),
+ notice: "Job has been successfully erased!"
else
respond_422
end
@@ -131,12 +132,16 @@ class Projects::JobsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :update_build, build)
end
+ def authorize_erase_build!
+ return access_denied! unless can?(current_user, :erase_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)
+ project_job_path(build.project, build)
end
end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index daa973c9281..99790b8e7e8 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -33,7 +33,7 @@ class Projects::LabelsController < Projects::ApplicationController
if @label.valid?
respond_to do |format|
- format.html { redirect_to namespace_project_labels_path(@project.namespace, @project) }
+ format.html { redirect_to project_labels_path(@project) }
format.json { render json: @label }
end
else
@@ -51,7 +51,7 @@ class Projects::LabelsController < Projects::ApplicationController
@label = Labels::UpdateService.new(label_params).execute(@label)
if @label.valid?
- redirect_to namespace_project_labels_path(@project.namespace, @project)
+ redirect_to project_labels_path(@project)
else
render :edit
end
@@ -61,12 +61,11 @@ class Projects::LabelsController < Projects::ApplicationController
Gitlab::IssuesLabels.generate(@project)
if params[:redirect] == 'issues'
- redirect_to namespace_project_issues_path(@project.namespace, @project)
+ redirect_to project_issues_path(@project)
elsif params[:redirect] == 'merge_requests'
- redirect_to namespace_project_merge_requests_path(@project.namespace,
- @project)
+ redirect_to project_merge_requests_path(@project)
else
- redirect_to namespace_project_labels_path(@project.namespace, @project)
+ redirect_to project_labels_path(@project)
end
end
@@ -74,7 +73,7 @@ class Projects::LabelsController < Projects::ApplicationController
@label.destroy
@labels = find_labels
- redirect_to namespace_project_labels_path(@project.namespace, @project),
+ redirect_to project_labels_path(@project),
status: 302,
notice: 'Label was removed'
end
@@ -112,12 +111,15 @@ class Projects::LabelsController < Projects::ApplicationController
begin
return render_404 unless promote_service.execute(@label)
+
+ flash[:notice] = "#{@label.title} promoted to group label."
respond_to do |format|
format.html do
- redirect_to(namespace_project_labels_path(@project.namespace, @project),
- notice: 'Label was promoted to a Group Label')
+ redirect_to(project_labels_path(@project), status: 303)
+ end
+ format.json do
+ render json: { url: project_labels_path(@project) }
end
- format.js
end
rescue ActiveRecord::RecordInvalid => e
Gitlab::AppLogger.error "Failed to promote label \"#{@label.title}\" to group label"
@@ -125,7 +127,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_labels_path(@project.namespace, @project),
+ redirect_to(project_labels_path(@project),
notice: 'Failed to promote label due to internal error. Please contact administrators.')
end
format.js
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index 1b0d3aab3fa..c77f10ef1dd 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -2,6 +2,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
include LfsRequest
skip_before_action :lfs_check_access!, only: [:deprecated]
+ before_action :lfs_check_batch_operation!, only: [:batch]
def batch
unless objects.present?
@@ -90,4 +91,21 @@ class Projects::LfsApiController < Projects::GitHttpClientController
}
}
end
+
+ def lfs_check_batch_operation!
+ if upload_request? && Gitlab::Database.read_only?
+ render(
+ json: {
+ message: lfs_read_only_message
+ },
+ content_type: LfsRequest::CONTENT_TYPE,
+ status: 403
+ )
+ end
+ end
+
+ # Overridden in EE
+ def lfs_read_only_message
+ _('You cannot write to this read-only GitLab instance.')
+ end
end
diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb
new file mode 100644
index 00000000000..3fff0fd69ae
--- /dev/null
+++ b/app/controllers/projects/lfs_locks_api_controller.rb
@@ -0,0 +1,70 @@
+class Projects::LfsLocksApiController < Projects::GitHttpClientController
+ include LfsRequest
+
+ def create
+ @result = Lfs::LockFileService.new(project, user, params).execute
+
+ render_json(@result[:lock])
+ end
+
+ def unlock
+ @result = Lfs::UnlockFileService.new(project, user, params).execute
+
+ render_json(@result[:lock])
+ end
+
+ def index
+ @result = Lfs::LocksFinderService.new(project, user, params).execute
+
+ render_json(@result[:locks])
+ end
+
+ def verify
+ @result = Lfs::LocksFinderService.new(project, user, {}).execute
+
+ ours, theirs = split_by_owner(@result[:locks])
+
+ render_json({ ours: ours, theirs: theirs }, false)
+ end
+
+ private
+
+ def render_json(data, process = true)
+ render json: build_payload(data, process),
+ content_type: LfsRequest::CONTENT_TYPE,
+ status: @result[:http_status]
+ end
+
+ def build_payload(data, process)
+ data = LfsFileLockSerializer.new.represent(data) if process
+
+ return data if @result[:status] == :success
+
+ # When the locking failed due to an existent Lock, the existent record
+ # is returned in `@result[:lock]`
+ error_payload(@result[:message], @result[:lock] ? data : {})
+ end
+
+ def error_payload(message, custom_attrs = {})
+ custom_attrs.merge({
+ message: message,
+ documentation_url: help_url
+ })
+ end
+
+ def split_by_owner(locks)
+ groups = locks.partition { |lock| lock.user_id == user.id }
+
+ groups.map! do |records|
+ LfsFileLockSerializer.new.represent(records, root: false)
+ end
+ end
+
+ def download_request?
+ params[:action] == 'index'
+ end
+
+ def upload_request?
+ %w(create unlock verify).include?(params[:action])
+ end
+end
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
index 32759672b6c..941638db427 100644
--- a/app/controllers/projects/lfs_storage_controller.rb
+++ b/app/controllers/projects/lfs_storage_controller.rb
@@ -54,12 +54,13 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
name = request.headers['X-Gitlab-Lfs-Tmp']
return if name.include?('/')
return unless oid.present? && name.start_with?(oid)
+
name
end
def store_file(oid, size, tmp_file)
# Define tmp_file_path early because we use it in "ensure"
- tmp_file_path = File.join("#{Gitlab.config.lfs.storage_path}/tmp/upload", tmp_file)
+ tmp_file_path = File.join(LfsObjectUploader.workhorse_upload_path, tmp_file)
object = LfsObject.find_or_create_by(oid: oid, size: size)
file_exists = object.file.exists? || move_tmp_file_to_storage(object, tmp_file_path)
diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb
index 38f7e6eb5e9..0f6add3e287 100644
--- a/app/controllers/projects/mattermosts_controller.rb
+++ b/app/controllers/projects/mattermosts_controller.rb
@@ -16,12 +16,10 @@ class Projects::MattermostsController < Projects::ApplicationController
if result
flash[:notice] = 'This service is now configured'
- redirect_to edit_namespace_project_service_path(
- @project.namespace, @project, service)
+ redirect_to edit_project_service_path(@project, service)
else
flash[:alert] = message || 'Failed to configure service'
- redirect_to new_namespace_project_mattermost_path(
- @project.namespace, @project)
+ redirect_to new_project_mattermost_path(@project)
end
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
new file mode 100644
index 00000000000..67d4ea2ca8f
--- /dev/null
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -0,0 +1,41 @@
+class Projects::MergeRequests::ApplicationController < Projects::ApplicationController
+ before_action :check_merge_requests_available!
+ before_action :merge_request
+ before_action :authorize_read_merge_request!
+
+ private
+
+ def merge_request
+ @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
+ end
+
+ def merge_request_params
+ params.require(:merge_request).permit(merge_request_params_attributes)
+ end
+
+ def merge_request_params_attributes
+ [
+ :allow_maintainer_to_push,
+ :assignee_id,
+ :description,
+ :force_remove_source_branch,
+ :lock_version,
+ :milestone_id,
+ :source_branch,
+ :source_project_id,
+ :state_event,
+ :target_branch,
+ :target_project_id,
+ :task_num,
+ :title,
+ :discussion_locked,
+ label_ids: []
+ ]
+ end
+
+ def set_pipeline_variables
+ @pipelines = @merge_request.all_pipelines
+ @pipeline = @merge_request.head_pipeline
+ @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0
+ end
+end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
new file mode 100644
index 00000000000..366524b0783
--- /dev/null
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -0,0 +1,66 @@
+class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::ApplicationController
+ include IssuableActions
+
+ before_action :authorize_can_resolve_conflicts!
+
+ def show
+ respond_to do |format|
+ format.html do
+ labels
+ end
+
+ format.json do
+ if @conflicts_list.can_be_resolved_in_ui?
+ render json: @conflicts_list
+ elsif @merge_request.can_be_merged?
+ render json: {
+ message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+ type: 'error'
+ }
+ else
+ render json: {
+ message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+ type: 'error'
+ }
+ end
+ end
+ end
+ end
+
+ def conflict_for_path
+ return render_404 unless @conflicts_list.can_be_resolved_in_ui?
+
+ file = @conflicts_list.file_for_path(params[:old_path], params[:new_path])
+
+ return render_404 unless file
+
+ render json: file, full_content: true
+ end
+
+ def resolve_conflicts
+ return render_404 unless @conflicts_list.can_be_resolved_in_ui?
+
+ if @merge_request.can_be_merged?
+ render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+ return
+ end
+
+ begin
+ ::MergeRequests::Conflicts::ResolveService
+ .new(merge_request)
+ .execute(current_user, params)
+
+ flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+
+ render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) }
+ rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e
+ render status: :bad_request, json: { message: e.message }
+ end
+ end
+
+ def authorize_can_resolve_conflicts!
+ @conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request)
+
+ return render_404 unless @conflicts_list.can_be_resolved_by?(current_user)
+ end
+end
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
new file mode 100644
index 00000000000..a90030a8312
--- /dev/null
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -0,0 +1,133 @@
+class Projects::MergeRequests::CreationsController < Projects::MergeRequests::ApplicationController
+ include DiffForPath
+ include DiffHelper
+ include RendersCommits
+
+ skip_before_action :merge_request
+ before_action :whitelist_query_limiting, only: [:create]
+ before_action :authorize_create_merge_request!
+ before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
+ before_action :build_merge_request, except: [:create]
+
+ def new
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40934
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ define_new_vars
+ end
+ end
+
+ def create
+ @target_branches ||= []
+ @merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute
+
+ if @merge_request.valid?
+ redirect_to(merge_request_path(@merge_request))
+ else
+ @source_project = @merge_request.source_project
+ @target_project = @merge_request.target_project
+
+ define_new_vars
+ render action: "new"
+ end
+ end
+
+ def pipelines
+ @pipelines = @merge_request.all_pipelines
+
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipelines)
+ }
+ end
+
+ def diffs
+ @diffs = @merge_request.diffs(diff_options) if @merge_request.can_be_created
+
+ @diff_notes_disabled = true
+
+ @environment = @merge_request.environments_for(current_user).last
+
+ render json: { html: view_to_html_string('projects/merge_requests/creations/_diffs', diffs: @diffs, environment: @environment) }
+ end
+
+ def diff_for_path
+ @diffs = @merge_request.diffs(diff_options)
+ @diff_notes_disabled = true
+
+ render_diff_for_path(@diffs)
+ end
+
+ def branch_from
+ # This is always source
+ @source_project = @merge_request.nil? ? @project : @merge_request.source_project
+
+ if params[:ref].present?
+ @ref = params[:ref]
+ @commit = @repository.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
+ end
+
+ render layout: false
+ end
+
+ def branch_to
+ @target_project = selected_target_project
+
+ if @target_project && params[:ref].present?
+ @ref = params[:ref]
+ @commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
+ end
+
+ render layout: false
+ end
+
+ def update_branches
+ @target_project = selected_target_project
+ @target_branches = @target_project ? @target_project.repository.branch_names : []
+
+ render layout: false
+ end
+
+ private
+
+ def build_merge_request
+ params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
+ @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
+ end
+
+ def define_new_vars
+ @noteable = @merge_request
+
+ @target_branches = if @merge_request.target_project
+ @merge_request.target_project.repository.branch_names
+ else
+ []
+ end
+
+ @target_project = @merge_request.target_project
+ @source_project = @merge_request.source_project
+ @commits = prepare_commits_for_rendering(@merge_request.commits)
+ @commit = @merge_request.diff_head_commit
+
+ @labels = LabelsFinder.new(current_user, project_id: @project.id).execute
+
+ set_pipeline_variables
+ end
+
+ def selected_target_project
+ if @project.id.to_s == params[:target_project_id] || !@project.forked?
+ @project
+ elsif params[:target_project_id].present?
+ MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: @project)
+ .find_by(id: params[:target_project_id])
+ else
+ @project.forked_from_project
+ end
+ end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42384')
+ end
+end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
new file mode 100644
index 00000000000..fe8525a488c
--- /dev/null
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -0,0 +1,80 @@
+class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController
+ include DiffForPath
+ include DiffHelper
+ include RendersNotes
+
+ before_action :apply_diff_view_cookie!
+ before_action :commit
+ before_action :define_diff_vars
+ before_action :define_diff_comment_vars
+
+ def show
+ @environment = @merge_request.environments_for(current_user).last
+
+ render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") }
+ end
+
+ def diff_for_path
+ render_diff_for_path(@diffs)
+ end
+
+ private
+
+ def define_diff_vars
+ @merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc
+ @compare = commit || find_merge_request_diff_compare
+ return render_404 unless @compare
+
+ @diffs = @compare.diffs(diff_options)
+ end
+
+ def commit
+ return nil unless commit_id = params[:commit_id].presence
+ return nil unless @merge_request.all_commits.exists?(sha: commit_id)
+
+ @commit ||= @project.commit(commit_id)
+ end
+
+ def find_merge_request_diff_compare
+ @merge_request_diff =
+ if diff_id = params[:diff_id].presence
+ @merge_request.merge_request_diffs.viewable.find_by(id: diff_id)
+ else
+ @merge_request.merge_request_diff
+ end
+
+ return unless @merge_request_diff
+
+ @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
+
+ if @start_sha = params[:start_sha].presence
+ @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
+
+ unless @start_version
+ @start_sha = @merge_request_diff.head_commit_sha
+ @start_version = @merge_request_diff
+ end
+ end
+
+ if @start_sha
+ @merge_request_diff.compare_with(@start_sha)
+ else
+ @merge_request_diff
+ end
+ end
+
+ def define_diff_comment_vars
+ @new_diff_note_attrs = {
+ noteable_type: 'MergeRequest',
+ noteable_id: @merge_request.id,
+ commit_id: @commit&.id
+ }
+
+ @diff_notes_disabled = false
+
+ @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
+
+ @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs)
+ @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request)
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 879ff6d393e..54e7d81de6a 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -1,64 +1,20 @@
-class Projects::MergeRequestsController < Projects::ApplicationController
+class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationController
include ToggleSubscriptionAction
- include DiffForPath
- include DiffHelper
include IssuableActions
include RendersNotes
+ include RendersCommits
include ToggleAwardEmoji
include IssuableCollections
- before_action :check_merge_requests_available!
- before_action :merge_request, only: [
- :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge,
- :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, :commit_change_content
- ]
- 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 :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
- before_action :apply_diff_view_cookie!, only: [:new_diffs]
- before_action :build_merge_request, only: [:new, :new_diffs]
-
- # Allow read any merge_request
- before_action :authorize_read_merge_request!
-
- # Allow write(create) merge_request
- before_action :authorize_create_merge_request!, only: [:new, :create]
-
- # Allow modify merge_request
- before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
-
+ skip_before_action :merge_request, only: [:index, :bulk_update]
+ before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
+ before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
+ before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
-
- before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :conflict_for_path, :resolve_conflicts]
+ before_action :check_user_can_push_to_source_branch!, only: [:rebase]
def index
- @collection_type = "MergeRequest"
- @merge_requests = merge_requests_collection
- @merge_requests = @merge_requests.page(params[:page])
- @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request)
- @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
-
- if @merge_requests.out_of_range? && @merge_requests.total_pages != 0
- return redirect_to url_for(params.merge(page: @merge_requests.total_pages, only_path: true))
- end
-
- if params[:label_name].present?
- labels_params = { project_id: @project.id, title: params[:label_name] }
- @labels = LabelsFinder.new(current_user, labels_params).execute
- end
-
- @users = []
- if params[:assignee_id].present?
- assignee = User.find_by_id(params[:assignee_id])
- @users.push(assignee) if assignee
- end
-
- if params[:author_id].present?
- author = User.find_by_id(params[:author_id])
- @users.push(author) if author
- end
+ @merge_requests = @issuables
respond_to do |format|
format.html
@@ -72,16 +28,35 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
def show
+ validates_merge_request
+ close_merge_request_without_source_project
+ check_if_can_be_merged
+
+ # Return if the response has already been rendered
+ return if response_body
+
respond_to do |format|
format.html do
- define_discussion_vars
- define_show_vars
+ # Build a note object for comment form
+ @note = @project.notes.new(noteable: @merge_request)
+
+ @noteable = @merge_request
+ @commits_count = @merge_request.commits_count
+
+ @discussions = @merge_request.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
+
+ labels
+
+ set_pipeline_variables
+
+ render
end
format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: serializer.represent(@merge_request, basic: params[:basic])
+ render json: serializer.represent(@merge_request, serializer: params[:serializer])
end
format.patch do
@@ -98,198 +73,44 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
- def diffs
- apply_diff_view_cookie!
-
- respond_to do |format|
- format.html { define_discussion_vars }
- format.json do
- define_diff_vars
- define_diff_comment_vars
-
- @environment = @merge_request.environments_for(current_user).last
-
- render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") }
- end
- end
- end
-
- # With an ID param, loads the MR at that ID. Otherwise, accepts the same params as #new
- # and uses that (unsaved) MR.
- #
- def diff_for_path
- if params[:id]
- merge_request
- define_diff_vars
- define_diff_comment_vars
- else
- build_merge_request
- @compare = @merge_request
- @diffs = @compare.diffs(diff_options)
- @diff_notes_disabled = true
- end
-
- render_diff_for_path(@diffs)
- end
-
def commits
- respond_to do |format|
- format.html do
- define_discussion_vars
-
- render 'show'
- end
- format.json do
- # Get commits from repository
- # or from cache if already merged
- @commits = @merge_request.commits
- @note_counts = Note.where(commit_id: @commits.map(&:id))
- .group(:commit_id).count
-
- render json: { html: view_to_html_string('projects/merge_requests/show/_commits') }
- end
- end
- end
-
- def conflicts
- respond_to do |format|
- format.html { define_discussion_vars }
-
- format.json do
- if @conflicts_list.can_be_resolved_in_ui?
- render json: @conflicts_list
- elsif @merge_request.can_be_merged?
- render json: {
- message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
- type: 'error'
- }
- else
- render json: {
- message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
- type: 'error'
- }
- end
- end
- end
- end
-
- def conflict_for_path
- return render_404 unless @conflicts_list.can_be_resolved_in_ui?
-
- file = @conflicts_list.file_for_path(params[:old_path], params[:new_path])
-
- return render_404 unless file
-
- render json: file, full_content: true
- end
+ # Get commits from repository
+ # or from cache if already merged
+ @commits =
+ prepare_commits_for_rendering(@merge_request.commits.with_pipeline_status)
- def resolve_conflicts
- return render_404 unless @conflicts_list.can_be_resolved_in_ui?
-
- if @merge_request.can_be_merged?
- render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
- return
- end
-
- begin
- MergeRequests::Conflicts::ResolveService
- .new(merge_request)
- .execute(current_user, params)
-
- flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
-
- render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
- rescue Gitlab::Conflict::ResolutionError => e
- render status: :bad_request, json: { message: e.message }
- end
+ render json: { html: view_to_html_string('projects/merge_requests/_commits') }
end
def pipelines
@pipelines = @merge_request.all_pipelines
- respond_to do |format|
- format.html do
- define_discussion_vars
-
- render 'show'
- end
-
- format.json do
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .represent(@pipelines)
- end
- end
- end
-
- def new
- respond_to do |format|
- format.html { define_new_vars }
- format.json do
- define_pipelines_vars
-
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
-
- render json: {
- pipelines: PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .represent(@pipelines)
- }
- end
- end
- end
-
- def new_diffs
- respond_to do |format|
- format.html do
- define_new_vars
- @show_changes_tab = true
- render "new"
- end
- format.json do
- @diffs = if @merge_request.can_be_created
- @merge_request.diffs(diff_options)
- else
- []
- end
- @diff_notes_disabled = true
-
- @environment = @merge_request.environments_for(current_user).last
-
- render json: { html: view_to_html_string('projects/merge_requests/_new_diffs', diffs: @diffs, environment: @environment) }
- end
- end
- end
-
- def create
- @target_branches ||= []
- @merge_request = MergeRequests::CreateService.new(project, current_user, merge_request_params).execute
-
- if @merge_request.valid?
- redirect_to(merge_request_path(@merge_request))
- else
- @source_project = @merge_request.source_project
- @target_project = @merge_request.target_project
- render action: "new"
- end
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipelines),
+ count: {
+ all: @pipelines.count
+ }
+ }
end
def edit
- @source_project = @merge_request.source_project
- @target_project = @merge_request.target_project
- @target_branches = @merge_request.target_project.repository.branch_names
+ define_edit_vars
end
def update
- @merge_request = MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request)
+ @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request)
respond_to do |format|
format.html do
if @merge_request.valid?
redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request])
else
+ define_edit_vars
+
render :edit
end
end
@@ -299,15 +120,17 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
rescue ActiveRecord::StaleObjectError
+ define_edit_vars if request.format.html?
+
render_conflict_response
end
def remove_wip
- @merge_request = MergeRequests::UpdateService
+ @merge_request = ::MergeRequests::UpdateService
.new(project, current_user, wip_event: 'unwip')
.execute(@merge_request)
- render json: serializer.represent(@merge_request)
+ render json: serialize_widget(@merge_request)
end
def commit_change_content
@@ -319,11 +142,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return access_denied!
end
- MergeRequests::MergeWhenPipelineSucceedsService
+ ::MergeRequests::MergeWhenPipelineSucceedsService
.new(@project, current_user)
.cancel(@merge_request)
- render json: serializer.represent(@merge_request)
+ render json: serialize_widget(@merge_request)
end
def merge
@@ -338,53 +161,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
- def branch_from
- # This is always source
- @source_project = @merge_request.nil? ? @project : @merge_request.source_project
-
- if params[:ref].present?
- @ref = params[:ref]
- @commit = @repository.commit("refs/heads/#{@ref}")
- end
-
- render layout: false
- end
-
- def branch_to
- @target_project = selected_target_project
-
- if params[:ref].present?
- @ref = params[:ref]
- @commit = @target_project.commit("refs/heads/#{@ref}")
- end
-
- render layout: false
- end
-
- def update_branches
- @target_project = selected_target_project
- @target_branches = @target_project.repository.branch_names
-
- render layout: false
- end
-
def assign_related_issues
- result = MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute
+ result = ::MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute
- respond_to do |format|
- format.html do
- case result[:count]
- when 0
- flash[:error] = "Failed to assign you issues related to the merge request"
- when 1
- flash[:notice] = "1 issue has been assigned to you"
- else
- flash[:notice] = "#{result[:count]} issues have been assigned to you"
- end
-
- redirect_to(merge_request_path(@merge_request))
- end
+ case result[:count]
+ when 0
+ flash[:error] = "Failed to assign you issues related to the merge request"
+ when 1
+ flash[:notice] = "1 issue has been assigned to you"
+ else
+ flash[:notice] = "#{result[:count]} issues have been assigned to you"
end
+
+ redirect_to(merge_request_path(@merge_request))
end
def pipeline_status
@@ -398,26 +187,29 @@ class Projects::MergeRequestsController < Projects::ApplicationController
begin
@merge_request.environments_for(current_user).map do |environment|
project = environment.project
- deployment = environment.first_deployment_for(@merge_request.diff_head_commit)
+ deployment = environment.first_deployment_for(@merge_request.diff_head_sha)
stop_url =
if environment.stop_action? && can?(current_user, :create_deployment, environment)
- stop_namespace_project_environment_path(project.namespace, project, environment)
+ stop_project_environment_path(project, environment)
end
metrics_url =
if can?(current_user, :read_environment, environment) && environment.has_metrics?
- metrics_namespace_project_environment_deployment_path(environment.project.namespace,
- environment.project,
- environment,
- deployment)
+ metrics_project_environment_deployment_path(environment.project, environment, deployment)
+ end
+
+ metrics_monitoring_url =
+ if can?(current_user, :read_environment, environment)
+ environment_metrics_path(environment)
end
{
id: environment.id,
name: environment.name,
- url: namespace_project_environment_path(project.namespace, project, environment),
+ url: project_environment_path(project, environment),
metrics_url: metrics_url,
+ metrics_monitoring_url: metrics_monitoring_url,
stop_url: stop_url,
external_url: environment.external_url,
external_url_formatted: environment.formatted_external_url,
@@ -430,37 +222,18 @@ class Projects::MergeRequestsController < Projects::ApplicationController
render json: environments
end
- protected
+ def rebase
+ RebaseWorker.perform_async(@merge_request.id, current_user.id)
- def selected_target_project
- if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil?
- @project
- else
- @project.forked_project_link.forked_from_project
- end
+ render nothing: true, status: 200
end
- def merge_request
- @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
- end
+ protected
+
alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request
alias_method :awardable, :merge_request
- def authorize_update_merge_request!
- return render_404 unless can?(current_user, :update_merge_request, @merge_request)
- end
-
- def authorize_admin_merge_request!
- return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
- end
-
- def authorize_can_resolve_conflicts!
- @conflicts_list = MergeRequests::Conflicts::ListService.new(@merge_request)
-
- return render_404 unless @conflicts_list.can_be_resolved_by?(current_user)
- end
-
def validates_merge_request
# Show git not found page
# if there is no saved commits between source & target branch
@@ -470,133 +243,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
- def define_show_vars
- @noteable = @merge_request
- @commits_count = @merge_request.commits_count
-
- if @merge_request.locked_long_ago?
- @merge_request.unlock_mr
- @merge_request.close
- end
-
- labels
- define_pipelines_vars
- end
-
- # Discussion tab data is rendered on html responses of actions
- # :show, :diff, :commits, :builds. but not when request the data through AJAX
- def define_discussion_vars
- # Build a note object for comment form
- @note = @project.notes.new(noteable: @merge_request)
-
- @discussions = @merge_request.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
- end
-
- def define_diff_vars
- @merge_request_diff =
- if params[:diff_id]
- @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
- else
- @merge_request.merge_request_diff
- end
-
- @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
- @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
-
- if params[:start_sha].present?
- @start_sha = params[:start_sha]
- @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
-
- unless @start_version
- @start_sha = @merge_request_diff.head_commit_sha
- @start_version = @merge_request_diff
- end
- end
-
- @compare =
- if @start_sha
- @merge_request_diff.compare_with(@start_sha)
- else
- @merge_request_diff
- end
-
- @diffs = @compare.diffs(diff_options)
- end
-
- def define_diff_comment_vars
- @new_diff_note_attrs = {
- noteable_type: 'MergeRequest',
- noteable_id: @merge_request.id
- }
-
- @diff_notes_disabled = false
-
- @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
-
- @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs)
- @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
- end
-
- def define_pipelines_vars
- @pipelines = @merge_request.all_pipelines
- @pipeline = @merge_request.head_pipeline
- @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0
- end
-
- def define_new_vars
- @noteable = @merge_request
-
- @target_branches = if @merge_request.target_project
- @merge_request.target_project.repository.branch_names
- else
- []
- end
-
- @target_project = merge_request.target_project
- @source_project = merge_request.source_project
- @commits = @merge_request.compare_commits.reverse
- @commit = @merge_request.diff_head_commit
-
- @note_counts = Note.where(commit_id: @commits.map(&:id))
- .group(:commit_id).count
-
- @labels = LabelsFinder.new(current_user, project_id: @project.id).execute
-
- @show_changes_tab = params[:show_changes].present?
-
- define_pipelines_vars
- end
-
def invalid_mr
# Render special view for MR with removed target branch
render 'invalid'
end
- def merge_request_params
- params.require(:merge_request)
- .permit(merge_request_params_attributes)
- end
-
- def merge_request_params_attributes
- [
- :assignee_id,
- :description,
- :force_remove_source_branch,
- :lock_version,
- :milestone_id,
- :source_branch,
- :source_project_id,
- :state_event,
- :target_branch,
- :target_project_id,
- :task_num,
- :title,
-
- label_ids: []
- ]
- end
-
def merge_params
params.permit(merge_params_attributes)
end
@@ -605,22 +256,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
[:should_remove_source_branch, :commit_message]
end
- # Make sure merge requests created before 8.0
- # have head file in refs/merge-requests/
- def ensure_ref_fetched
- @merge_request.ensure_ref_fetched
- end
-
def merge_when_pipeline_succeeds_active?
params[:merge_when_pipeline_succeeds].present? &&
@merge_request.head_pipeline && @merge_request.head_pipeline.active?
end
- def build_merge_request
- params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
- @merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
- end
-
def close_merge_request_without_source_project
if !@merge_request.source_project && @merge_request.open?
@merge_request.close
@@ -645,31 +285,60 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@merge_request.update(merge_error: nil)
if params[:merge_when_pipeline_succeeds].present?
- return :failed unless @merge_request.head_pipeline
+ return :failed unless @merge_request.actual_head_pipeline
- if @merge_request.head_pipeline.active?
- MergeRequests::MergeWhenPipelineSucceedsService
+ if @merge_request.actual_head_pipeline.active?
+ ::MergeRequests::MergeWhenPipelineSucceedsService
.new(@project, current_user, merge_params)
.execute(@merge_request)
:merge_when_pipeline_succeeds
- elsif @merge_request.head_pipeline.success?
+ elsif @merge_request.actual_head_pipeline.success?
# This can be triggered when a user clicks the auto merge button while
# the tests finish at about the same time
- MergeWorker.perform_async(@merge_request.id, current_user.id, params)
+ @merge_request.merge_async(current_user.id, params)
:success
else
:failed
end
else
- MergeWorker.perform_async(@merge_request.id, current_user.id, params)
+ @merge_request.merge_async(current_user.id, params)
:success
end
end
+ def serialize_widget(merge_request)
+ serializer.represent(merge_request, serializer: 'widget')
+ end
+
def serializer
MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
end
+
+ def define_edit_vars
+ @source_project = @merge_request.source_project
+ @target_project = @merge_request.target_project
+ @target_branches = @merge_request.target_project.repository.branch_names
+ end
+
+ def finder_type
+ MergeRequestsFinder
+ end
+
+ def check_user_can_push_to_source_branch!
+ return access_denied! unless @merge_request.source_branch_exists?
+
+ access_check = ::Gitlab::UserAccess
+ .new(current_user, project: @merge_request.source_project)
+ .can_push_to_branch?(@merge_request.source_branch)
+
+ access_denied! unless access_check
+ end
+
+ def whitelist_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42441
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42438')
+ end
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 953b1e83e49..ff93147d00f 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -2,31 +2,27 @@ class Projects::MilestonesController < Projects::ApplicationController
include MilestoneActions
before_action :check_issuables_available!
- before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels]
+ before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
# Allow read any milestone
before_action :authorize_read_milestone!
# Allow admin milestone
- before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels]
+ before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote]
respond_to :html
def index
- @milestones =
- case params[:state]
- when 'all' then @project.milestones
- when 'closed' then @project.milestones.closed
- else @project.milestones.active
- end
-
@sort = params[:sort] || 'due_date_asc'
- @milestones = @milestones.sort(@sort)
+ @milestones = milestones.sort(@sort)
respond_to do |format|
format.html do
@project_namespace = @project.namespace.becomes(Namespace)
- @milestones = @milestones.includes(:project)
+ # We need to show group milestones in the JSON response
+ # so that people can filter by and assign group milestones,
+ # but we don't need to show them on the project milestones page itself.
+ @milestones = @milestones.for_projects
@milestones = @milestones.page(params[:page])
end
format.json do
@@ -45,14 +41,14 @@ class Projects::MilestonesController < Projects::ApplicationController
end
def show
+ @project_namespace = @project.namespace.becomes(Namespace)
end
def create
@milestone = Milestones::CreateService.new(project, current_user, milestone_params).execute
- if @milestone.save
- redirect_to namespace_project_milestone_path(@project.namespace,
- @project, @milestone)
+ if @milestone.valid?
+ redirect_to project_milestone_path(@project, @milestone)
else
render "new"
end
@@ -65,8 +61,7 @@ class Projects::MilestonesController < Projects::ApplicationController
format.js
format.html do
if @milestone.valid?
- redirect_to namespace_project_milestone_path(@project.namespace,
- @project, @milestone)
+ redirect_to project_milestone_path(@project, @milestone)
else
render :edit
end
@@ -74,19 +69,41 @@ class Projects::MilestonesController < Projects::ApplicationController
end
end
+ def promote
+ Milestones::PromoteService.new(project, current_user).execute(milestone)
+
+ flash[:notice] = "#{milestone.title} promoted to group milestone"
+ respond_to do |format|
+ format.html do
+ redirect_to project_milestones_path(project)
+ end
+ format.json do
+ render json: { url: project_milestones_path(project) }
+ end
+ end
+ rescue Milestones::PromoteService::PromoteMilestoneError => error
+ redirect_to milestone, alert: error.message
+ end
+
def destroy
return access_denied! unless can?(current_user, :admin_milestone, @project)
Milestones::DestroyService.new(project, current_user).execute(milestone)
respond_to do |format|
- format.html { redirect_to namespace_project_milestones_path, status: 302 }
+ format.html { redirect_to namespace_project_milestones_path, status: 303 }
format.js { head :ok }
end
end
protected
+ def milestones
+ @milestones ||= begin
+ MilestonesFinder.new(search_params).execute
+ end
+ end
+
def milestone
@milestone ||= @project.milestones.find_by!(iid: params[:id])
end
@@ -98,4 +115,12 @@ class Projects::MilestonesController < Projects::ApplicationController
def milestone_params
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
+
+ def search_params
+ if @project.group && can?(current_user, :read_group, @project.group)
+ group = @project.group
+ end
+
+ params.permit(:state).merge(project_ids: @project.id, group_ids: group&.id)
+ end
end
diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb
index 33a152ad34f..35fec229db7 100644
--- a/app/controllers/projects/network_controller.rb
+++ b/app/controllers/projects/network_controller.rb
@@ -2,14 +2,15 @@ class Projects::NetworkController < Projects::ApplicationController
include ExtractsPath
include ApplicationHelper
+ before_action :whitelist_query_limiting
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
before_action :assign_commit
def show
- @url = namespace_project_network_path(@project.namespace, @project, @ref, @options.merge(format: :json))
- @commit_url = namespace_project_commit_path(@project.namespace, @project, 'ae45ca32').gsub("ae45ca32", "%s")
+ @url = project_network_path(@project, @ref, @options.merge(format: :json))
+ @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s")
respond_to do |format|
format.html do
@@ -22,6 +23,8 @@ class Projects::NetworkController < Projects::ApplicationController
@graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref])
end
end
+
+ render
end
def assign_commit
@@ -30,4 +33,8 @@ class Projects::NetworkController < Projects::ApplicationController
@options[:extended_sha1] = params[:extended_sha1]
@commit = @repo.commit(@options[:extended_sha1])
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42333')
+ end
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 41a13f6f577..dd41b9648e8 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,7 +1,9 @@
class Projects::NotesController < Projects::ApplicationController
include NotesActions
+ include NotesHelper
include ToggleAwardEmoji
+ before_action :whitelist_query_limiting, only: [:create]
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
@@ -11,7 +13,7 @@ class Projects::NotesController < Projects::ApplicationController
# Controller actions are returned from AbstractController::Base and methods of parent classes are
# excluded in order to return only specific controller related methods.
# That is ok for the app (no :create method in ancestors)
- # but fails for tests because there is a :create method on FactoryGirl (one of the ancestors)
+ # but fails for tests because there is a :create method on FactoryBot (one of the ancestors)
#
# see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78
#
@@ -37,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion
- render json: {
- resolved_by: note.resolved_by.try(:name),
- discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
- }
+ if serialize_notes?
+ render_json_with_notes_serializer
+ else
+ render json: {
+ resolved_by: note.resolved_by.try(:name),
+ discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
+ }
+ end
end
def unresolve
@@ -50,23 +56,48 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion
- render json: {
- discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
- }
+ if serialize_notes?
+ render_json_with_notes_serializer
+ else
+ render json: {
+ discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
+ }
+ end
end
private
+ def render_json_with_notes_serializer
+ Notes::RenderService.new(current_user).execute([note], project)
+
+ render json: note_serializer.represent(note)
+ end
+
def note
@note ||= @project.notes.find(params[:id])
end
+
alias_method :awardable, :note
def finder_params
params.merge(last_fetched_at: last_fetched_at)
end
+ def authorize_admin_note!
+ return access_denied! unless can?(current_user, :admin_note, note)
+ end
+
def authorize_resolve_note!
return access_denied! unless can?(current_user, :resolve_note, note)
end
+
+ def authorize_create_note!
+ return unless noteable.lockable?
+
+ access_denied! unless can?(current_user, :create_note, noteable)
+ end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42383')
+ end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 28b383e69eb..cae6e2c40b8 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -15,10 +15,32 @@ class Projects::PagesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to namespace_project_pages_path(@project.namespace, @project),
+ redirect_to project_pages_path(@project),
status: 302,
notice: 'Pages were removed'
end
end
end
+
+ def update
+ result = Projects::UpdateService.new(@project, current_user, project_params).execute
+
+ respond_to do |format|
+ format.html do
+ if result[:status] == :success
+ flash[:notice] = 'Your changes have been saved'
+ else
+ flash[:alert] = 'Something went wrong on our end'
+ end
+
+ redirect_to project_pages_path(@project)
+ end
+ end
+ end
+
+ private
+
+ def project_params
+ params.require(:project).permit(:pages_https_only)
+ end
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index dbd011f6c5d..4856be61e88 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show]
- before_action :domain, only: [:show, :destroy]
+ before_action :domain, except: [:new, :create]
def show
end
@@ -12,22 +12,47 @@ class Projects::PagesDomainsController < Projects::ApplicationController
@domain = @project.pages_domains.new
end
+ def verify
+ result = VerifyPagesDomainService.new(@domain).execute
+
+ if result[:status] == :success
+ flash[:notice] = 'Successfully verified domain ownership'
+ else
+ flash[:alert] = 'Failed to verify domain ownership'
+ end
+
+ redirect_to project_pages_domain_path(@project, @domain)
+ end
+
+ def edit
+ end
+
def create
- @domain = @project.pages_domains.create(pages_domain_params)
+ @domain = @project.pages_domains.create(create_params)
if @domain.valid?
- redirect_to namespace_project_pages_path(@project.namespace, @project)
+ redirect_to project_pages_domain_path(@project, @domain)
else
render 'new'
end
end
+ def update
+ if @domain.update(update_params)
+ redirect_to project_pages_path(@project),
+ status: 302,
+ notice: 'Domain was updated'
+ else
+ render 'edit'
+ end
+ end
+
def destroy
@domain.destroy
respond_to do |format|
format.html do
- redirect_to namespace_project_pages_path(@project.namespace, @project),
+ redirect_to project_pages_path(@project),
status: 302,
notice: 'Domain was removed'
end
@@ -37,15 +62,15 @@ class Projects::PagesDomainsController < Projects::ApplicationController
private
- def pages_domain_params
- params.require(:pages_domain).permit(
- :certificate,
- :key,
- :domain
- )
+ def create_params
+ params.require(:pages_domain).permit(:key, :certificate, :domain)
+ end
+
+ def update_params
+ params.require(:pages_domain).permit(:key, :certificate)
end
def domain
- @domain ||= @project.pages_domains.find_by(domain: params[:id].to_s)
+ @domain ||= @project.pages_domains.find_by!(domain: params[:id].to_s)
end
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index ef4f083b98f..fa258f3d9af 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -1,10 +1,13 @@
class Projects::PipelineSchedulesController < Projects::ApplicationController
+ before_action :schedule, except: [:index, :new, :create]
+
+ before_action :play_rate_limit, only: [:play]
+ before_action :authorize_play_pipeline_schedule!, only: [:play]
before_action :authorize_read_pipeline_schedule!
- before_action :authorize_create_pipeline_schedule!, only: [:new, :create, :edit, :take_ownership, :update]
+ before_action :authorize_create_pipeline_schedule!, only: [:new, :create]
+ before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
- before_action :schedule, only: [:edit, :update, :destroy, :take_ownership]
-
def index
@scope = params[:scope]
@all_schedules = PipelineSchedulesFinder.new(@project).execute
@@ -33,12 +36,24 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def update
if schedule.update(schedule_params)
- redirect_to namespace_project_pipeline_schedules_path(@project.namespace.becomes(Namespace), @project)
+ redirect_to project_pipeline_schedules_path(@project)
else
render :edit
end
end
+ def play
+ job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id)
+
+ if job_id
+ flash[:notice] = "Successfully scheduled a pipeline to run. Go to the <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details.".html_safe
+ else
+ flash[:alert] = 'Unable to schedule a pipeline to run immediately'
+ end
+
+ redirect_to pipeline_schedules_path(@project)
+ end
+
def take_ownership
if schedule.update(owner: current_user)
redirect_to pipeline_schedules_path(@project)
@@ -52,19 +67,43 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
redirect_to pipeline_schedules_path(@project), status: 302
else
redirect_to pipeline_schedules_path(@project),
- status: 302,
+ status: :forbidden,
alert: _("Failed to remove the pipeline schedule")
end
end
private
+ def play_rate_limit
+ return unless current_user
+
+ limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule)
+
+ return unless limiter.throttled?([current_user, schedule], 1)
+
+ flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.'
+ redirect_to pipeline_schedules_path(@project)
+ end
+
def schedule
@schedule ||= project.pipeline_schedules.find(params[:id])
end
def schedule_params
params.require(:schedule)
- .permit(:description, :cron, :cron_timezone, :ref, :active)
+ .permit(:description, :cron, :cron_timezone, :ref, :active,
+ variables_attributes: [:id, :key, :secret_value, :_destroy] )
+ end
+
+ def authorize_play_pipeline_schedule!
+ return access_denied! unless can?(current_user, :play_pipeline_schedule, schedule)
+ end
+
+ def authorize_update_pipeline_schedule!
+ return access_denied! unless can?(current_user, :update_pipeline_schedule, schedule)
+ end
+
+ def authorize_admin_pipeline_schedule!
+ return access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule)
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 303e91a8dc0..78d109cf33e 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -1,4 +1,5 @@
class Projects::PipelinesController < Projects::ApplicationController
+ before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :commit, only: [:show, :builds, :failures]
before_action :authorize_read_pipeline!
@@ -29,6 +30,8 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipelines_count = PipelinesFinder
.new(project).execute.count
+ @pipelines.map(&:commit) # List commits for batch loading
+
respond_to do |format|
format.html
format.json do
@@ -60,7 +63,7 @@ class Projects::PipelinesController < Projects::ApplicationController
.execute(:web, ignore_skip_ci: true, save_on_errors: false)
if @pipeline.persisted?
- redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
+ redirect_to project_pipeline_path(project, @pipeline)
else
render 'new'
end
@@ -111,7 +114,7 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ redirect_back_or_default default: project_pipelines_path(project)
end
format.json { head :no_content }
@@ -123,7 +126,7 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ redirect_back_or_default default: project_pipelines_path(project)
end
format.json { head :no_content }
@@ -132,10 +135,10 @@ class Projects::PipelinesController < Projects::ApplicationController
def charts
@charts = {}
- @charts[:week] = Ci::Charts::WeekChart.new(project)
- @charts[:month] = Ci::Charts::MonthChart.new(project)
- @charts[:year] = Ci::Charts::YearChart.new(project)
- @charts[:pipeline_times] = Ci::Charts::PipelineTime.new(project)
+ @charts[:week] = Gitlab::Ci::Charts::WeekChart.new(project)
+ @charts[:month] = Gitlab::Ci::Charts::MonthChart.new(project)
+ @charts[:year] = Gitlab::Ci::Charts::YearChart.new(project)
+ @charts[:pipeline_times] = Gitlab::Ci::Charts::PipelineTime.new(project)
@counts = {}
@counts[:total] = @project.pipelines.count(:all)
@@ -164,4 +167,9 @@ class Projects::PipelinesController < Projects::ApplicationController
def commit
@commit ||= @pipeline.commit
end
+
+ def whitelist_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42343
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42339')
+ end
end
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index 38a47651000..557671ab186 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -2,28 +2,43 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
def show
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project, params: params)
+ redirect_to project_settings_ci_cd_path(@project, params: params)
end
def update
- if @project.update_attributes(update_params)
- flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
- else
- render 'show'
+ Projects::UpdateService.new(project, current_user, update_params).tap do |service|
+ if service.execute
+ flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
+
+ run_autodevops_pipeline(service)
+
+ redirect_to project_settings_ci_cd_path(@project)
+ else
+ render 'show'
+ end
end
end
private
- def create_params
- params.require(:pipeline).permit(:ref)
+ def run_autodevops_pipeline(service)
+ return unless service.run_auto_devops_pipeline?
+
+ if @project.empty_repo?
+ flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
+ return
+ end
+
+ CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
+ flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
end
def update_params
params.require(:project).permit(
- :runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex,
- :public_builds, :auto_cancel_pending_pipelines
+ :runners_token, :builds_enabled, :build_allow_git_fetch,
+ :build_timeout_in_minutes, :build_coverage_regex, :public_builds,
+ :auto_cancel_pending_pipelines, :ci_config_path,
+ auto_devops_attributes: [:id, :domain, :enabled]
)
end
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index d2d26738582..e9b4679f94c 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -1,35 +1,33 @@
class Projects::ProjectMembersController < Projects::ApplicationController
include MembershipActions
+ include MembersPresentation
include SortingHelper
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index
- sort = params[:sort].presence || sort_value_name
- redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort)
- end
-
- def update
- @project_member = @project.project_members.find(params[:id])
-
- return render_403 unless can?(current_user, :update_project_member, @project_member)
+ @sort = params[:sort].presence || sort_value_name
+ @group_links = @project.project_group_links
- @project_member.update_attributes(member_params)
- end
+ @skip_groups = @group_links.pluck(:group_id)
+ @skip_groups << @project.namespace_id unless @project.personal?
+ @skip_groups += @project.group.ancestors.pluck(:id) if @project.group
- def resend_invite
- redirect_path = namespace_project_settings_members_path(@project.namespace, @project)
+ @project_members = MembersFinder.new(@project, current_user).execute
- @project_member = @project.project_members.find(params[:id])
+ if params[:search].present?
+ @project_members = @project_members.joins(:user).merge(User.search(params[:search]))
+ @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
+ end
- if @project_member.invite?
- @project_member.resend_invite
+ @project_members = present_members(@project_members.sort(@sort).page(params[:page]))
+ @requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user))
+ @project_member = @project.project_members.new
+ end
- redirect_to redirect_path, notice: 'The invitation was successfully resent.'
- else
- redirect_to redirect_path, alert: 'The invitation has already been accepted.'
- end
+ def import
+ @projects = current_user.authorized_projects.order_id_desc
end
def apply_import
@@ -42,16 +40,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController
return render_404
end
- redirect_to(namespace_project_settings_members_path(project.namespace, project),
+ redirect_to(project_project_members_path(project),
notice: notice)
end
- protected
-
- def member_params
- params.require(:project_member).permit(:user_id, :access_level, :expires_at)
- end
-
# MembershipActions concern
alias_method :membershipable, :project
end
diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
new file mode 100644
index 00000000000..1dd886409a5
--- /dev/null
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -0,0 +1,32 @@
+module Projects
+ module Prometheus
+ class MetricsController < Projects::ApplicationController
+ before_action :authorize_admin_project!
+ before_action :require_prometheus_metrics!
+
+ def active_common
+ respond_to do |format|
+ format.json do
+ matched_metrics = prometheus_adapter.query(:matched_metrics) || {}
+
+ if matched_metrics.any?
+ render json: matched_metrics
+ else
+ head :no_content
+ end
+ end
+ end
+ end
+
+ private
+
+ def prometheus_adapter
+ @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter
+ end
+
+ def require_prometheus_metrics!
+ render_404 unless prometheus_adapter.can_query?
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/prometheus_controller.rb b/app/controllers/projects/prometheus_controller.rb
deleted file mode 100644
index 507468d7102..00000000000
--- a/app/controllers/projects/prometheus_controller.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-class Projects::PrometheusController < Projects::ApplicationController
- before_action :authorize_read_project!
- before_action :require_prometheus_metrics!
-
- def active_metrics
- respond_to do |format|
- format.json do
- matched_metrics = project.prometheus_service.matched_metrics || {}
-
- if matched_metrics.any?
- render json: matched_metrics
- else
- head :no_content
- end
- end
- end
- end
-
- private
-
- def require_prometheus_metrics!
- render_404 unless project.prometheus_service.present?
- end
-end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 2a0b58fae7c..2376f469213 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -13,21 +13,21 @@ class Projects::RefsController < Projects::ApplicationController
new_path =
case params[:destination]
when "tree"
- namespace_project_tree_path(@project.namespace, @project, @id)
+ project_tree_path(@project, @id)
when "blob"
- namespace_project_blob_path(@project.namespace, @project, @id)
+ project_blob_path(@project, @id)
when "graph"
- namespace_project_network_path(@project.namespace, @project, @id, @options)
+ project_network_path(@project, @id, @options)
when "graphs"
- namespace_project_graph_path(@project.namespace, @project, @id)
+ project_graph_path(@project, @id)
when "find_file"
- namespace_project_find_file_path(@project.namespace, @project, @id)
+ project_find_file_path(@project, @id)
when "graphs_commits"
- commits_namespace_project_graph_path(@project.namespace, @project, @id)
+ commits_project_graph_path(@project, @id)
when "badges"
- namespace_project_pipelines_settings_path(@project.namespace, @project, ref: @id)
+ project_pipelines_settings_path(@project, ref: @id)
else
- namespace_project_commits_path(@project.namespace, @project, @id)
+ project_commits_path(@project, @id)
end
redirect_to new_path
@@ -51,22 +51,33 @@ class Projects::RefsController < Projects::ApplicationController
contents.push(*tree.blobs)
contents.push(*tree.submodules)
- @logs = contents[@offset, @limit].to_a.map do |content|
- file = @path ? File.join(@path, content.name) : content.name
- last_commit = @repo.last_commit_for_path(@commit.id, file)
- {
- file_name: content.name,
- commit: last_commit
- }
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37433
+ @logs = Gitlab::GitalyClient.allow_n_plus_1_calls do
+ contents[@offset, @limit].to_a.map do |content|
+ file = @path ? File.join(@path, content.name) : content.name
+ last_commit = @repo.last_commit_for_path(@commit.id, file)
+ commit_path = project_commit_path(@project, last_commit) if last_commit
+ {
+ file_name: content.name,
+ commit: last_commit,
+ type: content.type,
+ commit_path: commit_path
+ }
+ end
end
offset = (@offset + @limit)
if contents.size > offset
- @more_log_url = logs_file_namespace_project_ref_path(@project.namespace, @project, @ref, @path || '', offset: offset)
+ @more_log_url = logs_file_project_ref_path(@project, @ref, @path || '', offset: offset)
end
respond_to do |format|
format.html { render_404 }
+ format.json do
+ response.headers["More-Logs-Url"] = @more_log_url
+
+ render json: @logs
+ end
format.js
end
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 98e78585be8..32c0fc6d14a 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -6,17 +6,26 @@ module Projects
def index
@images = project.container_repositories
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: ContainerRepositoriesSerializer
+ .new(project: project, current_user: current_user)
+ .represent(@images)
+ end
+ end
end
def destroy
if image.destroy
- redirect_to project_container_registry_path(@project),
- status: 302,
- notice: 'Image repository has been removed successfully!'
+ respond_to do |format|
+ format.json { head :no_content }
+ end
else
- redirect_to project_container_registry_path(@project),
- status: 302,
- alert: 'Failed to remove image repository!'
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
end
end
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index 5050dba3aab..e602aa3f393 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -3,20 +3,35 @@ module Projects
class TagsController < ::Projects::Registry::ApplicationController
before_action :authorize_update_container_image!, only: [:destroy]
+ def index
+ respond_to do |format|
+ format.json do
+ render json: ContainerTagsSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(tags)
+ end
+ end
+ end
+
def destroy
if tag.delete
- redirect_to project_container_registry_path(@project),
- status: 302,
- notice: 'Registry tag has been removed successfully!'
+ respond_to do |format|
+ format.json { head :no_content }
+ end
else
- redirect_to project_container_registry_path(@project),
- status: 302,
- alert: 'Failed to remove registry tag!'
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
end
end
private
+ def tags
+ Kaminari::PaginatableArray.new(image.tags, limit: 15)
+ end
+
def image
@image ||= project.container_repositories
.find(params[:repository_id])
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 2c097cb4d8d..3e0a530fdb9 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -19,7 +19,7 @@ class Projects::ReleasesController < Projects::ApplicationController
release.destroy
end
- redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name)
+ redirect_to project_tag_path(@project, @tag.name)
end
private
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 160e632648a..c950d0f7001 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -5,7 +5,7 @@ class Projects::RunnersController < Projects::ApplicationController
layout 'project_settings'
def index
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def edit
@@ -29,17 +29,17 @@ class Projects::RunnersController < Projects::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
+ redirect_to runners_path(@project), notice: 'Runner was successfully updated.'
else
- redirect_to runner_path(@runner), alert: 'Runner was not updated.'
+ redirect_to runners_path(@project), alert: 'Runner was not updated.'
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
+ redirect_to runners_path(@project), notice: 'Runner was successfully updated.'
else
- redirect_to runner_path(@runner), alert: 'Runner was not updated.'
+ redirect_to runners_path(@project), alert: 'Runner was not updated.'
end
end
@@ -49,7 +49,7 @@ class Projects::RunnersController < Projects::ApplicationController
def toggle_shared_runners
project.toggle!(:shared_runners_enabled)
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
protected
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 704f8cc8a79..f14cb5f6a9f 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -3,8 +3,8 @@ 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]
+ before_action :ensure_service_enabled
+ before_action :service
respond_to :html
@@ -14,34 +14,40 @@ class Projects::ServicesController < Projects::ApplicationController
end
def update
+ @service.attributes = service_params[:service]
+
if @service.save(context: :manual_change)
- redirect_to(namespace_project_settings_integrations_path(@project.namespace, @project), notice: success_message)
+ redirect_to(project_settings_integrations_path(@project), notice: success_message)
else
render 'edit'
end
end
def test
- message = {}
-
if @service.can_test?
+ render json: service_test_response, status: :ok
+ else
+ render json: {}, status: :not_found
+ end
+ end
+
+ private
+
+ def service_test_response
+ if @service.update_attributes(service_params[:service])
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 }
+ if outcome[:success]
+ {}
+ else
+ { error: true, message: 'Test failed.', service_response: outcome[:result].to_s }
end
-
- status = :ok
else
- status = :not_found
+ { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') }
end
-
- render json: message, status: status
end
- private
-
def success_message
if @service.active?
"#{@service.title} activated."
@@ -50,11 +56,11 @@ class Projects::ServicesController < Projects::ApplicationController
end
end
- def update_service
- @service.assign_attributes(service_params[:service])
- end
-
def service
@service ||= @project.find_or_initialize_service(params[:id])
end
+
+ def ensure_service_enabled
+ render_404 unless service
+ end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 24fe78bc1bd..259809f3429 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -8,6 +8,19 @@ module Projects
define_secret_variables
define_triggers_variables
define_badges_variables
+ define_auto_devops_variables
+ end
+
+ def reset_cache
+ if ResetProjectCacheService.new(@project, current_user).execute
+ respond_to do |format|
+ format.json { head :ok }
+ end
+ else
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
+ end
end
private
@@ -21,7 +34,10 @@ module Projects
end
def define_secret_variables
- @variable = Ci::Variable.new
+ @variable = Ci::Variable.new(project: project)
+ .present(current_user: current_user)
+ @variables = project.variables.order_key_asc
+ .map { |variable| variable.present(current_user: current_user) }
end
def define_triggers_variables
@@ -32,13 +48,17 @@ module Projects
def define_badges_variables
@ref = params[:ref] || @project.default_branch || 'master'
- @badges = [Gitlab::Badge::Build::Status,
+ @badges = [Gitlab::Badge::Pipeline::Status,
Gitlab::Badge::Coverage::Report]
@badges.map! do |badge|
badge.new(@project, @ref).metadata
end
end
+
+ def define_auto_devops_variables
+ @auto_devops = @project.auto_devops || ProjectAutoDevops.new
+ end
end
end
end
diff --git a/app/controllers/projects/settings/members_controller.rb b/app/controllers/projects/settings/members_controller.rb
deleted file mode 100644
index 54f9dceddef..00000000000
--- a/app/controllers/projects/settings/members_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-module Projects
- module Settings
- class MembersController < Projects::ApplicationController
- include SortingHelper
-
- def show
- @sort = params[:sort].presence || sort_value_name
- @group_links = @project.project_group_links
-
- @skip_groups = @group_links.pluck(:group_id)
- @skip_groups << @project.namespace_id unless @project.personal?
- @skip_groups += @project.group.ancestors.pluck(:id) if @project.group
-
- @project_members = MembersFinder.new(@project, current_user).execute
-
- if params[:search].present?
- @project_members = @project_members.joins(:user).merge(User.search(params[:search]))
- @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
- end
-
- @project_members = @project_members.sort(@sort).page(params[:page])
- @requesters = AccessRequestsFinder.new(@project).execute(current_user)
- @project_member = @project.project_members.new
- end
- end
- end
-end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 44de8a49593..d06d18c498b 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -21,14 +21,14 @@ module Projects
def access_levels_options
{
- create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel),
- push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel),
- merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel)
+ create_access_levels: levels_for_dropdown,
+ push_access_levels: levels_for_dropdown,
+ merge_access_levels: levels_for_dropdown
}
end
- def levels_for_dropdown(access_level_type)
- roles = access_level_type.human_access_levels.map do |id, text|
+ def levels_for_dropdown
+ roles = ProtectedRefAccess::HUMAN_ACCESS_LEVELS.map do |id, text|
{ id: id, text: text, before_divider: true }
end
{ roles: roles }
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 98dd307bd9d..7c19aa7bb23 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -30,7 +30,7 @@ class Projects::SnippetsController < Projects::ApplicationController
).execute
@snippets = @snippets.page(params[:page])
if @snippets.out_of_range? && @snippets.total_pages != 0
- redirect_to namespace_project_snippets_path(page: @snippets.total_pages)
+ redirect_to project_snippets_path(@project, page: @snippets.total_pages)
end
end
@@ -64,7 +64,7 @@ class Projects::SnippetsController < Projects::ApplicationController
@noteable = @snippet
@discussions = @snippet.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
render 'show'
end
@@ -79,7 +79,7 @@ class Projects::SnippetsController < Projects::ApplicationController
@snippet.destroy
- redirect_to namespace_project_snippets_path(@project.namespace, @project), status: 302
+ redirect_to project_snippets_path(@project), status: 302
end
protected
@@ -90,6 +90,10 @@ class Projects::SnippetsController < Projects::ApplicationController
alias_method :awardable, :snippet
alias_method :spammable, :snippet
+ def spammable_path
+ project_snippet_path(@project, @snippet)
+ end
+
def authorize_read_project_snippet!
return render_404 unless can?(current_user, :read_project_snippet, @snippet)
end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index ebc9f4edab4..b62d7d9b7c5 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -35,7 +35,7 @@ class Projects::TagsController < Projects::ApplicationController
if result[:status] == :success
@tag = result[:tag]
- redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name)
+ redirect_to project_tag_path(@project, @tag.name)
else
@error = result[:message]
@message = params[:message]
@@ -50,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), status: 303
+ redirect_to project_tags_path(@project), status: 303
end
format.js
@@ -58,7 +58,7 @@ class Projects::TagsController < Projects::ApplicationController
@error = result[:message]
format.html do
- redirect_to namespace_project_tags_path(@project.namespace, @project),
+ redirect_to project_tags_path(@project),
alert: @error, status: 303
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 266a15c1cf9..ee9b5458282 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -16,7 +16,7 @@ class Projects::TreeController < Projects::ApplicationController
if tree.entries.empty?
if @repository.blob_at(@commit.id, @path)
return redirect_to(
- namespace_project_blob_path(@project.namespace, @project,
+ project_blob_path(@project,
File.join(@ref, @path))
)
elsif @path.present?
@@ -24,12 +24,25 @@ 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
- format.js { no_cache_headers }
+ format.html do
+ lfs_blob_ids
+ @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
+ end
+
+ format.js do
+ # Disable cache so browser history works
+ no_cache_headers
+ end
+
+ format.json do
+ page_title @path.presence || _("Files"), @ref, @project.full_name
+
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree)
+ end
+ end
end
end
@@ -37,8 +50,8 @@ class Projects::TreeController < Projects::ApplicationController
return render_404 unless @commit_params.values.all?
create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.",
- success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@branch_name, @dir_name)),
- failure_path: namespace_project_tree_path(@project.namespace, @project, @ref))
+ success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)),
+ failure_path: project_tree_path(@project, @ref))
end
private
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index e86adddd77f..e04145dd0b3 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -7,7 +7,7 @@ class Projects::TriggersController < Projects::ApplicationController
layout 'project_settings'
def index
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def create
@@ -19,7 +19,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = 'You could not create a new trigger.'
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def take_ownership
@@ -29,7 +29,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = 'You could not take ownership of trigger.'
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def edit
@@ -37,7 +37,7 @@ class Projects::TriggersController < Projects::ApplicationController
def update
if trigger.update(trigger_params)
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.'
+ redirect_to project_settings_ci_cd_path(@project), notice: 'Trigger was successfully updated.'
else
render action: "edit"
end
@@ -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), status: 302
+ redirect_to project_settings_ci_cd_path(@project), status: 302
end
private
@@ -69,8 +69,7 @@ class Projects::TriggersController < Projects::ApplicationController
def trigger_params
params.require(:trigger).permit(
- :description,
- trigger_schedule_attributes: [:id, :active, :cron, :cron_timezone, :ref]
+ :description
)
end
end
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 6966a7c5fee..f5cf089ad98 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -1,6 +1,7 @@
class Projects::UploadsController < Projects::ApplicationController
include UploadsActions
+ # These will kick you out if you don't have access.
skip_before_action :project, :repository,
if: -> { action_name == 'show' && image_or_video? }
@@ -8,32 +9,20 @@ class Projects::UploadsController < Projects::ApplicationController
private
- def uploader
- return @uploader if defined?(@uploader)
-
- namespace = params[:namespace_id]
- id = params[:project_id]
-
- file_project = Project.find_by_full_path("#{namespace}/#{id}")
-
- if file_project.nil?
- @uploader = nil
- return
- end
-
- @uploader = FileUploader.new(file_project, params[:secret])
- @uploader.retrieve_from_store!(params[:filename])
-
- @uploader
- end
-
- def image_or_video?
- uploader && uploader.file.exists? && uploader.image_or_video?
+ def upload_model_class
+ Project
end
def uploader_class
FileUploader
end
- alias_method :model, :project
+ def find_model
+ return @project if @project
+
+ namespace = params[:namespace_id]
+ id = params[:project_id]
+
+ Project.find_by_full_path("#{namespace}/#{id}")
+ end
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index 50e25a00f03..517d0b026c2 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -1,50 +1,41 @@
class Projects::VariablesController < Projects::ApplicationController
before_action :authorize_admin_build!
- layout 'project_settings'
-
- def index
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
- end
-
def show
- @variable = @project.variables.find(params[:id])
+ respond_to do |format|
+ format.json do
+ render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
+ end
+ end
end
def update
- @variable = @project.variables.find(params[:id])
-
- if @variable.update_attributes(project_params)
- redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Variable was successfully updated.'
+ if @project.update(variables_params)
+ respond_to do |format|
+ format.json { return render_variables }
+ end
else
- render action: "show"
+ respond_to do |format|
+ format.json { render_error }
+ end
end
end
- def create
- @variable = Ci::Variable.new(project_params)
+ private
- if @variable.valid? && @project.variables << @variable
- flash[:notice] = 'Variables were successfully updated.'
- redirect_to namespace_project_settings_ci_cd_path(project.namespace, project)
- else
- render "show"
- end
+ def render_variables
+ render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
end
- def destroy
- @key = @project.variables.find(params[:id])
- @key.destroy
-
- redirect_to namespace_project_settings_ci_cd_path(project.namespace, project),
- status: 302,
- notice: 'Variable was successfully removed.'
+ def render_error
+ render status: :bad_request, json: @project.errors.full_messages
end
- private
+ def variables_params
+ params.permit(variables_attributes: [*variable_params_attributes])
+ end
- def project_params
- params.require(:variable)
- .permit([:id, :key, :value, :protected, :_destroy])
+ def variable_params_attributes
+ %i[id key secret_value protected _destroy]
end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index e54b90b8d52..c4930d3d18d 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -1,4 +1,6 @@
class Projects::WikisController < Projects::ApplicationController
+ include PreviewMarkdown
+
before_action :authorize_read_wiki!
before_action :authorize_create_wiki!, only: [:edit, :create, :history]
before_action :authorize_admin_wiki!, only: :destroy
@@ -18,18 +20,15 @@ class Projects::WikisController < Projects::ApplicationController
response.headers['Content-Security-Policy'] = "default-src 'none'"
response.headers['X-Content-Security-Policy'] = "default-src 'none'"
- if file.on_disk?
- send_file file.on_disk_path, disposition: 'inline'
- else
- send_data(
- file.raw_data,
- type: file.mime_type,
- disposition: 'inline',
- filename: file.name
- )
- end
+ send_data(
+ file.raw_data,
+ type: file.mime_type,
+ disposition: 'inline',
+ filename: file.name
+ )
else
return render('empty') unless can?(current_user, :create_wiki, @project)
+
@page = WikiPage.new(@project_wiki)
@page.title = params[:id]
@@ -49,12 +48,15 @@ class Projects::WikisController < Projects::ApplicationController
if @page.valid?
redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, @page),
+ project_wiki_path(@project, @page),
notice: 'Wiki was successfully updated.'
)
else
render 'edit'
end
+ rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e
+ @error = e
+ render 'edit'
end
def create
@@ -62,7 +64,7 @@ class Projects::WikisController < Projects::ApplicationController
if @page.persisted?
redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, @page),
+ project_wiki_path(@project, @page),
notice: 'Wiki was successfully updated.'
)
else
@@ -73,9 +75,13 @@ class Projects::WikisController < Projects::ApplicationController
def history
@page = @project_wiki.find_page(params[:id])
- unless @page
+ if @page
+ @page_versions = Kaminari.paginate_array(@page.versions(page: params[:page].to_i),
+ total_count: @page.count_versions)
+ .page(params[:page])
+ else
redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, :home),
+ project_wiki_path(@project, :home),
notice: "Page not found"
)
end
@@ -85,7 +91,7 @@ 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),
+ redirect_to project_wiki_path(@project, :home),
status: 302,
notice: "Page was successfully deleted"
end
@@ -93,17 +99,6 @@ class Projects::WikisController < Projects::ApplicationController
def git_access
end
- def preview_markdown
- result = PreviewMarkdownService.new(@project, current_user, params).execute
-
- render json: {
- body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]),
- references: {
- users: result[:users]
- }
- }
- end
-
private
def load_project_wiki
@@ -111,7 +106,7 @@ class Projects::WikisController < Projects::ApplicationController
# Call #wiki to make sure the Wiki Repo is initialized
@project_wiki.wiki
- @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages.first(15))
+ @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages(limit: 15))
rescue ProjectWiki::CouldNotCreateWikiError
flash[:notice] = "Could not create Wiki Repository at this time. Please try again later."
redirect_to project_path(@project)
@@ -119,6 +114,6 @@ class Projects::WikisController < Projects::ApplicationController
end
def wiki_params
- params.require(:wiki).permit(:title, :content, :format, :message)
+ params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 5480814874b..ee197c75764 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -1,12 +1,17 @@
class ProjectsController < Projects::ApplicationController
include IssuableCollections
include ExtractsPath
+ include PreviewMarkdown
+ before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
+ before_action :redirect_git_extension, only: [:show]
before_action :project, except: [:index, :new, :create]
before_action :repository, except: [:index, :new, :create]
before_action :assign_ref_vars, only: [:show], if: :repo_exists?
before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
+ before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?]
+ before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
# Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
@@ -19,7 +24,10 @@ class ProjectsController < Projects::ApplicationController
end
def new
- @project = Project.new
+ namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
+ return access_denied! if namespace && !can?(current_user, :create_projects, namespace)
+
+ @project = Project.new(namespace_id: namespace&.id)
end
def edit
@@ -33,11 +41,11 @@ class ProjectsController < Projects::ApplicationController
cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) }
redirect_to(
- project_path(@project),
+ project_path(@project, custom_import_params),
notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
)
else
- render 'new'
+ render 'new', locals: { active_tab: active_new_project_tab }
end
end
@@ -50,10 +58,13 @@ class ProjectsController < Projects::ApplicationController
respond_to do |format|
if result[:status] == :success
flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
+
format.html do
redirect_to(edit_project_path(@project))
end
else
+ flash[:alert] = result[:message]
+
format.html { render 'edit' }
end
@@ -92,17 +103,19 @@ class ProjectsController < Projects::ApplicationController
def show
if @project.import_in_progress?
- redirect_to namespace_project_import_path(@project.namespace, @project)
+ redirect_to project_import_path(@project, custom_import_params)
return
end
if @project.pending_delete?
- flash[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name }
+ flash.now[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name }
end
respond_to do |format|
format.html do
@notification_setting = current_user.notification_settings_for(@project) if current_user
+ @project = @project.present(current_user: current_user)
+
render_landing_page
end
@@ -117,18 +130,18 @@ 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}' will be deleted.") % { project_name: @project.name_with_namespace }
+ flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex
redirect_to edit_project_path(@project), status: 302, alert: ex.message
end
- def new_issue_address
+ def new_issuable_address
return render_404 unless Gitlab::IncomingEmail.supports_issue_creation?
current_user.reset_incoming_email_token!
- render json: { new_issue_address: @project.new_issue_address(current_user) }
+ render json: { new_address: @project.new_issuable_address(current_user, params[:issuable_type]) }
end
def archive
@@ -193,6 +206,7 @@ class ProjectsController < Projects::ApplicationController
else
flash[:alert] = _("Project export could not be deleted.")
end
+
redirect_to(edit_project_path(@project))
end
@@ -217,39 +231,40 @@ class ProjectsController < Projects::ApplicationController
end
def refs
- branches = BranchesFinder.new(@repository, params).execute.map(&:name)
+ find_refs = params['find']
- options = {
- s_('RefSwitcher|Branches') => branches.take(100)
- }
+ find_branches = true
+ find_tags = true
+ find_commits = true
+
+ unless find_refs.nil?
+ find_branches = find_refs.include?('branches')
+ find_tags = find_refs.include?('tags')
+ find_commits = find_refs.include?('commits')
+ end
- unless @repository.tag_count.zero?
- tags = TagsFinder.new(@repository, params).execute.map(&:name)
+ options = {}
- options[s_('RefSwitcher|Tags')] = tags.take(100)
+ if find_branches
+ branches = BranchesFinder.new(@repository, params).execute.take(100).map(&:name)
+ options[s_('RefSwitcher|Branches')] = branches
+ end
+
+ if find_tags && @repository.tag_count.nonzero?
+ tags = TagsFinder.new(@repository, params).execute.take(100).map(&:name)
+
+ options[s_('RefSwitcher|Tags')] = tags
end
# If reference is commit id - we should add it to branch/tag selectbox
ref = Addressable::URI.unescape(params[:ref])
- if ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/
+ if find_commits && ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/
options['Commits'] = [ref]
end
render json: options.to_json
end
- def preview_markdown
- result = PreviewMarkdownService.new(@project, current_user, params).execute
-
- render json: {
- body: view_context.markdown(result[:text]),
- references: {
- users: result[:users],
- commands: view_context.markdown(result[:commands])
- }
- }
- end
-
private
# Render project landing depending of which features are available
@@ -259,13 +274,14 @@ class ProjectsController < Projects::ApplicationController
def render_landing_page
if can?(current_user, :download_code, @project)
return render 'projects/no_repo' unless @project.repository_exists?
+
render 'projects/empty' if @project.empty_repo?
else
- if @project.wiki_enabled?
+ if can?(current_user, :read_wiki, @project)
@project_wiki = @project.wiki
@wiki_home = @project_wiki.find_page('home', params[:version_id])
elsif @project.feature_available?(:issues, current_user)
- @issues = issues_collection.page(params[:page])
+ @issues = issuables_collection.page(params[:page])
@collection_type = 'Issue'
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
end
@@ -274,6 +290,10 @@ class ProjectsController < Projects::ApplicationController
end
end
+ def finder_type
+ IssuesFinder
+ end
+
def determine_layout
if [:new, :create].include?(action_name.to_sym)
'application'
@@ -285,23 +305,27 @@ class ProjectsController < Projects::ApplicationController
end
def load_events
- @events = @project.events.recent
- @events = event_filter.apply_filter(@events).with_associations
- limit = (params[:limit] || 20).to_i
- @events = @events.limit(limit).offset(params[:offset] || 0)
+ projects = Project.where(id: @project.id)
+
+ @events = EventCollection
+ .new(projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
+
+ Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
def project_params
params.require(:project)
- .permit(project_params_ce)
+ .permit(project_params_attributes)
end
- def project_params_ce
+ def project_params_attributes
[
:avatar,
:build_allow_git_fetch,
:build_coverage_regex,
:build_timeout_in_minutes,
+ :resolve_outdated_diff_discussions,
:container_registry_enabled,
:default_branch,
:description,
@@ -321,6 +345,8 @@ class ProjectsController < Projects::ApplicationController
:runners_token,
:tag_list,
:visibility_level,
+ :template_name,
+ :merge_method,
project_feature_attributes: %i[
builds_access_level
@@ -333,8 +359,16 @@ class ProjectsController < Projects::ApplicationController
]
end
+ def custom_import_params
+ {}
+ end
+
+ def active_new_project_tab
+ project_params[:import_url].present? ? 'import' : 'blank'
+ end
+
def repo_exists?
- project.repository_exists? && !project.empty_repo? && project.repo
+ project.repository_exists? && !project.empty_repo?
rescue Gitlab::Git::Repository::NoRepository
project.repository.expire_exists_cache
@@ -372,4 +406,21 @@ class ProjectsController < Projects::ApplicationController
url_for(params)
end
+
+ def project_export_enabled
+ render_404 unless Gitlab::CurrentSettings.project_export_enabled?
+ end
+
+ def redirect_git_extension
+ # Redirect from
+ # localhost/group/project.git
+ # to
+ # localhost/group/project
+ #
+ redirect_to request.original_url.sub(%r{\.git/?\Z}, '') if params[:format] == 'git'
+ end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440')
+ end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 1bc6520370a..1848c806c41 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -1,6 +1,8 @@
class RegistrationsController < Devise::RegistrationsController
include Recaptcha::Verify
+ before_action :whitelist_query_limiting, only: [:destroy]
+
def new
redirect_to(new_user_session_path)
end
@@ -25,27 +27,44 @@ class RegistrationsController < Devise::RegistrationsController
end
def destroy
- current_user.delete_async(deleted_by: current_user)
-
- respond_to do |format|
- format.html do
- session.try(:destroy)
- redirect_to new_user_session_path, status: 302, notice: "Account scheduled for removal."
- end
+ if destroy_confirmation_valid?
+ current_user.delete_async(deleted_by: current_user)
+ session.try(:destroy)
+ redirect_to new_user_session_path, status: 303, notice: s_('Profiles|Account scheduled for removal.')
+ else
+ redirect_to profile_account_path, status: 303, alert: destroy_confirmation_failure_message
end
end
protected
+ def destroy_confirmation_valid?
+ if current_user.confirm_deletion_with_password?
+ current_user.valid_password?(params[:password])
+ else
+ current_user.username == params[:username]
+ end
+ end
+
+ def destroy_confirmation_failure_message
+ if current_user.confirm_deletion_with_password?
+ s_('Profiles|Invalid password')
+ else
+ s_('Profiles|Invalid username')
+ end
+ end
+
def build_resource(hash = nil)
super
end
def after_sign_up_path_for(user)
+ Gitlab::AppLogger.info("User Created: username=#{user.username} email=#{user.email} ip=#{request.remote_ip} confirmed:#{user.confirmed?}")
user.confirmed? ? dashboard_projects_path : users_almost_there_path
end
- def after_inactive_sign_up_path_for(_resource)
+ def after_inactive_sign_up_path_for(resource)
+ Gitlab::AppLogger.info("User Created: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip} confirmed:false")
users_almost_there_path
end
@@ -66,4 +85,8 @@ class RegistrationsController < Devise::RegistrationsController
def devise_mapping
@devise_mapping ||= Devise.mappings[:user]
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42380')
+ end
end
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 1b4545e4a49..8acefd58e77 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -13,14 +13,17 @@ class RootController < Dashboard::ProjectsController
before_action :redirect_logged_user, if: -> { current_user.present? }
def index
- super
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37434
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ super
+ end
end
private
def redirect_unlogged_user
if redirect_to_home_page_url?
- redirect_to(current_application_settings.home_page_url)
+ redirect_to(Gitlab::CurrentSettings.home_page_url)
else
redirect_to(new_user_session_path)
end
@@ -45,9 +48,9 @@ class RootController < Dashboard::ProjectsController
def redirect_to_home_page_url?
# If user is not signed-in and tries to access root_path - redirect him to landing page
# Don't redirect to the default URL to prevent endless redirections
- return false unless current_application_settings.home_page_url.present?
+ return false unless Gitlab::CurrentSettings.home_page_url.present?
- home_page_url = current_application_settings.home_page_url.chomp('/')
+ home_page_url = Gitlab::CurrentSettings.home_page_url.chomp('/')
root_urls = [Gitlab.config.gitlab['url'].chomp('/'), root_url.chomp('/')]
root_urls.exclude?(home_page_url)
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 4a579601785..983f888b8ec 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -1,7 +1,13 @@
class SearchController < ApplicationController
- skip_before_action :authenticate_user!
-
+ include ControllerWithCrossProjectAccessCheck
include SearchHelper
+ include RendersCommits
+
+ skip_before_action :authenticate_user!
+ requires_cross_project_access if: -> do
+ search_term_present = params[:search].present? || params[:term].present?
+ search_term_present && !params[:project_id].present?
+ end
layout 'search'
@@ -20,6 +26,8 @@ class SearchController < ApplicationController
@search_results = search_service.search_results
@search_objects = search_service.search_objects
+ render_commits if @scope == 'commits'
+
check_single_commit_result
end
@@ -38,13 +46,17 @@ class SearchController < ApplicationController
private
+ def render_commits
+ @search_objects = prepare_commits_for_rendering(@search_objects)
+ end
+
def check_single_commit_result
if @search_results.single_commit_result?
only_commit = @search_results.objects('commits').first
query = params[:search].strip.downcase
found_by_commit_sha = Commit.valid_hash?(query) && only_commit.sha.start_with?(query)
- redirect_to namespace_project_commit_path(@project.namespace, @project, only_commit) if found_by_commit_sha
+ redirect_to project_commit_path(@project, only_commit) if found_by_commit_sha
end
end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index f39441a281e..f3a4aa849c7 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -8,19 +8,15 @@ class SessionsController < Devise::SessionsController
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
if: :two_factor_enabled?, only: [:create]
- prepend_before_action :store_redirect_path, only: [:new]
-
+ prepend_before_action :store_redirect_uri, only: [:new]
before_action :auto_sign_in_with_provider, only: [:new]
before_action :load_recaptcha
+ after_action :log_failed_login, only: [:new], if: :failed_login?
+
def new
set_minimum_password_length
- @ldap_servers =
- if Gitlab.config.ldap.enabled
- Gitlab::LDAP::Config.servers
- else
- []
- end
+ @ldap_servers = Gitlab::Auth::LDAP::Config.available_servers
super
end
@@ -32,14 +28,16 @@ class SessionsController < Devise::SessionsController
resource.update_attributes(reset_password_token: nil,
reset_password_sent_at: nil)
end
+
# hide the signed-in notification
flash[:notice] = nil
- log_audit_event(current_user, with: authentication_method)
+ log_audit_event(current_user, resource, with: authentication_method)
log_user_activity(current_user)
end
end
def destroy
+ Gitlab::AppLogger.info("User Logout: username=#{current_user.username} ip=#{request.remote_ip}")
super
# hide the signed_out notice
flash[:notice] = nil
@@ -47,8 +45,16 @@ class SessionsController < Devise::SessionsController
private
+ def log_failed_login
+ Gitlab::AppLogger.info("Failed Login: username=#{user_params[:login]} ip=#{request.remote_ip}")
+ end
+
+ def failed_login?
+ (options = env["warden.options"]) && options[:action] == "unauthenticated"
+ end
+
def login_counter
- @login_counter ||= Gitlab::Metrics.counter(:user_session_logins, 'User sign in count')
+ @login_counter ||= Gitlab::Metrics.counter(:user_session_logins_total, 'User sign in count')
end
# Handle an "initial setup" state, where there's only one user, it's an admin,
@@ -58,9 +64,9 @@ class SessionsController < Devise::SessionsController
user = User.admins.last
- return unless user && user.require_password?
+ return unless user && user.require_password_creation_for_web?
- Users::UpdateService.new(user).execute do |user|
+ Users::UpdateService.new(current_user, user: user).execute do |user|
@token = user.generate_reset_token
end
@@ -80,28 +86,36 @@ class SessionsController < Devise::SessionsController
end
end
- def store_redirect_path
- redirect_path =
+ def stored_redirect_uri
+ @redirect_to ||= stored_location_for(:redirect)
+ end
+
+ def store_redirect_uri
+ redirect_uri =
if request.referer.present? && (params['redirect_to_referer'] == 'yes')
- referer_uri = URI(request.referer)
- if referer_uri.host == Gitlab.config.gitlab.host
- referer_uri.request_uri
- else
- request.fullpath
- end
+ URI(request.referer)
else
- request.fullpath
+ URI(request.url)
end
# Prevent a 'you are already signed in' message directly after signing:
# we should never redirect to '/users/sign_in' after signing in successfully.
- unless URI(redirect_path).path == new_user_session_path
- store_location_for(:redirect, redirect_path)
- end
+ return true if redirect_uri.path == new_user_session_path
+
+ redirect_to = redirect_uri.to_s if redirect_allowed_to?(redirect_uri)
+
+ @redirect_to = redirect_to
+ store_location_for(:redirect, redirect_to)
+ end
+
+ # Overridden in EE
+ def redirect_allowed_to?(uri)
+ uri.host == Gitlab.config.gitlab.host &&
+ uri.port == Gitlab.config.gitlab.port
end
def two_factor_enabled?
- find_user.try(:two_factor_enabled?)
+ find_user&.two_factor_enabled?
end
def auto_sign_in_with_provider
@@ -128,7 +142,8 @@ class SessionsController < Devise::SessionsController
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end
- def log_audit_event(user, options = {})
+ def log_audit_event(user, resource, options = {})
+ Gitlab::AppLogger.info("Successful Login: username=#{resource.username} ip=#{request.remote_ip} method=#{options[:with]} admin=#{resource.admin?}")
AuditEventService.new(user, user, options)
.for_authentication.security_event
end
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index f9496787b15..c8b4682e6dc 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -20,6 +20,7 @@ class Snippets::NotesController < ApplicationController
def snippet
PersonalSnippet.find_by(id: params[:snippet_id])
end
+ alias_method :noteable, :snippet
def note_params
super.merge(noteable_id: params[:snippet_id])
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 3d86dd2ea2c..be2d3f638ff 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -4,6 +4,7 @@ class SnippetsController < ApplicationController
include SpammableActions
include SnippetsActions
include RendersBlob
+ include PreviewMarkdown
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
@@ -66,7 +67,7 @@ class SnippetsController < ApplicationController
@noteable = @snippet
@discussions = @snippet.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
respond_to do |format|
format.html do
@@ -87,17 +88,6 @@ class SnippetsController < ApplicationController
redirect_to snippets_path, status: 302
end
- def preview_markdown
- result = PreviewMarkdownService.new(@project, current_user, params).execute
-
- render json: {
- body: view_context.markdown(result[:text], skip_project_check: true),
- references: {
- users: result[:users]
- }
- }
- end
-
protected
def snippet
@@ -107,6 +97,10 @@ class SnippetsController < ApplicationController
alias_method :awardable, :snippet
alias_method :spammable, :snippet
+ def spammable_path
+ snippet_path(@snippet)
+ end
+
def authorize_read_snippet!
return if can?(current_user, :read_personal_snippet, @snippet)
diff --git a/app/controllers/unicorn_test_controller.rb b/app/controllers/unicorn_test_controller.rb
deleted file mode 100644
index b7a1a046be0..00000000000
--- a/app/controllers/unicorn_test_controller.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-if Rails.env.test?
- class UnicornTestController < ActionController::Base
- def pid
- render plain: Process.pid.to_s
- end
-
- def kill
- Process.kill(params[:signal], Process.pid)
- render plain: 'Bye!'
- end
- end
-end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index dc882b17143..3d227b0a955 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -1,19 +1,34 @@
class UploadsController < ApplicationController
include UploadsActions
+ UnknownUploadModelError = Class.new(StandardError)
+
+ MODEL_CLASSES = {
+ "user" => User,
+ "project" => Project,
+ "note" => Note,
+ "group" => Group,
+ "appearance" => Appearance,
+ "personal_snippet" => PersonalSnippet,
+ nil => PersonalSnippet
+ }.freeze
+
+ rescue_from UnknownUploadModelError, with: :render_404
+
skip_before_action :authenticate_user!
+ before_action :upload_mount_satisfied?
before_action :find_model
before_action :authorize_access!, only: [:show]
before_action :authorize_create_access!, only: [:create]
- private
+ def uploader_class
+ PersonalFileUploader
+ end
def find_model
return nil unless params[:id]
- return render_404 unless upload_model && upload_mount
-
- @model = upload_model.find(params[:id])
+ upload_model_class.find(params[:id])
end
def authorize_access!
@@ -53,55 +68,17 @@ class UploadsController < ApplicationController
end
end
- def upload_model
- upload_models = {
- "user" => User,
- "project" => Project,
- "note" => Note,
- "group" => Group,
- "appearance" => Appearance,
- "personal_snippet" => PersonalSnippet
- }
-
- upload_models[params[:model]]
- end
-
- def upload_mount
- return true unless params[:mounted_as]
-
- upload_mounts = %w(avatar attachment file logo header_logo)
-
- if upload_mounts.include?(params[:mounted_as])
- params[:mounted_as]
- end
+ def upload_model_class
+ MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
end
- def uploader
- return @uploader if defined?(@uploader)
-
- 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])
- else
- @uploader = @model.send(upload_mount)
-
- redirect_to @uploader.url unless @uploader.file_storage?
- end
-
- @uploader
+ def upload_model_class_has_mounts?
+ upload_model_class < CarrierWave::Mount::Extension
end
- def uploader_class
- PersonalFileUploader
- end
+ def upload_mount_satisfied?
+ return true unless upload_model_class_has_mounts?
- def model
- @model ||= find_model
+ upload_model_class.uploader_options.has_key?(upload_mount)
end
end
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
new file mode 100644
index 00000000000..18cde4a7b1a
--- /dev/null
+++ b/app/controllers/user_callouts_controller.rb
@@ -0,0 +1,23 @@
+class UserCalloutsController < ApplicationController
+ def create
+ if ensure_callout.persisted?
+ respond_to do |format|
+ format.json { head :ok }
+ end
+ else
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
+ end
+ end
+
+ private
+
+ def ensure_callout
+ current_user.callouts.find_or_create_by(feature_name: UserCallout.feature_names[feature_name])
+ end
+
+ def feature_name
+ params.require(:feature_name)
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 8131eba6a2f..956df4a0a16 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,5 +1,15 @@
class UsersController < ApplicationController
include RoutableActions
+ include RendersMemberAccess
+ include ControllerWithCrossProjectAccessCheck
+
+ requires_cross_project_access show: false,
+ groups: false,
+ projects: false,
+ contributed: false,
+ snippets: true,
+ calendar: false,
+ calendar_activities: true
skip_before_action :authenticate_user!
before_action :user, except: [:exists]
@@ -73,10 +83,7 @@ class UsersController < ApplicationController
end
def calendar
- calendar = contributions_calendar
- @activity_dates = calendar.activity_dates
-
- render 'calendar', layout: false
+ render json: contributions_calendar.activity_dates
end
def calendar_activities
@@ -105,26 +112,29 @@ class UsersController < ApplicationController
end
def load_events
- # Get user activity feed for projects common for both users
- @events = user.recent_events
- .merge(projects_for_current_user)
- .references(:project)
- .with_associations
- .limit_recent(20, params[:offset])
+ @events = UserRecentEventsFinder.new(current_user, user, params).execute
+
+ Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
def load_projects
@projects =
PersonalProjectsFinder.new(user).execute(current_user)
.page(params[:page])
+
+ prepare_projects_for_rendering(@projects)
end
def load_contributed_projects
@contributed_projects = contributed_projects.joined(user)
+
+ prepare_projects_for_rendering(@contributed_projects)
end
def load_groups
@groups = JoinedGroupsFinder.new(user).execute(current_user)
+
+ prepare_groups_for_rendering(@groups)
end
def load_snippets
@@ -135,10 +145,6 @@ class UsersController < ApplicationController
).execute.page(params[:page])
end
- def projects_for_current_user
- ProjectsFinder.new(current_user: current_user).execute
- end
-
def build_canonical_path(user)
url_for(params.merge(username: user.to_param))
end
diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb
new file mode 100644
index 00000000000..2c8f21c2400
--- /dev/null
+++ b/app/finders/admin/projects_finder.rb
@@ -0,0 +1,67 @@
+class Admin::ProjectsFinder
+ attr_reader :params, :current_user
+
+ def initialize(params:, current_user:)
+ @params = params
+ @current_user = current_user
+ end
+
+ def execute
+ items = Project.without_deleted.with_statistics
+ items = by_namespace_id(items)
+ items = by_visibilty_level(items)
+ items = by_with_push(items)
+ items = by_abandoned(items)
+ items = by_last_repository_check_failed(items)
+ items = by_archived(items)
+ items = by_personal(items)
+ items = by_name(items)
+ items = items.includes(namespace: [:owner])
+ sort(items).page(params[:page])
+ end
+
+ private
+
+ def by_namespace_id(items)
+ params[:namespace_id].present? ? items.in_namespace(params[:namespace_id]) : items
+ end
+
+ def by_visibilty_level(items)
+ params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
+ end
+
+ def by_with_push(items)
+ params[:with_push].present? ? items.with_push : items
+ end
+
+ def by_abandoned(items)
+ params[:abandoned].present? ? items.abandoned : items
+ end
+
+ def by_last_repository_check_failed(items)
+ params[:last_repository_check_failed].present? ? items.where(last_repository_check_failed: true) : items
+ end
+
+ def by_archived(items)
+ if params[:archived] == 'only'
+ items.archived
+ elsif params[:archived].blank?
+ items.non_archived
+ else
+ items
+ end
+ end
+
+ def by_personal(items)
+ params[:personal].present? ? items.personal(current_user) : items
+ end
+
+ def by_name(items)
+ params[:name].present? ? items.search(params[:name]) : items
+ end
+
+ def sort(items)
+ sort = params.fetch(:sort) { 'latest_activity_desc' }
+ items.sort(sort)
+ end
+end
diff --git a/app/finders/autocomplete_users_finder.rb b/app/finders/autocomplete_users_finder.rb
new file mode 100644
index 00000000000..e8a03947f59
--- /dev/null
+++ b/app/finders/autocomplete_users_finder.rb
@@ -0,0 +1,68 @@
+class AutocompleteUsersFinder
+ # The number of users to display in the results is hardcoded to 20, and
+ # pagination is not supported. This ensures that performance remains
+ # consistent and removes the need for implementing keyset pagination to ensure
+ # good performance.
+ LIMIT = 20
+
+ attr_reader :current_user, :project, :group, :search, :skip_users,
+ :author_id, :params
+
+ def initialize(params:, current_user:, project:, group:)
+ @current_user = current_user
+ @project = project
+ @group = group
+ @search = params[:search]
+ @skip_users = params[:skip_users]
+ @author_id = params[:author_id]
+ @params = params
+ end
+
+ def execute
+ items = find_users
+ items = items.active
+ items = items.reorder(:name)
+ items = items.search(search) if search.present?
+ items = items.where.not(id: skip_users) if skip_users.present?
+ items = items.limit(LIMIT)
+
+ if params[:todo_filter].present? && current_user
+ items = items.todo_authors(current_user.id, params[:todo_state_filter])
+ end
+
+ if search.blank?
+ # Include current user if available to filter by "Me"
+ if params[:current_user].present? && current_user
+ items = [current_user, *items].uniq
+ end
+
+ if author_id.present? && current_user
+ author = User.find_by_id(author_id)
+ items = [author, *items].uniq if author
+ end
+ end
+
+ items
+ end
+
+ private
+
+ def find_users
+ return users_from_project if project
+ return group.users_with_parents if group
+ return User.all if current_user
+
+ User.none
+ end
+
+ def users_from_project
+ if author_id.present?
+ union = Gitlab::SQL::Union
+ .new([project.authorized_users, User.where(id: author_id)])
+
+ User.from("(#{union.to_sql}) #{User.table_name}")
+ else
+ project.authorized_users
+ end
+ end
+end
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index 533076585c0..8bb1366867c 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -1,5 +1,5 @@
class BranchesFinder
- def initialize(repository, params)
+ def initialize(repository, params = {})
@repository = repository
@params = params
end
@@ -23,7 +23,7 @@ class BranchesFinder
def filter_by_name(branches)
if search
- branches.select { |branch| branch.name.include?(search) }
+ branches.select { |branch| branch.name.upcase.include?(search.upcase) }
else
branches
end
diff --git a/app/finders/clusters_finder.rb b/app/finders/clusters_finder.rb
new file mode 100644
index 00000000000..c13f98257bf
--- /dev/null
+++ b/app/finders/clusters_finder.rb
@@ -0,0 +1,29 @@
+class ClustersFinder
+ def initialize(project, user, scope)
+ @project = project
+ @user = user
+ @scope = scope || :active
+ end
+
+ def execute
+ clusters = project.clusters
+ filter_by_scope(clusters)
+ end
+
+ private
+
+ attr_reader :project, :user, :scope
+
+ def filter_by_scope(clusters)
+ case scope.to_sym
+ when :all
+ clusters
+ when :inactive
+ clusters.disabled
+ when :active
+ clusters.enabled
+ else
+ raise "Invalid scope #{scope}"
+ end
+ end
+end
diff --git a/app/finders/concerns/created_at_filter.rb b/app/finders/concerns/created_at_filter.rb
new file mode 100644
index 00000000000..ac9ac77732c
--- /dev/null
+++ b/app/finders/concerns/created_at_filter.rb
@@ -0,0 +1,8 @@
+module CreatedAtFilter
+ def by_created_at(items)
+ items = items.created_before(params[:created_before]) if params[:created_before].present?
+ items = items.created_after(params[:created_after]) if params[:created_after].present?
+
+ items
+ end
+end
diff --git a/app/finders/concerns/custom_attributes_filter.rb b/app/finders/concerns/custom_attributes_filter.rb
new file mode 100644
index 00000000000..5bbf9ca242d
--- /dev/null
+++ b/app/finders/concerns/custom_attributes_filter.rb
@@ -0,0 +1,20 @@
+module CustomAttributesFilter
+ def by_custom_attributes(items)
+ return items unless params[:custom_attributes].is_a?(Hash)
+ return items unless Ability.allowed?(current_user, :read_custom_attribute)
+
+ association = items.reflect_on_association(:custom_attributes)
+ attributes_table = association.klass.arel_table
+ attributable_table = items.model.arel_table
+
+ custom_attributes = association.klass.select('true').where(
+ attributes_table[association.foreign_key]
+ .eq(attributable_table[association.association_primary_key])
+ )
+
+ # perform a subquery for each attribute to be filtered
+ params[:custom_attributes].inject(items) do |scope, (key, value)|
+ scope.where('EXISTS (?)', custom_attributes.where(key: key, value: value))
+ end
+ end
+end
diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb
new file mode 100644
index 00000000000..2e905fa5750
--- /dev/null
+++ b/app/finders/concerns/finder_methods.rb
@@ -0,0 +1,51 @@
+module FinderMethods
+ def find_by!(*args)
+ raise_not_found_unless_authorized execute.find_by!(*args)
+ end
+
+ def find_by(*args)
+ if_authorized execute.find_by(*args)
+ end
+
+ def find(*args)
+ raise_not_found_unless_authorized model.find(*args)
+ end
+
+ private
+
+ def raise_not_found_unless_authorized(result)
+ result = if_authorized(result)
+
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{model}") unless result
+
+ result
+ end
+
+ def if_authorized(result)
+ # Return the result if the finder does not perform authorization checks.
+ # this is currently the case in the `MilestoneFinder`
+ return result unless respond_to?(:current_user)
+
+ if can_read_object?(result)
+ result
+ else
+ nil
+ end
+ end
+
+ def can_read_object?(object)
+ # When there's no policy, we'll allow the read, this is for example the case
+ # for Todos
+ return true unless DeclarativePolicy.has_policy?(object)
+
+ model_name = object&.model_name || model.model_name
+
+ Ability.allowed?(current_user, :"read_#{model_name.singular}", object)
+ end
+
+ # This fetches the model from the `ActiveRecord::Relation` but does not
+ # actually execute the query.
+ def model
+ execute.model
+ end
+end
diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb
new file mode 100644
index 00000000000..92bf98d7cd2
--- /dev/null
+++ b/app/finders/concerns/finder_with_cross_project_access.rb
@@ -0,0 +1,70 @@
+# Module to prepend into finders to specify wether or not the finder requires
+# cross project access
+#
+# This module depends on the finder implementing the following methods:
+#
+# - `#execute` should return an `ActiveRecord::Relation`
+# - `#current_user` the user that requires access (or nil)
+module FinderWithCrossProjectAccess
+ extend ActiveSupport::Concern
+ extend ::Gitlab::Utils::Override
+
+ prepended do
+ extend Gitlab::CrossProjectAccess::ClassMethods
+ end
+
+ override :execute
+ def execute(*args)
+ check = Gitlab::CrossProjectAccess.find_check(self)
+ original = super
+
+ return original unless check
+ return original if should_skip_cross_project_check || can_read_cross_project?
+
+ if check.should_run?(self)
+ original.model.none
+ else
+ original
+ end
+ end
+
+ # We can skip the cross project check for finding indivitual records.
+ # this would be handled by the `can?(:read_*, result)` call in `FinderMethods`
+ # itself.
+ override :find_by!
+ def find_by!(*args)
+ skip_cross_project_check { super }
+ end
+
+ override :find_by
+ def find_by(*args)
+ skip_cross_project_check { super }
+ end
+
+ override :find
+ def find(*args)
+ skip_cross_project_check { super }
+ end
+
+ private
+
+ attr_accessor :should_skip_cross_project_check
+
+ def skip_cross_project_check
+ self.should_skip_cross_project_check = true
+
+ yield
+ ensure
+ # The find could raise an `ActiveRecord::RecordNotFound`, after which we
+ # still want to re-enable the check.
+ self.should_skip_cross_project_check = false
+ end
+
+ def can_read_cross_project?
+ Ability.allowed?(current_user, :read_cross_project)
+ end
+
+ def can_read_project?(project)
+ Ability.allowed?(current_user, :read_project, project)
+ end
+end
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 46ecbaba73a..8676925a540 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -1,6 +1,10 @@
class EventsFinder
+ prepend FinderMethods
+ prepend FinderWithCrossProjectAccess
attr_reader :source, :params, :current_user
+ requires_cross_project_access unless: -> { source.is_a?(Project) }
+
# Used to filter Events
#
# Arguments:
diff --git a/app/finders/fork_projects_finder.rb b/app/finders/fork_projects_finder.rb
new file mode 100644
index 00000000000..28d1b31868e
--- /dev/null
+++ b/app/finders/fork_projects_finder.rb
@@ -0,0 +1,6 @@
+class ForkProjectsFinder < ProjectsFinder
+ def initialize(project, params: {}, current_user: nil)
+ project_ids = project.forks.includes(:creator).select(:id)
+ super(params: params, current_user: current_user, project_ids_relation: project_ids)
+ end
+end
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
new file mode 100644
index 00000000000..e72fd8eb3a5
--- /dev/null
+++ b/app/finders/group_descendants_finder.rb
@@ -0,0 +1,171 @@
+# GroupDescendantsFinder
+#
+# Used to find and filter all subgroups and projects of a passed parent group
+# visible to a specified user.
+#
+# When passing a `filter` param, the search is performed over all nested levels
+# of the `parent_group`. All ancestors for a search result are loaded
+#
+# Arguments:
+# current_user: The user for which the children should be visible
+# parent_group: The group to find children of
+# params:
+# Supports all params that the `ProjectsFinder` and `GroupProjectsFinder`
+# support.
+#
+# filter: string - is aliased to `search` for consistency with the frontend
+# archived: string - `only` or `true`.
+# `non_archived` is passed to the `ProjectFinder`s if none
+# was given.
+class GroupDescendantsFinder
+ attr_reader :current_user, :parent_group, :params
+
+ def initialize(current_user: nil, parent_group:, params: {})
+ @current_user = current_user
+ @parent_group = parent_group
+ @params = params.reverse_merge(non_archived: params[:archived].blank?)
+ end
+
+ def execute
+ # The children array might be extended with the ancestors of projects and
+ # subgroups when filtering. In that case, take the maximum so the array does
+ # not get limited otherwise, allow paginating through all results.
+ #
+ all_required_elements = children
+ if params[:filter]
+ all_required_elements |= ancestors_of_filtered_subgroups
+ all_required_elements |= ancestors_of_filtered_projects
+ end
+
+ total_count = [all_required_elements.size, paginator.total_count].max
+
+ Kaminari.paginate_array(all_required_elements, total_count: total_count)
+ end
+
+ def has_children?
+ projects.any? || subgroups.any?
+ end
+
+ private
+
+ def children
+ @children ||= paginator.paginate(params[:page])
+ end
+
+ def paginator
+ @paginator ||= Gitlab::MultiCollectionPaginator.new(
+ subgroups,
+ projects.with_route,
+ per_page: params[:per_page]
+ )
+ end
+
+ def direct_child_groups
+ GroupsFinder.new(current_user,
+ parent: parent_group,
+ all_available: true).execute
+ end
+
+ def all_visible_descendant_groups
+ groups_table = Group.arel_table
+ visible_to_user = groups_table[:visibility_level]
+ .in(Gitlab::VisibilityLevel.levels_for_user(current_user))
+
+ if current_user
+ authorized_groups = GroupsFinder.new(current_user,
+ all_available: false)
+ .execute.as('authorized')
+ authorized_to_user = groups_table.project(1).from(authorized_groups)
+ .where(authorized_groups[:id].eq(groups_table[:id]))
+ .exists
+ visible_to_user = visible_to_user.or(authorized_to_user)
+ end
+
+ hierarchy_for_parent
+ .descendants
+ .where(visible_to_user)
+ end
+
+ def subgroups_matching_filter
+ all_visible_descendant_groups
+ .search(params[:filter])
+ end
+
+ # When filtering we want all to preload all the ancestors upto the specified
+ # parent group.
+ #
+ # - root
+ # - subgroup
+ # - nested-group
+ # - project
+ #
+ # So when searching 'project', on the 'subgroup' page we want to preload
+ # 'nested-group' but not 'subgroup' or 'root'
+ def ancestors_of_groups(base_for_ancestors)
+ group_ids = base_for_ancestors.except(:select, :sort).select(:id)
+ Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
+ .base_and_ancestors(upto: parent_group.id)
+ end
+
+ def ancestors_of_filtered_projects
+ projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
+ groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
+ ancestors_of_groups(groups_to_load_ancestors_of)
+ .with_selects_for_list(archived: params[:archived])
+ end
+
+ def ancestors_of_filtered_subgroups
+ ancestors_of_groups(subgroups)
+ .with_selects_for_list(archived: params[:archived])
+ end
+
+ def subgroups
+ return Group.none unless Group.supports_nested_groups?
+
+ # When filtering subgroups, we want to find all matches withing the tree of
+ # descendants to show to the user
+ groups = if params[:filter]
+ subgroups_matching_filter
+ else
+ direct_child_groups
+ end
+
+ groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
+ end
+
+ def direct_child_projects
+ GroupProjectsFinder.new(group: parent_group,
+ current_user: current_user,
+ options: { only_owned: true },
+ params: params).execute
+ end
+
+ # Finds all projects nested under `parent_group` or any of its descendant
+ # groups
+ def projects_matching_filter
+ projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
+ params_with_search = params.merge(search: params[:filter])
+
+ ProjectsFinder.new(params: params_with_search,
+ current_user: current_user,
+ project_ids_relation: projects_nested_in_group).execute
+ end
+
+ def projects
+ projects = if params[:filter]
+ projects_matching_filter
+ else
+ direct_child_projects
+ end
+
+ projects.with_route.order_by(sort)
+ end
+
+ def sort
+ params.fetch(:sort, 'id_asc')
+ end
+
+ def hierarchy_for_parent
+ @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
+ end
+end
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index f2d3b90b8e2..f73cf8adb4d 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -87,8 +87,17 @@ class GroupProjectsFinder < ProjectsFinder
options.fetch(:only_shared, false)
end
+ # subgroups are supported only for owned projects not for shared
+ def include_subgroups?
+ options.fetch(:include_subgroups, false)
+ end
+
def owned_projects
- group.projects
+ if include_subgroups?
+ Project.where(namespace_id: group.self_and_descendants.select(:id))
+ else
+ group.projects
+ end
end
def shared_projects
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index e6fb112e7f2..0282b378d88 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -1,4 +1,22 @@
+# GroupsFinder
+#
+# Used to filter Groups by a set of params
+#
+# Arguments:
+# current_user - which user is requesting groups
+# params:
+# owned: boolean
+# parent: Group
+# all_available: boolean (defaults to true)
+#
+# Users with full private access can see all groups. The `owned` and `parent`
+# params can be used to restrict the groups that are returned.
+#
+# Anonymous users will never return any `owned` groups. They will return all
+# public groups instead, even if `all_available` is set to false.
class GroupsFinder < UnionFinder
+ include CustomAttributesFilter
+
def initialize(current_user = nil, params = {})
@current_user = current_user
@params = params
@@ -6,8 +24,12 @@ class GroupsFinder < UnionFinder
def execute
items = all_groups.map do |item|
- by_parent(item)
+ item = by_parent(item)
+ item = by_custom_attributes(item)
+
+ item
end
+
find_union(items, Group).with_route.order_id_desc
end
@@ -16,13 +38,13 @@ class GroupsFinder < UnionFinder
attr_reader :current_user, :params
def all_groups
- groups = []
-
- if current_user
- groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups
- end
- groups << Group.unscoped.public_to_user(current_user)
+ return [owned_groups] if params[:owned]
+ return [Group.all] if current_user&.full_private_access?
+ groups = []
+ groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user
+ groups << Group.unscoped.public_to_user(current_user) if include_public_groups?
+ groups << Group.none if groups.empty?
groups
end
@@ -39,4 +61,12 @@ class GroupsFinder < UnionFinder
groups.where(parent: params[:parent])
end
+
+ def owned_groups
+ current_user&.owned_groups || Group.none
+ end
+
+ def include_public_groups?
+ current_user.nil? || params.fetch(:all_available, true)
+ end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 558f8b5e2e5..b2d4f9938ff 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -11,18 +11,60 @@
# group_id: integer
# project_id: integer
# milestone_title: string
+# author_id: integer
# assignee_id: integer
# search: string
# label_name: string
# sort: string
# non_archived: boolean
# iids: integer[]
+# my_reaction_emoji: string
+# created_after: datetime
+# created_before: datetime
+# updated_after: datetime
+# updated_before: datetime
#
class IssuableFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+ include CreatedAtFilter
+
+ requires_cross_project_access unless: -> { project? }
+
NONE = '0'.freeze
attr_accessor :current_user, :params
+ def self.scalar_params
+ @scalar_params ||= %i[
+ assignee_id
+ assignee_username
+ author_id
+ author_username
+ authorized_only
+ group_id
+ iids
+ label_name
+ milestone_title
+ my_reaction_emoji
+ non_archived
+ project_id
+ scope
+ search
+ sort
+ state
+ include_subgroups
+ ]
+ end
+
+ def self.array_params
+ @array_params ||= { label_name: [], iids: [], assignee_username: [] }
+ end
+
+ def self.valid_params
+ @valid_params ||= scalar_params + [array_params]
+ end
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params
@@ -30,30 +72,32 @@ class IssuableFinder
def execute
items = init_collection
+ items = filter_items(items)
+
+ # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far
+ items = by_project(items)
+
+ sort(items)
+ end
+
+ def filter_items(items)
items = by_scope(items)
+ items = by_created_at(items)
+ items = by_updated_at(items)
items = by_state(items)
items = by_group(items)
items = by_search(items)
items = by_assignee(items)
items = by_author(items)
- items = by_due_date(items)
items = by_non_archived(items)
items = by_iids(items)
items = by_milestone(items)
items = by_label(items)
- items = by_created_at(items)
-
- # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far
- items = by_project(items)
- sort(items)
- end
-
- def find(*params)
- execute.find(*params)
+ by_my_reaction_emoji(items)
end
- def find_by(*params)
- execute.find_by(*params)
+ def row_count
+ Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state])
end
# We often get counts for each state by running a query per state, and
@@ -77,15 +121,10 @@ class IssuableFinder
end
counts[:all] = counts.values.sum
- counts[:opened] += counts[:reopened]
counts
end
- def find_by!(*params)
- execute.find_by!(*params)
- end
-
def group
return @group if defined?(@group)
@@ -117,7 +156,8 @@ class IssuableFinder
if current_user && params[:authorized_only].presence && !current_user_related?
current_user.authorized_projects
elsif group
- GroupProjectsFinder.new(group: group, current_user: current_user).execute
+ finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
+ GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
else
ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids(items)).execute
end
@@ -142,9 +182,17 @@ class IssuableFinder
@milestones =
if milestones?
- scope = Milestone.where(project_id: projects)
+ if project?
+ group_id = project.group&.id
+ project_id = project.id
+ end
+
+ group_id = group.id if group
- scope.where(title: params[:milestone_title])
+ search_params =
+ { title: params[:milestone_title], project_ids: project_id, group_ids: group_id }
+
+ MilestonesFinder.new(search_params).execute
else
Milestone.none
end
@@ -228,6 +276,8 @@ class IssuableFinder
end
def by_scope(items)
+ return items.none if current_user_related? && !current_user
+
case params[:scope]
when 'created-by-me', 'authored'
items.where(author_id: current_user.id)
@@ -238,6 +288,13 @@ class IssuableFinder
end
end
+ def by_updated_at(items)
+ items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
+ items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
+
+ items
+ end
+
def by_state(items)
case params[:state].to_s
when 'closed'
@@ -326,11 +383,6 @@ class IssuableFinder
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
else
items = items.with_milestone(params[:milestone_title])
- items_projects = projects(items)
-
- if items_projects
- items = items.where(milestones: { project_id: items_projects })
- end
end
end
@@ -338,59 +390,26 @@ class IssuableFinder
end
def by_label(items)
- if labels?
+ return items unless labels?
+
+ items =
if filter_by_no_label?
- items = items.without_label
+ items.without_label
else
- items = items.with_label(label_names, params[:sort])
- items_projects = projects(items)
-
- if items_projects
- label_ids = LabelsFinder.new(current_user, project_ids: items_projects).execute(skip_authorization: true).select(:id)
- items = items.where(labels: { id: label_ids })
- end
+ items.with_label(label_names, params[:sort])
end
- end
items
end
- def by_due_date(items)
- if due_date?
- if filter_by_no_due_date?
- items = items.without_due_date
- elsif filter_by_overdue?
- items = items.due_before(Date.today)
- elsif filter_by_due_this_week?
- items = items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
- elsif filter_by_due_this_month?
- items = items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
- end
+ def by_my_reaction_emoji(items)
+ if params[:my_reaction_emoji].present? && current_user
+ items = items.awarded(current_user, params[:my_reaction_emoji])
end
items
end
- def filter_by_no_due_date?
- due_date? && params[:due_date] == Issue::NoDueDate.name
- end
-
- def filter_by_overdue?
- due_date? && params[:due_date] == Issue::Overdue.name
- end
-
- def filter_by_due_this_week?
- due_date? && params[:due_date] == Issue::DueThisWeek.name
- end
-
- def filter_by_due_this_month?
- due_date? && params[:due_date] == Issue::DueThisMonth.name
- end
-
- def due_date?
- params[:due_date].present? && klass.column_names.include?('due_date')
- end
-
def label_names
if labels?
params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
@@ -403,18 +422,6 @@ class IssuableFinder
params[:non_archived].present? ? items.non_archived : items
end
- def by_created_at(items)
- if params[:created_after].present?
- items = items.where(items.klass.arel_table[:created_at].gteq(params[:created_after]))
- end
-
- if params[:created_before].present?
- items = items.where(items.klass.arel_table[:created_at].lteq(params[:created_before]))
- end
-
- items
- end
-
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 3da5508aefd..2a27ff0e386 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -14,16 +14,109 @@
# search: string
# label_name: string
# sort: string
+# my_reaction_emoji: string
+# public_only: boolean
+# due_date: date or '0', '', 'overdue', 'week', or 'month'
+# created_after: datetime
+# created_before: datetime
+# updated_after: datetime
+# updated_before: datetime
#
class IssuesFinder < IssuableFinder
+ CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
+
+ def self.scalar_params
+ @scalar_params ||= super + [:due_date]
+ end
+
def klass
- Issue
+ Issue.includes(:author)
+ end
+
+ def with_confidentiality_access_check
+ return Issue.all if user_can_see_all_confidential_issues?
+ return Issue.where('issues.confidential IS NOT TRUE') if user_cannot_see_confidential_issues?
+
+ Issue.where('
+ issues.confidential IS NOT TRUE
+ OR (issues.confidential = TRUE
+ AND (issues.author_id = :user_id
+ OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
+ OR issues.project_id IN(:project_ids)))',
+ user_id: current_user.id,
+ project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id))
end
private
def init_collection
- IssuesFinder.not_restricted_by_confidentiality(current_user)
+ if public_only?
+ Issue.public_only
+ else
+ with_confidentiality_access_check
+ end
+ end
+
+ def public_only?
+ params.fetch(:public_only, false)
+ end
+
+ def filter_items(items)
+ by_due_date(super)
+ end
+
+ def by_due_date(items)
+ if due_date?
+ if filter_by_no_due_date?
+ items = items.without_due_date
+ elsif filter_by_overdue?
+ items = items.due_before(Date.today)
+ elsif filter_by_due_this_week?
+ items = items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
+ elsif filter_by_due_this_month?
+ items = items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
+ end
+ end
+
+ items
+ end
+
+ def filter_by_no_due_date?
+ due_date? && params[:due_date] == Issue::NoDueDate.name
+ end
+
+ def filter_by_overdue?
+ due_date? && params[:due_date] == Issue::Overdue.name
+ end
+
+ def filter_by_due_this_week?
+ due_date? && params[:due_date] == Issue::DueThisWeek.name
+ end
+
+ def filter_by_due_this_month?
+ due_date? && params[:due_date] == Issue::DueThisMonth.name
+ end
+
+ def due_date?
+ params[:due_date].present?
+ end
+
+ def user_can_see_all_confidential_issues?
+ return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues)
+
+ return @user_can_see_all_confidential_issues = false if current_user.blank?
+ return @user_can_see_all_confidential_issues = true if current_user.full_private_access?
+
+ @user_can_see_all_confidential_issues =
+ project? &&
+ project &&
+ project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
+ end
+
+ def user_cannot_see_confidential_issues?
+ return false if user_can_see_all_confidential_issues?
+
+ current_user.blank?
end
def by_assignee(items)
@@ -38,21 +131,6 @@ class IssuesFinder < IssuableFinder
end
end
- def self.not_restricted_by_confidentiality(user)
- return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
-
- return Issue.all if user.full_private_access?
-
- Issue.where('
- issues.confidential IS NOT TRUE
- OR (issues.confidential = TRUE
- AND (issues.author_id = :user_id
- OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
- OR issues.project_id IN(:project_ids)))',
- user_id: user.id,
- project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
- end
-
def item_project_ids(items)
items&.reorder(nil)&.select(:project_id)
end
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index 042d792dada..780c0fdb03e 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -1,4 +1,10 @@
class LabelsFinder < UnionFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+ include Gitlab::Utils::StrongMemoize
+
+ requires_cross_project_access unless: -> { project? }
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params
@@ -32,6 +38,8 @@ class LabelsFinder < UnionFinder
label_ids << project.labels
end
end
+ elsif only_group_labels?
+ label_ids << Label.where(group_id: group_ids)
else
label_ids << Label.where(group_id: projects.group_ids)
label_ids << Label.where(project_id: projects.select(:id))
@@ -51,6 +59,22 @@ class LabelsFinder < UnionFinder
items.where(title: title)
end
+ def group_ids
+ strong_memoize(:group_ids) do
+ groups_user_can_read_labels(groups_to_include).map(&:id)
+ end
+ end
+
+ def groups_to_include
+ group = Group.find(params[:group_id])
+ groups = [group]
+
+ groups += group.ancestors if params[:include_ancestor_groups].present?
+ groups += group.descendants if params[:include_descendant_groups].present?
+
+ groups
+ end
+
def group?
params[:group_id].present?
end
@@ -60,7 +84,11 @@ class LabelsFinder < UnionFinder
end
def projects?
- params[:project_ids].present?
+ params[:project_ids]
+ end
+
+ def only_group_labels?
+ params[:only_group_labels]
end
def title
@@ -83,7 +111,12 @@ class LabelsFinder < UnionFinder
def projects
return @projects if defined?(@projects)
- @projects = skip_authorization ? Project.all : ProjectsFinder.new(current_user: current_user).execute
+ @projects = if skip_authorization
+ Project.all
+ else
+ ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute
+ end
+
@projects = @projects.in_namespace(params[:group_id]) if group?
@projects = @projects.where(id: params[:project_ids]) if projects?
@projects = @projects.reorder(nil)
@@ -91,9 +124,15 @@ class LabelsFinder < UnionFinder
@projects
end
- def authorized_to_read_labels?(project)
+ def authorized_to_read_labels?(label_parent)
return true if skip_authorization
- Ability.allowed?(current_user, :read_label, project)
+ Ability.allowed?(current_user, :read_label, label_parent)
+ end
+
+ def groups_user_can_read_labels(groups)
+ DeclarativePolicy.user_scope do
+ groups.select { |group| authorized_to_read_labels?(group) }
+ end
end
end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index af24045886e..4734d97b8c7 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -10,26 +10,59 @@ class MembersFinder
def execute
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
- wheres = ["members.id IN (#{project_members.select(:id).to_sql})"]
if group
- # We need `.where.not(user_id: nil)` here otherwise when a group has an
- # invitee, it would make the following query return 0 rows since a NULL
- # user_id would be present in the subquery
- # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values
- non_null_user_ids = project_members.where.not(user_id: nil).select(:user_id)
-
group_members = GroupMembersFinder.new(group).execute
- group_members = group_members.where.not(user_id: non_null_user_ids)
- group_members = group_members.non_invite unless can?(current_user, :admin_group, group)
+ group_members = group_members.non_invite
- wheres << "members.id IN (#{group_members.select(:id).to_sql})"
- end
+ union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false)
- Member.where(wheres.join(' OR '))
+ sql = distinct_on(union)
+
+ Member.includes(:user).from("(#{sql}) AS #{Member.table_name}")
+ else
+ project_members
+ end
end
def can?(*args)
Ability.allowed?(*args)
end
+
+ private
+
+ def distinct_on(union)
+ # We're interested in a list of members without duplicates by user_id.
+ # We prefer project members over group members, project members should go first.
+ if Gitlab::Database.postgresql?
+ <<~SQL
+ SELECT DISTINCT ON (user_id, invite_email) member_union.*
+ FROM (#{union.to_sql}) AS member_union
+ ORDER BY user_id,
+ invite_email,
+ CASE
+ WHEN type = 'ProjectMember' THEN 1
+ WHEN type = 'GroupMember' THEN 2
+ ELSE 3
+ END
+ SQL
+ else
+ # Older versions of MySQL do not support window functions (and DISTINCT ON is postgres-specific).
+ <<~SQL
+ SELECT t1.*
+ FROM (#{union.to_sql}) AS t1
+ JOIN (
+ SELECT
+ COALESCE(user_id, -1) AS user_id,
+ COALESCE(invite_email, 'NULL') AS invite_email,
+ MIN(CASE WHEN type = 'ProjectMember' THEN 1 WHEN type = 'GroupMember' THEN 2 ELSE 3 END) AS type_number
+ FROM
+ (#{union.to_sql}) AS t3
+ GROUP BY COALESCE(user_id, -1), COALESCE(invite_email, 'NULL')
+ ) AS t2 ON COALESCE(t1.user_id, -1) = t2.user_id
+ AND COALESCE(t1.invite_email, 'NULL') = t2.invite_email
+ AND CASE WHEN t1.type = 'ProjectMember' THEN 1 WHEN t1.type = 'GroupMember' THEN 2 ELSE 3 END = t2.type_number
+ SQL
+ end
+ end
end
diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb
new file mode 100644
index 00000000000..f358938344e
--- /dev/null
+++ b/app/finders/merge_request_target_project_finder.rb
@@ -0,0 +1,20 @@
+class MergeRequestTargetProjectFinder
+ include FinderMethods
+
+ attr_reader :current_user, :source_project
+
+ def initialize(current_user: nil, source_project:)
+ @current_user = current_user
+ @source_project = source_project
+ end
+
+ def execute
+ if @source_project.fork_network
+ @source_project.fork_network.projects
+ .public_or_visible_to_user(current_user)
+ .with_feature_available_for_user(:merge_requests, current_user)
+ else
+ Project.where(id: source_project)
+ end
+ end
+end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 2fc34f186ad..64dc1e6af0f 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -10,19 +10,53 @@
# group_id: integer
# project_id: integer
# milestone_title: string
+# author_id: integer
# assignee_id: integer
# search: string
# label_name: string
# sort: string
# non_archived: boolean
+# my_reaction_emoji: string
+# source_branch: string
+# target_branch: string
+# created_after: datetime
+# created_before: datetime
+# updated_after: datetime
+# updated_before: datetime
#
class MergeRequestsFinder < IssuableFinder
def klass
MergeRequest
end
+ def filter_items(_items)
+ items = by_source_branch(super)
+
+ by_target_branch(items)
+ end
+
private
+ def source_branch
+ @source_branch ||= params[:source_branch].presence
+ end
+
+ def by_source_branch(items)
+ return items unless source_branch
+
+ items.where(source_branch: source_branch)
+ end
+
+ def target_branch
+ @target_branch ||= params[:target_branch].presence
+ end
+
+ def by_target_branch(items)
+ return items unless target_branch
+
+ items.where(target_branch: target_branch)
+ end
+
def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id)
end
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 630c73c2a94..f5d2b9f253a 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -1,12 +1,54 @@
+# Search for milestones
+#
+# params - Hash
+# project_ids: Array of project ids or single project id.
+# group_ids: Array of group ids or single group id.
+# order - Orders by field default due date asc.
+# title - filter by title.
+# state - filters by state.
+
class MilestonesFinder
- def execute(projects, params)
- milestones = Milestone.of_projects(projects)
- milestones = milestones.reorder("due_date ASC")
-
- case params[:state]
- when 'closed' then milestones.closed
- when 'all' then milestones
- else milestones.active
+ include FinderMethods
+
+ attr_reader :params, :project_ids, :group_ids
+
+ def initialize(params = {})
+ @project_ids = Array(params[:project_ids])
+ @group_ids = Array(params[:group_ids])
+ @params = params
+ end
+
+ def execute
+ return Milestone.none if project_ids.empty? && group_ids.empty?
+
+ items = Milestone.all
+ items = by_groups_and_projects(items)
+ items = by_title(items)
+ items = by_state(items)
+
+ order(items)
+ end
+
+ private
+
+ def by_groups_and_projects(items)
+ items.for_projects_and_groups(project_ids, group_ids)
+ end
+
+ def by_title(items)
+ if params[:title]
+ items.where(title: params[:title])
+ else
+ items
end
end
+
+ def by_state(items)
+ Milestone.filter_by_state(items, params[:state])
+ end
+
+ def order(items)
+ order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
+ items.reorder(order_statement).order('title ASC')
+ end
end
diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb
index 79eb45568be..038d5565a1e 100644
--- a/app/finders/move_to_project_finder.rb
+++ b/app/finders/move_to_project_finder.rb
@@ -9,6 +9,7 @@ class MoveToProjectFinder
projects = @user.projects_where_can_admin_issues
projects = projects.search(search) if search.present?
projects = projects.excluding_project(from_project)
+ projects = projects.order_id_desc
# infinite scroll using offset
projects = projects.where('projects.id < ?', offset_id) if offset_id.present?
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 02eb983bf55..35f4ff2f62f 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -48,16 +48,28 @@ class NotesFinder
def init_collection
if target
notes_on_target
+ elsif target_type
+ notes_of_target_type
else
notes_of_any_type
end
end
+ def notes_of_target_type
+ notes = notes_for_type(target_type)
+
+ search(notes)
+ end
+
+ def target_type
+ @params[:target_type]
+ end
+
def notes_of_any_type
types = %w(commit issue merge_request snippet)
note_relations = types.map { |t| notes_for_type(t) }
note_relations.map! { |notes| search(notes) }
- UnionFinder.new.find_union(note_relations, Note)
+ UnionFinder.new.find_union(note_relations, Note.includes(:author))
end
def noteables_for_type(noteable_type)
@@ -104,8 +116,7 @@ class NotesFinder
query = @params[:search]
return notes unless query
- pattern = "%#{query}%"
- notes.where(Note.arel_table[:note].matches(pattern))
+ notes.search(query)
end
# Notes changed since last fetch
diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb
index 760166b453f..d975f354a88 100644
--- a/app/finders/personal_access_tokens_finder.rb
+++ b/app/finders/personal_access_tokens_finder.rb
@@ -18,6 +18,7 @@ class PersonalAccessTokensFinder
def by_user(tokens)
return tokens unless @params[:user]
+
tokens.where(user: @params[:user])
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 8bfbe37c543..005612ededc 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -18,6 +18,8 @@
# non_archived: boolean
#
class ProjectsFinder < UnionFinder
+ include CustomAttributesFilter
+
attr_accessor :params
attr_reader :current_user, :project_ids_relation
@@ -28,7 +30,14 @@ class ProjectsFinder < UnionFinder
end
def execute
- collection = init_collection
+ user = params.delete(:user)
+ collection =
+ if user
+ PersonalProjectsFinder.new(user).execute(current_user)
+ else
+ init_collection
+ end
+
collection = by_ids(collection)
collection = by_personal(collection)
collection = by_starred(collection)
@@ -37,6 +46,7 @@ class ProjectsFinder < UnionFinder
collection = by_tags(collection)
collection = by_search(collection)
collection = by_archived(collection)
+ collection = by_custom_attributes(collection)
sort(collection)
end
@@ -114,13 +124,22 @@ class ProjectsFinder < UnionFinder
end
def sort(items)
- params[:sort].present? ? items.sort(params[:sort]) : items
+ params[:sort].present? ? items.sort(params[:sort]) : items.order_id_desc
end
def by_archived(projects)
- # Back-compatibility with the places where `params[:archived]` can be set explicitly to `false`
- params[:non_archived] = !Gitlab::Utils.to_boolean(params[:archived]) if params.key?(:archived)
-
- params[:non_archived] ? projects.non_archived : projects
+ if params[:non_archived]
+ projects.non_archived
+ elsif params.key?(:archived) # Back-compatibility with the places where `params[:archived]` can be set explicitly to `false`
+ if params[:archived] == 'only'
+ projects.archived
+ elsif Gitlab::Utils.to_boolean(params[:archived])
+ projects
+ else
+ projects.non_archived
+ end
+ else
+ projects
+ end
end
end
diff --git a/app/finders/runner_jobs_finder.rb b/app/finders/runner_jobs_finder.rb
new file mode 100644
index 00000000000..52340f94523
--- /dev/null
+++ b/app/finders/runner_jobs_finder.rb
@@ -0,0 +1,22 @@
+class RunnerJobsFinder
+ attr_reader :runner, :params
+
+ def initialize(runner, params = {})
+ @runner = runner
+ @params = params
+ end
+
+ def execute
+ items = @runner.builds
+ items = by_status(items)
+ items
+ end
+
+ private
+
+ def by_status(items)
+ return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
+
+ items.where(status: params[:status])
+ end
+end
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index c04f61de79c..d498a2d6d11 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -1,14 +1,30 @@
+# Snippets Finder
+#
+# Used to filter Snippets collections by a set of params
+#
+# Arguments.
+#
+# current_user - The current user, nil also can be used.
+# params:
+# visibility (integer) - Individual snippet visibility: Public(20), internal(10) or private(0).
+# project (Project) - Project related.
+# author (User) - Author related.
+#
+# params are optional
class SnippetsFinder < UnionFinder
- attr_accessor :current_user, :params
+ include Gitlab::Allowable
+ include FinderMethods
+
+ attr_accessor :current_user, :project, :params
def initialize(current_user, params = {})
@current_user = current_user
@params = params
+ @project = params[:project]
end
def execute
items = init_collection
- items = by_project(items)
items = by_author(items)
items = by_visibility(items)
@@ -18,25 +34,74 @@ class SnippetsFinder < UnionFinder
private
def init_collection
- items = Snippet.all
+ if project.present?
+ authorized_snippets_from_project
+ else
+ authorized_snippets
+ end
+ end
- accessible(items)
+ def authorized_snippets_from_project
+ if can?(current_user, :read_project_snippet, project)
+ if project.team.member?(current_user)
+ project.snippets
+ else
+ project.snippets.public_to_user(current_user)
+ end
+ else
+ Snippet.none
+ end
end
- def accessible(items)
- segments = []
- segments << items.public_to_user(current_user)
- segments << authorized_to_user(items) if current_user
+ def authorized_snippets
+ Snippet.where(feature_available_projects.or(not_project_related))
+ .public_or_visible_to_user(current_user)
+ end
+
+ # Returns a collection of projects that is either public or visible to the
+ # logged in user.
+ #
+ # A caller must pass in a block to modify individual parts of
+ # the query, e.g. to apply .with_feature_available_for_user on top of it.
+ # This is useful for performance as we can stick those additional filters
+ # at the bottom of e.g. the UNION.
+ def projects_for_user
+ return yield(Project.public_to_user) unless current_user
+
+ # If the current_user is allowed to see all projects,
+ # we can shortcut and just return.
+ return yield(Project.all) if current_user.full_private_access?
+
+ authorized_projects = yield(Project.where('EXISTS (?)', current_user.authorizations_for_projects))
+
+ levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
+ visible_projects = yield(Project.where(visibility_level: levels))
- find_union(segments, Snippet)
+ # We use a UNION here instead of OR clauses since this results in better
+ # performance.
+ union = Gitlab::SQL::Union.new([authorized_projects.select('projects.id'), visible_projects.select('projects.id')])
+
+ Project.from("(#{union.to_sql}) AS #{Project.table_name}")
+ end
+
+ def feature_available_projects
+ # Don't return any project related snippets if the user cannot read cross project
+ return table[:id].eq(nil) unless Ability.allowed?(current_user, :read_cross_project)
+
+ projects = projects_for_user do |part|
+ part.with_feature_available_for_user(:snippets, current_user)
+ end.select(:id)
+
+ arel_query = Arel::Nodes::SqlLiteral.new(projects.to_sql)
+ table[:project_id].in(arel_query)
end
- def authorized_to_user(items)
- items.where(
- 'author_id = :author_id
- OR project_id IN (:project_ids)',
- author_id: current_user.id,
- project_ids: current_user.authorized_projects.select(:id))
+ def not_project_related
+ table[:project_id].eq(nil)
+ end
+
+ def table
+ Snippet.arel_table
end
def by_visibility(items)
@@ -53,12 +118,6 @@ class SnippetsFinder < UnionFinder
items.where(author_id: params[:author].id)
end
- def by_project(items)
- return items unless params[:project]
-
- items.where(project_id: params[:project].id)
- end
-
def visibility_from_scope
case params[:scope].to_s
when 'are_private'
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index c358f23f541..150f4c7688b 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -13,6 +13,11 @@
#
class TodosFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+
+ requires_cross_project_access unless: -> { project? }
+
NONE = '0'.freeze
attr_accessor :current_user, :params
@@ -83,6 +88,8 @@ class TodosFinder
if project?
@project = Project.find(params[:project_id])
+ @project = nil if @project.pending_delete?
+
unless Ability.allowed?(current_user, :read_project, @project)
@project = nil
end
@@ -93,9 +100,14 @@ class TodosFinder
@project
end
- def projects(items)
- item_project_ids = items.reorder(nil).select(:project_id)
- ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids).execute
+ def project_ids(items)
+ ids = items.except(:order).select(:project_id)
+ if Gitlab::Database.mysql?
+ # To make UPDATE work on MySQL, wrap it in a SELECT with an alias
+ ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t")
+ end
+
+ ids
end
def type?
@@ -107,7 +119,7 @@ class TodosFinder
end
def sort(items)
- params[:sort] ? items.sort(params[:sort]) : items.reorder(id: :desc)
+ params[:sort] ? items.sort(params[:sort]) : items.order_id_desc
end
def by_action(items)
@@ -136,13 +148,12 @@ class TodosFinder
def by_project(items)
if project?
- items = items.where(project: project)
+ items.where(project: project)
else
- item_projects = projects(items)
- items = items.merge(item_projects).joins(:project)
- end
+ projects = Project.public_or_visible_to_user(current_user)
- items
+ items.joins(:project).merge(projects)
+ end
end
def by_state(items)
diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb
new file mode 100644
index 00000000000..65d6e019746
--- /dev/null
+++ b/app/finders/user_recent_events_finder.rb
@@ -0,0 +1,64 @@
+# Get user activity feed for projects common for a user and a logged in user
+#
+# - current_user: The user viewing the events
+# - user: The user for which to load the events
+# - params:
+# - offset: The page of events to return
+class UserRecentEventsFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+
+ requires_cross_project_access
+
+ attr_reader :current_user, :target_user, :params
+
+ LIMIT = 20
+
+ def initialize(current_user, target_user, params = {})
+ @current_user = current_user
+ @target_user = target_user
+ @params = params
+ end
+
+ def execute
+ recent_events(params[:offset] || 0)
+ .joins(:project)
+ .with_associations
+ .limit_recent(LIMIT, params[:offset])
+ end
+
+ private
+
+ def recent_events(offset)
+ sql = <<~SQL
+ (#{projects}) AS projects_for_join
+ JOIN (#{target_events.to_sql}) AS #{Event.table_name}
+ ON #{Event.table_name}.project_id = projects_for_join.id
+ SQL
+
+ # Workaround for https://github.com/rails/rails/issues/24193
+ Event.from([Arel.sql(sql)])
+ end
+
+ def target_events
+ Event.where(author: target_user)
+ end
+
+ def projects
+ # Compile a list of projects `current_user` interacted with
+ # and `target_user` is allowed to see.
+
+ authorized = target_user
+ .project_interactions
+ .joins(:project_authorizations)
+ .where(project_authorizations: { user: current_user })
+ .select(:id)
+
+ visible = target_user
+ .project_interactions
+ .where(visibility_level: [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC])
+ .select(:id)
+
+ Gitlab::SQL::Union.new([authorized, visible]).to_sql
+ end
+end
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index dbd50d1db7c..edde8022ec9 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -14,6 +14,9 @@
# external: boolean
#
class UsersFinder
+ include CreatedAtFilter
+ include CustomAttributesFilter
+
attr_accessor :current_user, :params
def initialize(current_user, params = {})
@@ -22,13 +25,15 @@ class UsersFinder
end
def execute
- users = User.all
+ users = User.all.order_id_desc
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 = by_created_at(users)
+ users = by_custom_attributes(users)
users
end
@@ -60,13 +65,13 @@ class UsersFinder
end
def by_external_identity(users)
- return users unless current_user.admin? && params[:extern_uid] && params[:provider]
+ 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 = users.where.not(external: true) unless current_user&.admin?
return users unless params[:external]
users.external
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 16136d02530..c037de33c22 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -1,39 +1,36 @@
module AppearancesHelper
def brand_title
- if brand_item && brand_item.title
- brand_item.title
- else
- 'GitLab Community Edition'
- end
+ brand_item&.title.presence || 'GitLab Community Edition'
end
def brand_image
- if brand_item.logo?
- image_tag brand_item.logo
- else
- nil
- end
+ image_tag(brand_item.logo) if brand_item&.logo?
end
def brand_text
markdown_field(brand_item, :description)
end
+ def brand_new_project_guidelines
+ markdown_field(brand_item, :new_project_guidelines)
+ end
+
def brand_item
- @appearance ||= Appearance.first
+ @appearance ||= Appearance.current
end
def brand_header_logo
- if brand_item && brand_item.header_logo?
+ if brand_item&.header_logo?
image_tag brand_item.header_logo
else
render 'shared/logo.svg'
end
end
- def custom_icon(icon_name, size: 16)
- # We can't simply do the below, because there are some .erb SVGs.
- # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
- render "shared/icons/#{icon_name}.svg", size: size
+ # Skip the 'GitLab' type logo when custom brand logo is set
+ def brand_header_logo_type
+ unless brand_item&.header_logo?
+ render 'shared/logo_type.svg'
+ end
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index dc7ff78f3df..701be97ee96 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -34,7 +34,7 @@ module ApplicationHelper
def project_icon(project_id, options = {})
project =
- if project_id.is_a?(Project)
+ if project_id.respond_to?(:avatar_url)
project_id
else
Project.find_by_full_path(project_id)
@@ -68,18 +68,32 @@ module ApplicationHelper
end
end
- def avatar_icon(user_or_email = nil, size = nil, scale = 2, only_path: true)
- user =
- if user_or_email.is_a?(User)
- user_or_email
- else
- User.find_by_any_email(user_or_email.try(:downcase))
- end
+ # Takes both user and email and returns the avatar_icon by
+ # user (preferred) or email.
+ def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
+ if user
+ avatar_icon_for_user(user, size, scale, only_path: only_path)
+ elsif email
+ avatar_icon_for_email(email, size, scale, only_path: only_path)
+ else
+ default_avatar
+ end
+ end
+ def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
+ user = User.find_by_any_email(email.try(:downcase))
+ if user
+ avatar_icon_for_user(user, size, scale, only_path: only_path)
+ else
+ gravatar_icon(email, size, scale)
+ end
+ end
+
+ def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true)
if user
user.avatar_url(size: size, only_path: only_path) || default_avatar
else
- gravatar_icon(user_or_email, size, scale)
+ gravatar_icon(nil, size, scale)
end
end
@@ -89,7 +103,7 @@ module ApplicationHelper
end
def default_avatar
- 'no_avatar.png'
+ asset_path('no_avatar.png')
end
def last_commit(project)
@@ -131,10 +145,7 @@ module ApplicationHelper
end
def body_data_page
- path = controller.controller_path.split('/')
- namespace = path.first if path.second
-
- [namespace, controller.controller_name, controller.action_name].compact.join(':')
+ [*controller.controller_path.split('/'), controller.action_name].compact.join(':')
end
# shortcut for gitlab config
@@ -181,7 +192,7 @@ module ApplicationHelper
end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
- return unless object.is_edited?
+ return unless object.edited?
content_tag :small, class: 'edited-text' do
output = content_tag(:span, 'Edited ')
@@ -205,7 +216,7 @@ module ApplicationHelper
end
def support_url
- current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
+ Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
end
def page_filter_path(options = {})
@@ -267,7 +278,11 @@ module ApplicationHelper
end
def page_class
- "issue-boards-page" if current_controller?(:boards)
+ class_names = []
+ class_names << 'issue-boards-page' if current_controller?(:boards)
+ class_names << 'with-performance-bar' if performance_bar_enabled?
+
+ class_names
end
# Returns active css class when condition returns true
@@ -285,7 +300,7 @@ module ApplicationHelper
def linkedin_url(user)
name = user.linkedin
- if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/}
+ if name =~ %r{\Ahttps?://(www\.)?linkedin\.com/in/}
name
else
"https://www.linkedin.com/in/#{name}"
@@ -294,18 +309,25 @@ module ApplicationHelper
def twitter_url(user)
name = user.twitter
- if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/}
+ if name =~ %r{\Ahttps?://(www\.)?twitter\.com/}
name
else
- "https://www.twitter.com/#{name}"
+ "https://twitter.com/#{name}"
end
end
- def can_toggle_new_nav?
- Rails.env.development?
+ def collapsed_sidebar?
+ cookies["sidebar_collapsed"] == "true"
+ end
+
+ def locale_path
+ asset_path("locale/#{Gitlab::I18n.locale}/app.js")
end
- def show_new_nav?
- cookies["new_nav"] == "true"
+ # Overridden in EE
+ def read_only_message
+ return unless Gitlab::Database.read_only?
+
+ _('You are on a read-only GitLab instance.')
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index ca326dd0627..b3b080e6dcf 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -1,21 +1,23 @@
module ApplicationSettingsHelper
- delegate :gravatar_enabled?,
- :signup_enabled?,
- :signin_enabled?,
+ extend self
+
+ delegate :allow_signup?,
+ :gravatar_enabled?,
+ :password_authentication_enabled_for_web?,
:akismet_enabled?,
:koding_enabled?,
- to: :current_application_settings
+ to: :'Gitlab::CurrentSettings.current_application_settings'
def user_oauth_applications?
- current_application_settings.user_oauth_applications
+ Gitlab::CurrentSettings.user_oauth_applications
end
def allowed_protocols_present?
- current_application_settings.enabled_git_access_protocol.present?
+ Gitlab::CurrentSettings.enabled_git_access_protocol.present?
end
def enabled_protocol
- case current_application_settings.enabled_git_access_protocol
+ case Gitlab::CurrentSettings.enabled_git_access_protocol
when 'http'
gitlab_config.protocol
when 'ssh'
@@ -26,25 +28,25 @@ module ApplicationSettingsHelper
def enabled_project_button(project, protocol)
case protocol
when 'ssh'
- ssh_clone_button(project, 'bottom', append_link: false)
+ ssh_clone_button(project, append_link: false)
else
- http_clone_button(project, 'bottom', append_link: false)
+ http_clone_button(project, append_link: false)
end
end
# Return a group of checkboxes that use Bootstrap's button plugin for a
# toggle button effect.
- def restricted_level_checkboxes(help_block_id)
- Gitlab::VisibilityLevel.options.map do |name, level|
+ def restricted_level_checkboxes(help_block_id, checkbox_name)
+ Gitlab::VisibilityLevel.values.map do |level|
checked = restricted_visibility_levels(true).include?(level)
css_class = checked ? 'active' : ''
- checkbox_name = "application_setting[restricted_visibility_levels][]"
+ tag_name = "application_setting_visibility_level_#{level}"
- label_tag(name, class: css_class) do
+ label_tag(tag_name, class: css_class) do
check_box_tag(checkbox_name, level, checked,
autocomplete: 'off',
'aria-describedby' => help_block_id,
- id: name) + visibility_level_icon(level) + name
+ id: tag_name) + visibility_level_icon(level) + visibility_level_label(level)
end
end
end
@@ -53,7 +55,7 @@ module ApplicationSettingsHelper
# toggle button effect.
def import_sources_checkboxes(help_block_id)
Gitlab::ImportSources.options.map do |name, source|
- checked = current_application_settings.import_sources.include?(source)
+ checked = Gitlab::CurrentSettings.import_sources.include?(source)
css_class = checked ? 'active' : ''
checkbox_name = 'application_setting[import_sources][]'
@@ -68,27 +70,183 @@ module ApplicationSettingsHelper
def oauth_providers_checkboxes
button_based_providers.map do |source|
- disabled = current_application_settings.disabled_oauth_sign_in_sources.include?(source.to_s)
+ disabled = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources.include?(source.to_s)
css_class = 'btn'
css_class << ' active' unless disabled
checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]'
label_tag(checkbox_name, class: css_class) do
check_box_tag(checkbox_name, source, !disabled,
- autocomplete: 'off') + Gitlab::OAuth::Provider.label_for(source)
+ autocomplete: 'off') + Gitlab::Auth::OAuth::Provider.label_for(source)
end
end
end
- def repository_storages_options_for_select
+ def key_restriction_options_for_select(type)
+ bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits|
+ ["Must be at least #{bits} bits", bits]
+ end
+
+ [
+ ['Are allowed', 0],
+ *bit_size_options,
+ ['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE]
+ ]
+ end
+
+ def repository_storages_options_for_select(selected)
options = Gitlab.config.repositories.storages.map do |name, storage|
- ["#{name} - #{storage['path']}", name]
+ ["#{name} - #{storage['gitaly_address']}", name]
end
- options_for_select(options, @application_setting.repository_storages)
+ options_for_select(options, selected)
end
def sidekiq_queue_options_for_select
options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
end
+
+ def circuitbreaker_failure_count_help_text
+ health_link = link_to(s_('AdminHealthPageLink|health page'), admin_health_check_path)
+ api_link = link_to(s_('CircuitBreakerApiLink|circuitbreaker api'), help_page_path("api/repository_storage_health"))
+ message = _("The number of failures of after which GitLab will completely "\
+ "prevent access to the storage. The number of failures can be "\
+ "reset in the admin interface: %{link_to_health_page} or using "\
+ "the %{api_documentation_link}.")
+ message = message % { link_to_health_page: health_link, api_documentation_link: api_link }
+
+ message.html_safe
+ end
+
+ def circuitbreaker_access_retries_help_text
+ _('The number of attempts GitLab will make to access a storage.')
+ end
+
+ def circuitbreaker_failure_reset_time_help_text
+ _("The time in seconds GitLab will keep failure information. When no "\
+ "failures occur during this time, information about the mount is reset.")
+ end
+
+ def circuitbreaker_storage_timeout_help_text
+ _("The time in seconds GitLab will try to access storage. After this time a "\
+ "timeout error will be raised.")
+ end
+
+ def circuitbreaker_check_interval_help_text
+ _("The time in seconds between storage checks. When a previous check did "\
+ "complete yet, GitLab will skip a check.")
+ end
+
+ def visible_attributes
+ [
+ :admin_notification_email,
+ :after_sign_out_path,
+ :after_sign_up_text,
+ :akismet_api_key,
+ :akismet_enabled,
+ :authorized_keys_enabled,
+ :auto_devops_enabled,
+ :auto_devops_domain,
+ :circuitbreaker_access_retries,
+ :circuitbreaker_check_interval,
+ :circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_storage_timeout,
+ :clientside_sentry_dsn,
+ :clientside_sentry_enabled,
+ :container_registry_token_expire_delay,
+ :default_artifacts_expire_in,
+ :default_branch_protection,
+ :default_group_visibility,
+ :default_project_visibility,
+ :default_projects_limit,
+ :default_snippet_visibility,
+ :disabled_oauth_sign_in_sources,
+ :domain_blacklist_enabled,
+ :domain_blacklist_raw,
+ :domain_whitelist_raw,
+ :dsa_key_restriction,
+ :ecdsa_key_restriction,
+ :ed25519_key_restriction,
+ :email_author_in_body,
+ :enabled_git_access_protocol,
+ :gitaly_timeout_default,
+ :gitaly_timeout_medium,
+ :gitaly_timeout_fast,
+ :gravatar_enabled,
+ :hashed_storage_enabled,
+ :help_page_hide_commercial_content,
+ :help_page_support_url,
+ :help_page_text,
+ :home_page_url,
+ :housekeeping_bitmaps_enabled,
+ :housekeeping_enabled,
+ :housekeeping_full_repack_period,
+ :housekeeping_gc_period,
+ :housekeeping_incremental_repack_period,
+ :html_emails_enabled,
+ :import_sources,
+ :koding_enabled,
+ :koding_url,
+ :max_artifacts_size,
+ :max_attachment_size,
+ :max_pages_size,
+ :metrics_enabled,
+ :metrics_host,
+ :metrics_method_call_threshold,
+ :metrics_packet_size,
+ :metrics_pool_size,
+ :metrics_port,
+ :metrics_sample_interval,
+ :metrics_timeout,
+ :pages_domain_verification_enabled,
+ :password_authentication_enabled_for_web,
+ :password_authentication_enabled_for_git,
+ :performance_bar_allowed_group_id,
+ :performance_bar_enabled,
+ :plantuml_enabled,
+ :plantuml_url,
+ :polling_interval_multiplier,
+ :project_export_enabled,
+ :prometheus_metrics_enabled,
+ :recaptcha_enabled,
+ :recaptcha_private_key,
+ :recaptcha_site_key,
+ :repository_checks_enabled,
+ :repository_storages,
+ :require_two_factor_authentication,
+ :restricted_visibility_levels,
+ :rsa_key_restriction,
+ :send_user_confirmation_email,
+ :sentry_dsn,
+ :sentry_enabled,
+ :session_expire_delay,
+ :shared_runners_enabled,
+ :shared_runners_text,
+ :sidekiq_throttling_enabled,
+ :sidekiq_throttling_factor,
+ :sidekiq_throttling_queues,
+ :sign_in_text,
+ :signup_enabled,
+ :terminal_max_session_time,
+ :throttle_unauthenticated_enabled,
+ :throttle_unauthenticated_requests_per_period,
+ :throttle_unauthenticated_period_in_seconds,
+ :throttle_authenticated_web_enabled,
+ :throttle_authenticated_web_requests_per_period,
+ :throttle_authenticated_web_period_in_seconds,
+ :throttle_authenticated_api_enabled,
+ :throttle_authenticated_api_requests_per_period,
+ :throttle_authenticated_api_period_in_seconds,
+ :two_factor_grace_period,
+ :unique_ips_limit_enabled,
+ :unique_ips_limit_per_user,
+ :unique_ips_limit_time_window,
+ :usage_ping_enabled,
+ :user_default_external,
+ :user_oauth_applications,
+ :version_check_enabled,
+ :allow_local_requests_from_hooks_and_services
+ ]
+ end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 9c71d6c7f4c..c109954f3a3 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -3,7 +3,7 @@ module AuthHelper
FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze
def ldap_enabled?
- Gitlab::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.enabled?
end
def omniauth_enabled?
@@ -15,11 +15,11 @@ module AuthHelper
end
def auth_providers
- Gitlab::OAuth::Provider.providers
+ Gitlab::Auth::OAuth::Provider.providers
end
def label_for_provider(name)
- Gitlab::OAuth::Provider.label_for(name)
+ Gitlab::Auth::OAuth::Provider.label_for(name)
end
def form_based_provider?(name)
@@ -39,7 +39,7 @@ module AuthHelper
end
def enabled_button_based_providers
- disabled_providers = current_application_settings.disabled_oauth_sign_in_sources || []
+ disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || []
button_based_providers.map(&:to_s) - disabled_providers
end
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
new file mode 100644
index 00000000000..16451993e93
--- /dev/null
+++ b/app/helpers/auto_devops_helper.rb
@@ -0,0 +1,36 @@
+module AutoDevopsHelper
+ def show_auto_devops_callout?(project)
+ Feature.get(:auto_devops_banner_disabled).off? &&
+ show_callout?('auto_devops_settings_dismissed') &&
+ can?(current_user, :admin_pipeline, project) &&
+ project.has_auto_devops_implicitly_disabled? &&
+ !project.repository.gitlab_ci_yml &&
+ !project.ci_service
+ end
+
+ def auto_devops_warning_message(project)
+ if missing_auto_devops_service?(project)
+ params = {
+ kubernetes: link_to('Kubernetes cluster', project_clusters_path(project))
+ }
+
+ if missing_auto_devops_domain?(project)
+ _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params
+ else
+ _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params
+ end
+ elsif missing_auto_devops_domain?(project)
+ _('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
+ end
+ end
+
+ private
+
+ def missing_auto_devops_domain?(project)
+ !(project.auto_devops || project.build_auto_devops)&.has_domain?
+ end
+
+ def missing_auto_devops_service?(project)
+ !project.deployment_platform&.active?
+ end
+end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index bbe7f3c8fb4..21b6c0a8ad5 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -8,24 +8,46 @@ module AvatarsHelper
}))
end
+ def user_avatar_url_for(options = {})
+ if options[:url]
+ options[:url]
+ elsif options[:user]
+ avatar_icon_for_user(options[:user], options[:size])
+ else
+ avatar_icon_for_email(options[:user_email], options[:size])
+ end
+ end
+
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_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
- data_attributes = { container: 'body' }
+
+ avatar_url = user_avatar_url_for(options.merge(size: avatar_size))
+
+ has_tooltip = options[:has_tooltip].nil? ? true : options[:has_tooltip]
+ data_attributes = options[:data] || {}
+ css_class = %W[avatar s#{avatar_size}].push(*options[:css_class])
+
+ if has_tooltip
+ css_class.push('has-tooltip')
+ data_attributes[:container] = 'body'
+ end
if options[:lazy]
+ css_class << 'lazy'
data_attributes[:src] = avatar_url
+ avatar_url = LazyImageTagHelper.placeholder_image
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: data_attributes
- )
+ image_options = {
+ alt: "#{user_name}'s avatar",
+ src: avatar_url,
+ data: data_attributes,
+ class: css_class,
+ title: user_name
+ }
+
+ tag(:img, image_options)
end
def user_avatar(options = {})
diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb
index 024cf38469e..86b19368cfd 100644
--- a/app/helpers/award_emoji_helper.rb
+++ b/app/helpers/award_emoji_helper.rb
@@ -7,7 +7,7 @@ module AwardEmojiHelper
if awardable.for_personal_snippet?
toggle_award_emoji_snippet_note_path(awardable.noteable, awardable)
else
- toggle_award_emoji_namespace_project_note_path(@project.namespace, @project, awardable.id)
+ toggle_award_emoji_project_note_path(@project, awardable.id)
end
else
url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable])
diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb
index d1dc4d94560..089d9e3e387 100644
--- a/app/helpers/blame_helper.rb
+++ b/app/helpers/blame_helper.rb
@@ -11,11 +11,15 @@ module BlameHelper
end
def age_map_class(commit_date, duration)
- commit_date_days_ago = (duration[:now] - commit_date).to_i / 1.day
- # Numbers 0 to 10 come from this calculation, but only commits on the oldest
- # day get number 10 (all other numbers can be multiple days), so the range
- # is normalized to 0-9
- age_group = [(10 * commit_date_days_ago) / duration[:started_days_ago], 9].min
- "blame-commit-age-#{age_group}"
+ if duration[:started_days_ago] == 0
+ "blame-commit-age-0"
+ else
+ commit_date_days_ago = (duration[:now] - commit_date).to_i / 1.day
+ # Numbers 0 to 10 come from this calculation, but only commits on the oldest
+ # day get number 10 (all other numbers can be multiple days), so the range
+ # is normalized to 0-9
+ age_group = [(10 * commit_date_days_ago) / duration[:started_days_ago], 9].min
+ "blame-commit-age-#{age_group}"
+ end
end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 3efa7c36057..2b440e4d584 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -1,6 +1,8 @@
module BlobHelper
def highlight(blob_name, blob_content, repository: nil, plain: false)
+ plain ||= blob_content.length > Blob::MAXIMUM_TEXT_HIGHLIGHT_SIZE
highlighted = Gitlab::Highlight.highlight(blob_name, blob_content, plain: plain, repository: repository)
+
raw %(<pre class="code highlight"><code>#{highlighted}</code></pre>)
end
@@ -8,40 +10,41 @@ module BlobHelper
%w(credits changelog news copying copyright license authors)
end
- def edit_path(project = @project, ref = @ref, path = @path, options = {})
- namespace_project_edit_blob_path(project.namespace, project,
- tree_join(ref, path),
- options[:link_opts])
+ def edit_blob_path(project = @project, ref = @ref, path = @path, options = {})
+ project_edit_blob_path(project,
+ tree_join(ref, path),
+ options[:link_opts])
end
- def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
- blob = options.delete(:blob)
- blob ||= project.repository.blob_at(ref, path) rescue nil
+ def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
+ "#{ide_path}/project#{edit_blob_path(project, ref, path, options)}"
+ end
- return unless blob && blob.readable_text?
+ def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
+ return unless blob = readable_blob(options, path, project, ref)
common_classes = "btn js-edit-blob #{options[:extra_class]}"
- if !on_top_of_branch?(project, ref)
- button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
- # This condition applies to anonymous or users who can edit directly
- elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
- link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
- elsif current_user && can?(current_user, :fork_project, project)
- continue_params = {
- to: edit_path(project, ref, path, options),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now
- }
- fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
-
- button_tag 'Edit',
- class: "#{common_classes} js-edit-blob-link-fork-toggler",
- data: { action: 'edit', fork_path: fork_path }
- end
+ edit_button_tag(blob,
+ common_classes,
+ _('Edit'),
+ edit_blob_path(project, ref, path, options),
+ project,
+ ref)
+ end
+
+ def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ return unless blob = readable_blob(options, path, project, ref)
+
+ edit_button_tag(blob,
+ 'btn btn-default',
+ _('Web IDE'),
+ ide_edit_path(project, ref, path, options),
+ project,
+ ref)
end
- def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
+ def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
return unless current_user
blob = project.repository.blob_at(ref, path) rescue nil
@@ -57,21 +60,12 @@ module BlobHelper
elsif can_modify_blob?(blob, project, ref)
button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
elsif can?(current_user, :fork_project, project)
- continue_params = {
- to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to #{action} this file again.",
- notice_now: edit_in_new_fork_notice_now
- }
- fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
-
- button_tag label,
- class: "#{common_classes} js-edit-blob-link-fork-toggler",
- data: { action: action, fork_path: fork_path }
+ edit_fork_button_tag(common_classes, project, label, edit_modify_file_fork_params(action), action)
end
end
def replace_blob_link(project = @project, ref = @ref, path = @path)
- modify_file_link(
+ modify_file_button(
project,
ref,
path,
@@ -83,7 +77,7 @@ module BlobHelper
end
def delete_blob_link(project = @project, ref = @ref, path = @path)
- modify_file_link(
+ modify_file_button(
project,
ref,
path,
@@ -118,20 +112,24 @@ module BlobHelper
icon("#{file_type_icon_class('file', mode, name)} fw")
end
- def blob_raw_url
+ def blob_raw_url(only_path: false)
if @build && @entry
- raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
+ raw_project_job_artifacts_url(@project, @build, path: @entry.path, only_path: only_path)
elsif @snippet
if @snippet.project_id
- raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
+ raw_project_snippet_url(@project, @snippet, only_path: only_path)
else
- raw_snippet_path(@snippet)
+ raw_snippet_url(@snippet, only_path: only_path)
end
elsif @blob
- namespace_project_raw_path(@project.namespace, @project, @id)
+ project_raw_url(@project, @id, only_path: only_path)
end
end
+ def blob_raw_path
+ blob_raw_url(only_path: true)
+ end
+
# SVGs can contain malicious JavaScript; only include whitelisted
# elements and attributes. Note that this whitelist is by no means complete
# and may omit some elements.
@@ -228,14 +226,14 @@ module BlobHelper
return if blob.empty?
if blob.raw_binary? || blob.stored_externally?
- icon = icon('download')
+ icon = sprite_icon('download')
title = 'Download'
else
icon = icon('file-code-o')
title = 'Open raw'
end
- link_to icon, blob_raw_url, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
+ link_to icon, blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
end
def blob_render_error_reason(viewer)
@@ -270,7 +268,7 @@ module BlobHelper
options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' })
end
- options << link_to('download it', blob_raw_url, target: '_blank', rel: 'noopener noreferrer')
+ options << link_to('download it', blob_raw_path, target: '_blank', rel: 'noopener noreferrer')
options
end
@@ -279,14 +277,65 @@ module BlobHelper
options = []
if can?(current_user, :create_issue, project)
- options << link_to("submit an issue", new_namespace_project_issue_path(project.namespace, project))
+ options << link_to("submit an issue", new_project_issue_path(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))
+ options << link_to("create a merge request", project_new_merge_request_path(project))
end
options
end
+
+ def readable_blob(options, path, project, ref)
+ blob = options.delete(:blob)
+ blob ||= project.repository.blob_at(ref, path) rescue nil
+
+ blob if blob&.readable_text?
+ end
+
+ def edit_blob_fork_params(path)
+ {
+ to: path,
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now
+ }
+ end
+
+ def edit_modify_file_fork_params(action)
+ {
+ to: request.fullpath,
+ notice: edit_in_new_fork_notice_action(action),
+ notice_now: edit_in_new_fork_notice_now
+ }
+ end
+
+ def edit_fork_button_tag(common_classes, project, label, params, action = 'edit')
+ fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: params)
+
+ button_tag label,
+ class: "#{common_classes} js-edit-blob-link-fork-toggler",
+ data: { action: action, fork_path: fork_path }
+ end
+
+ def edit_disabled_button_tag(button_text, common_classes)
+ button_tag(button_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' })
+ end
+
+ def edit_link_tag(link_text, edit_path, common_classes)
+ link_to link_text, edit_path, class: "#{common_classes} btn-sm"
+ end
+
+ def edit_button_tag(blob, common_classes, text, edit_path, project, ref)
+ if !on_top_of_branch?(project, ref)
+ edit_disabled_button_tag(text, common_classes)
+ # This condition only applies to users who are logged in
+ # Web IDE (Beta) requires the user to have this feature enabled
+ elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
+ edit_link_tag(text, edit_path, common_classes)
+ elsif current_user && can?(current_user, :fork_project, project)
+ edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path))
+ end
+ end
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index e2df52e3833..275e892b2e6 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -1,15 +1,87 @@
module BoardsHelper
- def board_data
- board = @board || @boards.first
+ def board
+ @board ||= @board || @boards.first
+ end
+ def board_data
{
- endpoint: namespace_project_boards_path(@project.namespace, @project),
+ boards_endpoint: @boards_endpoint,
+ lists_endpoint: board_lists_path(board),
board_id: board.id,
- disabled: "#{!can?(current_user, :admin_list, @project)}",
- issue_link_base: namespace_project_issues_path(@project.namespace, @project),
+ disabled: "#{!can?(current_user, :admin_list, current_board_parent)}",
+ issue_link_base: build_issue_link_base,
root_path: root_path,
- bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project),
+ bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar)
}
end
+
+ def build_issue_link_base
+ if board.group_board?
+ "#{group_path(@board.group)}/:project_path/issues"
+ else
+ project_issues_path(@project)
+ end
+ end
+
+ def board_base_url
+ if board.group_board?
+ group_boards_url(@group)
+ else
+ project_boards_path(@project)
+ end
+ end
+
+ def multiple_boards_available?
+ current_board_parent.multiple_issue_boards_available?
+ end
+
+ def current_board_path(board)
+ @current_board_path ||= if board.group_board?
+ group_board_path(current_board_parent, board)
+ else
+ project_board_path(current_board_parent, board)
+ end
+ end
+
+ def current_board_parent
+ @current_board_parent ||= @group || @project
+ end
+
+ def can_admin_issue?
+ can?(current_user, :admin_issue, current_board_parent)
+ end
+
+ def board_list_data
+ {
+ toggle: "dropdown",
+ list_labels_path: labels_filter_path(true),
+ labels: labels_filter_path(true),
+ labels_endpoint: @labels_endpoint,
+ namespace_path: @namespace_path,
+ project_path: @project&.path,
+ group_path: @group&.path
+ }
+ end
+
+ def board_sidebar_user_data
+ dropdown_options = issue_assignees_dropdown_options
+
+ {
+ toggle: 'dropdown',
+ field_name: 'issue[assignee_ids][]',
+ first_user: current_user&.username,
+ current_user: 'true',
+ project_id: @project&.id,
+ group_id: @group&.id,
+ null_user: 'true',
+ multi_select: 'true',
+ 'dropdown-header': dropdown_options[:data][:'dropdown-header'],
+ 'max-select': dropdown_options[:data][:'max-select']
+ }
+ end
+
+ def boards_link_text
+ s_("IssueBoards|Board")
+ end
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index 59519c1335b..07b1fc3d7cf 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -1,21 +1,4 @@
module BranchesHelper
- def filter_branches_path(options = {})
- exist_opts = {
- search: params[:search],
- sort: params[:sort]
- }
-
- options = exist_opts.merge(options)
-
- namespace_project_branches_path(@project.namespace, @project, @id, options)
- end
-
- def can_push_branch?(project, branch_name)
- return false unless project.repository.branch_exists?(branch_name)
-
- ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch_name)
- end
-
def project_branches
options_for_select(@project.repository.branch_names, @project.default_branch)
end
@@ -23,4 +6,12 @@ module BranchesHelper
def protected_branch?(project, branch)
ProtectedBranch.protected?(project, branch.name)
end
+
+ def diverging_count_label(count)
+ if count >= Repository::MAX_DIVERGING_COUNT
+ "#{Repository::MAX_DIVERGING_COUNT - 1}+"
+ else
+ count.to_s
+ end
+ end
end
diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb
new file mode 100644
index 00000000000..e88fe6bcd7e
--- /dev/null
+++ b/app/helpers/breadcrumbs_helper.rb
@@ -0,0 +1,33 @@
+module BreadcrumbsHelper
+ def add_to_breadcrumbs(text, link)
+ @breadcrumbs_extra_links ||= []
+ @breadcrumbs_extra_links.push({
+ text: text,
+ link: link
+ })
+ end
+
+ def breadcrumb_title_link
+ return @breadcrumb_link if @breadcrumb_link
+
+ request.path
+ end
+
+ def breadcrumb_title(title)
+ return if defined?(@breadcrumb_title)
+
+ @breadcrumb_title = title
+ end
+
+ def breadcrumb_list_item(link)
+ content_tag "li" do
+ link + sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle")
+ end
+ end
+
+ def add_to_breadcrumb_dropdown(link, location: :before)
+ @breadcrumb_dropdown_links ||= {}
+ @breadcrumb_dropdown_links[location] ||= []
+ @breadcrumb_dropdown_links[location] << link
+ end
+end
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index f0a0d245dc0..4ec63fdaffc 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -20,8 +20,7 @@ module BuildsHelper
def javascript_build_options
{
- page_url: namespace_project_job_url(@project.namespace, @project, @build),
- build_url: namespace_project_job_url(@project.namespace, @project, @build, :json),
+ page_path: project_job_path(@project, @build),
build_status: @build.status,
build_stage: @build.stage,
log_state: ''
@@ -30,8 +29,8 @@ module BuildsHelper
def build_failed_issue_options
{
- title: "Build Failed ##{@build.id}",
- description: namespace_project_job_url(@project.namespace, @project, @build)
+ title: "Job Failed ##{@build.id}",
+ description: project_job_url(@project, @build)
}
end
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index ba84dbe4a7a..3605d6a3c95 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -20,6 +20,9 @@ module ButtonHelper
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
title = data[:title] || 'Copy to clipboard'
+ button_text = data[:button_text] || ''
+ hide_tooltip = data[:hide_tooltip] || false
+ hide_button_icon = data[:hide_button_icon] || false
# This supports code in app/assets/javascripts/copy_to_clipboard.js that
# works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
@@ -35,55 +38,54 @@ module ButtonHelper
target = data.delete(:target)
data[:clipboard_target] = target if target
- data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data)
+ unless hide_tooltip
+ data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data)
+ end
- content_tag :button,
- icon('clipboard', 'aria-hidden': 'true'),
+ button_attributes = {
class: "btn #{css_class}",
data: data,
type: :button,
title: title,
- aria: {
- label: title
- }
- end
+ aria: { label: title }
+ }
- def http_clone_button(project, placement = 'right', append_link: true)
- klass = 'http-selector'
- klass << ' has-tooltip' if current_user.try(:require_password?) || current_user.try(:require_personal_access_token?)
+ content_tag :button, button_attributes do
+ concat(icon('clipboard', 'aria-hidden': 'true')) unless hide_button_icon
+ concat(button_text)
+ end
+ end
+ def http_clone_button(project, append_link: true)
protocol = gitlab_config.protocol.upcase
+ dropdown_description = http_dropdown_description(protocol)
+ append_url = project.http_url_to_repo if append_link
+
+ dropdown_item_with_description(protocol, dropdown_description, href: append_url)
+ end
+
+ def http_dropdown_description(protocol)
+ if current_user.try(:require_password_creation_for_git?)
+ _("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
+ else
+ _("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
+ end
+ end
- tooltip_title =
- if current_user.try(:require_password?)
- _("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
- else
- _("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
- end
+ def ssh_clone_button(project, append_link: true)
+ dropdown_description = _("You won't be able to pull or push project code via SSH until you add an SSH key to your profile") if current_user.try(:require_ssh_key?)
+ append_url = project.ssh_url_to_repo if append_link
- content_tag (append_link ? :a : :span), protocol,
- class: klass,
- href: (project.http_url_to_repo if append_link),
- data: {
- html: true,
- placement: placement,
- container: 'body',
- title: tooltip_title
- }
+ dropdown_item_with_description('SSH', dropdown_description, href: append_url)
end
- def ssh_clone_button(project, placement = 'right', append_link: true)
- klass = 'ssh-selector'
- klass << ' has-tooltip' if current_user.try(:require_ssh_key?)
+ def dropdown_item_with_description(title, description, href: nil)
+ button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title')
+ button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description
- content_tag (append_link ? :a : :span), 'SSH',
- class: klass,
- href: (project.ssh_url_to_repo if append_link),
- data: {
- html: true,
- placement: placement,
- container: 'body',
- title: _('Add an SSH key to your profile to pull or push via SSH.')
- }
+ content_tag (href ? :a : :span),
+ (href ? button_content : title),
+ class: "#{title.downcase}-selector",
+ href: (href if href)
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 21c0eb8b54c..636316da80a 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -6,11 +6,6 @@
# See 'detailed_status?` method and `Gitlab::Ci::Status` module.
#
module CiStatusHelper
- def ci_status_path(pipeline)
- project = pipeline.project
- namespace_project_pipeline_path(project.namespace, project, pipeline)
- end
-
def ci_label_for_status(status)
if detailed_status?(status)
return status.label
@@ -63,34 +58,34 @@ module CiStatusHelper
def ci_icon_for_status(status)
if detailed_status?(status)
- return custom_icon(status.icon)
+ return sprite_icon(status.icon)
end
icon_name =
case status
when 'success'
- 'icon_status_success'
+ 'status_success'
when 'success_with_warnings'
- 'icon_status_warning'
+ 'status_warning'
when 'failed'
- 'icon_status_failed'
+ 'status_failed'
when 'pending'
- 'icon_status_pending'
+ 'status_pending'
when 'running'
- 'icon_status_running'
+ 'status_running'
when 'play'
- 'icon_play'
+ 'play'
when 'created'
- 'icon_status_created'
+ 'status_created'
when 'skipped'
- 'icon_status_skipped'
+ 'status_skipped'
when 'manual'
- 'icon_status_manual'
+ 'status_manual'
else
- 'icon_status_canceled'
+ 'status_canceled'
end
- custom_icon(icon_name)
+ sprite_icon(icon_name, size: 16)
end
def pipeline_status_cache_key(pipeline_status)
@@ -99,10 +94,7 @@ module CiStatusHelper
def render_project_pipeline_status(pipeline_status, tooltip_placement: 'auto left')
project = pipeline_status.project
- path = pipelines_namespace_project_commit_path(
- project.namespace,
- project,
- pipeline_status.sha)
+ path = pipelines_project_commit_path(project, pipeline_status.sha)
render_status_with_link(
'commit',
@@ -113,10 +105,7 @@ module CiStatusHelper
def render_commit_status(commit, ref: nil, tooltip_placement: 'auto left')
project = commit.project
- path = pipelines_namespace_project_commit_path(
- project.namespace,
- project,
- commit)
+ path = pipelines_project_commit_path(project, commit)
render_status_with_link(
'commit',
@@ -127,7 +116,7 @@ module CiStatusHelper
def render_pipeline_status(pipeline, tooltip_placement: 'auto left')
project = pipeline.project
- path = namespace_project_pipeline_path(project.namespace, project, pipeline)
+ path = project_pipeline_path(project, pipeline)
render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement)
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
new file mode 100644
index 00000000000..7e4eb06b99d
--- /dev/null
+++ b/app/helpers/clusters_helper.rb
@@ -0,0 +1,5 @@
+module ClustersHelper
+ def has_multiple_clusters?(project)
+ false
+ end
+end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 0accd1f8d77..0333c29e2fd 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -30,7 +30,7 @@ module CommitsHelper
crumbs = content_tag(:li) do
link_to(
@project.path,
- namespace_project_commits_path(@project.namespace, @project, @ref)
+ project_commits_path(@project, @ref)
)
end
@@ -42,8 +42,7 @@ module CommitsHelper
# The text is just the individual part, but the link needs all the parts before it
link_to(
part,
- namespace_project_commits_path(
- @project.namespace,
+ project_commits_path(
@project,
tree_join(@ref, parts[0..i].join('/'))
)
@@ -61,23 +60,33 @@ module CommitsHelper
branches.include?(project.default_branch) ? branches.delete(project.default_branch) : branches.pop
end
+ # Returns a link formatted as a commit branch link
+ def commit_branch_link(url, text)
+ link_to(url, class: 'label label-gray ref-name branch-link') do
+ sprite_icon('fork', size: 16, css_class: 'fork-svg') + "#{text}"
+ end
+ end
+
# Returns the sorted alphabetically links to branches, separated by a comma
def commit_branches_links(project, branches)
branches.sort.map do |branch|
- link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do
- icon('code-fork') + " #{branch}"
- end
- end.join(" ").html_safe
+ commit_branch_link(project_ref_path(project, branch), branch)
+ end.join(' ').html_safe
+ end
+
+ # Returns a link formatted as a commit tag link
+ def commit_tag_link(url, text)
+ link_to(url, class: 'label label-gray ref-name') do
+ icon('tag', class: 'append-right-5') + "#{text}"
+ end
end
# Returns the sorted links to tags, separated by a comma
def commit_tags_links(project, tags)
sorted = VersionSorter.rsort(tags)
sorted.map do |tag|
- link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do
- icon('tag') + " #{tag}"
- end
- end.join(" ").html_safe
+ commit_tag_link(project_ref_path(project, tag), tag)
+ end.join(' ').html_safe
end
def link_to_browse_code(project, commit)
@@ -86,20 +95,20 @@ module CommitsHelper
if @path.blank?
return link_to(
_("Browse Files"),
- namespace_project_tree_path(project.namespace, project, commit),
+ project_tree_path(project, commit),
class: "btn btn-default"
)
elsif @repo.blob_at(commit.id, @path)
return link_to(
_("Browse File"),
- namespace_project_blob_path(project.namespace, project,
+ project_blob_path(project,
tree_join(commit.id, @path)),
class: "btn btn-default"
)
elsif @path.present?
return link_to(
_("Browse Directory"),
- namespace_project_tree_path(project.namespace, project,
+ project_tree_path(project,
tree_join(commit.id, @path)),
class: "btn btn-default"
)
@@ -114,6 +123,10 @@ module CommitsHelper
commit_action_link('cherry-pick', commit, continue_to_path, btn_class: btn_class, has_tooltip: has_tooltip)
end
+ def commit_signature_badge_classes(additional_classes)
+ %w(btn gpg-status-box) + Array(additional_classes)
+ end
+
protected
# Private: Returns a link to a person. If the person has a matching user and
@@ -125,16 +138,16 @@ module CommitsHelper
# avatar: true will prepend the avatar image
# size: size of the avatar image in px
def commit_person_link(commit, options = {})
- user = commit.send(options[:source])
+ user = commit.public_send(options[:source]) # rubocop:disable GitlabSecurity/PublicSend
- source_name = clean(commit.send "#{options[:source]}_name".to_sym)
- source_email = clean(commit.send "#{options[:source]}_email".to_sym)
+ source_name = clean(commit.public_send(:"#{options[:source]}_name")) # rubocop:disable GitlabSecurity/PublicSend
+ source_email = clean(commit.public_send(:"#{options[:source]}_email")) # rubocop:disable GitlabSecurity/PublicSend
person_name = user.try(:name) || source_name
text =
if options[:avatar]
- %Q{<span class="commit-#{options[:source]}-name">#{person_name}</span>}
+ content_tag(:span, person_name, class: "commit-#{options[:source]}-name")
else
person_name
end
@@ -145,9 +158,9 @@ module CommitsHelper
}
if user.nil?
- mail_to(source_email, text.html_safe, options)
+ mail_to(source_email, text, options)
else
- link_to(text.html_safe, user_path(user), options)
+ link_to(text, user_path(user), options)
end
end
@@ -165,7 +178,7 @@ module CommitsHelper
notice: "#{edit_in_new_fork_notice} Try to #{action} this commit again.",
notice_now: edit_in_new_fork_notice_now
}
- fork_path = namespace_project_forks_path(@project.namespace, @project,
+ fork_path = project_forks_path(@project,
namespace_key: current_user.namespace.id,
continue: continue_params)
@@ -173,13 +186,15 @@ module CommitsHelper
end
end
- def view_file_button(commit_sha, diff_new_path, project)
+ def view_file_button(commit_sha, diff_new_path, project, replaced: false)
+ title = replaced ? _('View replaced file @ ') : _('View file @ ')
+
link_to(
- namespace_project_blob_path(project.namespace, project,
+ project_blob_path(project,
tree_join(commit_sha, diff_new_path)),
class: 'btn view-file js-view-file'
) do
- raw('View file @ ') + content_tag(:span, Commit.truncate_sha(commit_sha),
+ raw(title) + content_tag(:span, Commit.truncate_sha(commit_sha),
class: 'commit-sha')
end
end
@@ -213,4 +228,12 @@ module CommitsHelper
[commits, 0]
end
end
+
+ def commit_path(project, commit, merge_request: nil)
+ if merge_request&.persisted?
+ diffs_project_merge_request_path(project, merge_request, commit_id: commit.id)
+ else
+ project_commit_path(project, commit)
+ end
+ end
end
diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb
index 2aa0449c46e..8bf96c0905f 100644
--- a/app/helpers/compare_helper.rb
+++ b/app/helpers/compare_helper.rb
@@ -4,13 +4,12 @@ module CompareHelper
to.present? &&
from != to &&
can?(current_user, :create_merge_request, project) &&
- project.repository.branch_names.include?(from) &&
- project.repository.branch_names.include?(to)
+ project.repository.branch_exists?(from) &&
+ project.repository.branch_exists?(to)
end
def create_mr_path(from = params[:from], to = params[:to], project = @project)
- new_namespace_project_merge_request_path(
- project.namespace,
+ project_new_merge_request_path(
project,
merge_request: {
source_branch: to,
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index c25b54eadc6..19aa55a8d49 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -6,4 +6,28 @@ module DashboardHelper
def assigned_mrs_dashboard_path
merge_requests_dashboard_path(assignee_id: current_user.id)
end
+
+ def dashboard_nav_links
+ @dashboard_nav_links ||= get_dashboard_nav_links
+ end
+
+ def dashboard_nav_link?(link)
+ dashboard_nav_links.include?(link)
+ end
+
+ def any_dashboard_nav_link?(links)
+ links.any? { |link| dashboard_nav_link?(link) }
+ end
+
+ private
+
+ def get_dashboard_nav_links
+ links = [:projects, :groups, :snippets]
+
+ if can?(current_user, :read_cross_project)
+ links += [:activity, :milestones]
+ end
+
+ links
+ end
end
diff --git a/app/helpers/defer_script_tag_helper.rb b/app/helpers/defer_script_tag_helper.rb
new file mode 100644
index 00000000000..e1567556e5e
--- /dev/null
+++ b/app/helpers/defer_script_tag_helper.rb
@@ -0,0 +1,6 @@
+module DeferScriptTagHelper
+ # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading
+ def javascript_include_tag(*sources)
+ super(*sources, defer: true)
+ end
+end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 16a99addd0b..b5ca39711bc 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -15,7 +15,7 @@ module DiffHelper
def diff_view
@diff_view ||= begin
diff_views = %w(inline parallel)
- diff_view = cookies[:diff_view]
+ diff_view = params[:view] || cookies[:diff_view]
diff_view = diff_views.first unless diff_views.include?(diff_view)
diff_view.to_sym
end
@@ -33,19 +33,21 @@ module DiffHelper
end
def diff_match_line(old_pos, new_pos, text: '', view: :inline, bottom: false)
- content = content_tag :td, text, class: "line_content match #{view == :inline ? '' : view}"
- cls = ['diff-line-num', 'unfold', 'js-unfold']
- cls << 'js-unfold-bottom' if bottom
+ content_line_class = %w[line_content match]
+ content_line_class << 'parallel' if view == :parallel
+
+ line_num_class = %w[diff-line-num unfold js-unfold]
+ line_num_class << 'js-unfold-bottom' if bottom
html = ''
if old_pos
- html << content_tag(:td, '...', class: cls + ['old_line'], data: { linenumber: old_pos })
- html << content unless view == :inline
+ html << content_tag(:td, '...', class: [*line_num_class, 'old_line'], data: { linenumber: old_pos })
+ html << content_tag(:td, text, class: [*content_line_class, 'left-side']) if view == :parallel
end
if new_pos
- html << content_tag(:td, '...', class: cls + ['new_line'], data: { linenumber: new_pos })
- html << content
+ html << content_tag(:td, '...', class: [*line_num_class, 'new_line'], data: { linenumber: new_pos })
+ html << content_tag(:td, text, class: [*content_line_class, ('right-side' if view == :parallel)])
end
html.html_safe
@@ -88,33 +90,42 @@ module DiffHelper
end
def submodule_link(blob, ref, repository = @repository)
- tree, commit = submodule_links(blob, ref, repository)
- commit_id = if commit.nil?
+ project_url, tree_url = submodule_links(blob, ref, repository)
+ commit_id = if tree_url.nil?
Commit.truncate_sha(blob.id)
else
- link_to Commit.truncate_sha(blob.id), commit
+ link_to Commit.truncate_sha(blob.id), tree_url
end
[
- content_tag(:span, link_to(truncate(blob.name, length: 40), tree)),
+ content_tag(:span, link_to(truncate(blob.name, length: 40), project_url)),
'@',
content_tag(:span, commit_id, class: 'commit-sha')
].join(' ').html_safe
end
- 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))
+ def diff_file_blob_raw_url(diff_file, only_path: false)
+ project_raw_url(@project, tree_join(diff_file.content_sha, diff_file.file_path), only_path: only_path)
end
- def diff_file_old_blob_raw_path(diff_file)
+ def diff_file_old_blob_raw_url(diff_file, only_path: false)
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))
+
+ project_raw_url(@project, tree_join(diff_file.old_content_sha, diff_file.old_path), only_path: only_path)
+ end
+
+ def diff_file_blob_raw_path(diff_file)
+ diff_file_blob_raw_url(diff_file, only_path: true)
+ end
+
+ def diff_file_old_blob_raw_path(diff_file)
+ diff_file_old_blob_raw_url(diff_file, only_path: true)
end
def diff_file_html_data(project, diff_file_path, diff_commit_id)
{
- blob_diff_path: namespace_project_blob_diff_path(project.namespace, project,
+ blob_diff_path: project_blob_diff_path(project,
tree_join(diff_commit_id, diff_file_path)),
view: diff_view
}
@@ -142,12 +153,30 @@ module DiffHelper
diff_file = viewer.diff_file
options = []
- blob_url = namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.file_path))
+ blob_url = project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.file_path))
options << link_to('view the blob', blob_url)
options
end
+ def diff_file_changed_icon(diff_file)
+ if diff_file.deleted_file?
+ "file-deletion"
+ elsif diff_file.new_file?
+ "file-addition"
+ else
+ "file-modified"
+ end
+ end
+
+ def diff_file_changed_icon_color(diff_file)
+ if diff_file.deleted_file?
+ "cred"
+ elsif diff_file.new_file?
+ "cgreen"
+ end
+ end
+
private
def diff_btn(title, name, selected)
@@ -163,17 +192,17 @@ module DiffHelper
end
def commit_diff_whitespace_link(project, commit, options)
- url = namespace_project_commit_path(project.namespace, project, commit.id, params_with_whitespace)
+ url = project_commit_path(project, commit.id, params_with_whitespace)
toggle_whitespace_link(url, options)
end
def diff_merge_request_whitespace_link(project, merge_request, options)
- url = diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, params_with_whitespace)
+ url = diffs_project_merge_request_path(project, merge_request, params_with_whitespace)
toggle_whitespace_link(url, options)
end
def diff_compare_whitespace_link(project, from, to, options)
- url = namespace_project_compare_path(project.namespace, project, from, to, params_with_whitespace)
+ url = project_compare_path(project, from, to, params_with_whitespace)
toggle_whitespace_link(url, options)
end
@@ -197,4 +226,12 @@ module DiffHelper
diffs.overflow?
end
+
+ def diff_file_path_text(diff_file, max: 60)
+ path = diff_file.new_path
+
+ return path unless path.size > max && max > 3
+
+ "...#{path[-(max - 3)..-1]}"
+ end
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index ac8c518ac84..5089da519df 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -48,11 +48,11 @@ module DropdownsHelper
end
end
- def dropdown_title(title, back: false)
+ def dropdown_title(title, options: {})
content_tag :div, class: "dropdown-title" do
title_output = ""
- if back
+ if options.fetch(:back, false)
title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do
icon('arrow-left')
end
@@ -60,14 +60,25 @@ module DropdownsHelper
title_output << content_tag(:span, title)
- title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do
- icon('times', class: 'dropdown-menu-close-icon')
+ if options.fetch(:close, true)
+ title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do
+ icon('times', class: 'dropdown-menu-close-icon')
+ end
end
title_output.html_safe
end
end
+ def dropdown_input(placeholder, input_id: nil)
+ content_tag :div, class: "dropdown-input" do
+ filter_output = text_field_tag input_id, nil, class: "dropdown-input-field dropdown-no-filter", placeholder: placeholder, autocomplete: 'off'
+ filter_output << icon('times', class: "dropdown-input-clear js-dropdown-input-clear", role: "button")
+
+ filter_output.html_safe
+ end
+ end
+
def dropdown_filter(placeholder, search_id: nil)
content_tag :div, class: "dropdown-input" do
filter_output = search_field_tag search_id, nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
@@ -86,9 +97,11 @@ module DropdownsHelper
end
end
- def dropdown_footer(&block)
+ def dropdown_footer(add_content_class: false, &block)
content_tag(:div, class: "dropdown-footer") do
- if block
+ if add_content_class
+ content_tag(:div, capture(&block), class: "dropdown-footer-content")
+ else
capture(&block)
end
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index fdbca789d21..4ddc1dbed49 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -24,6 +24,7 @@ module EmailsHelper
def action_title(url)
return unless url
+
%w(merge_requests issues commit).each do |action|
if url.split("/").include?(action)
return "View #{action.humanize.singularize}"
@@ -61,8 +62,8 @@ module EmailsHelper
else
image_tag(
image_url('mailers/gitlab_header_logo.gif'),
- size: "55x50",
- alt: "GitLab"
+ size: '55x50',
+ alt: 'GitLab'
)
end
end
@@ -79,4 +80,20 @@ module EmailsHelper
'text-align:center'
].join(';')
end
+
+ # "You are receiving this email because #{reason}"
+ def notification_reason_text(reason)
+ string = case reason
+ when NotificationReason::OWN_ACTIVITY
+ 'of your activity'
+ when NotificationReason::ASSIGNED
+ 'you have been assigned an item'
+ when NotificationReason::MENTIONED
+ 'you have been mentioned'
+ else
+ 'of your account'
+ end
+
+ "#{string} on #{Gitlab.config.gitlab.host}"
+ end
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index ff8550439d0..1e78a189c08 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -8,7 +8,7 @@ module EnvironmentHelper
def environment_link_for_build(project, build)
environment = environment_for_build(project, build)
if environment
- link_to environment.name, namespace_project_environment_path(project.namespace, project, environment)
+ link_to environment.name, project_environment_path(project, environment)
else
content_tag :span, build.expanded_environment_name
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 515e802e01e..4ce89f89fa9 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -1,7 +1,7 @@
module EnvironmentsHelper
def environments_list_data
{
- endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json)
+ endpoint: project_environments_path(@project, format: :json)
}
end
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 751d61955b7..079b3cd3aa0 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -1,13 +1,15 @@
module EventsHelper
ICON_NAMES_BY_EVENT_TYPE = {
- 'pushed to' => 'icon_commit',
- 'pushed new' => 'icon_commit',
- 'created' => 'icon_status_open',
- 'opened' => 'icon_status_open',
- 'closed' => 'icon_status_closed',
- 'accepted' => 'icon_code_fork',
- 'commented on' => 'icon_comment_o',
- 'deleted' => 'icon_trash_o'
+ 'pushed to' => 'commit',
+ 'pushed new' => 'commit',
+ 'created' => 'status_open',
+ 'opened' => 'status_open',
+ 'closed' => 'status_closed',
+ 'accepted' => 'fork',
+ 'commented on' => 'comment',
+ 'deleted' => 'remove',
+ 'imported' => 'import',
+ 'joined' => 'users'
}.freeze
def link_to_author(event, self_added: false)
@@ -35,18 +37,18 @@ module EventsHelper
[event.action_name, target].join(" ")
end
- def event_filter_link(key, tooltip)
+ def event_filter_link(key, text, tooltip)
key = key.to_s
active = 'active' if @event_filter.active?(key)
link_opts = {
- class: "event-filter-link",
+ class: "event-filter-link has-tooltip",
id: "#{key}_event_filter",
- title: "Filter by #{tooltip.downcase}"
+ title: tooltip
}
content_tag :li, class: active do
link_to request.path, link_opts do
- content_tag(:span, ' ' + tooltip)
+ content_tag(:span, ' ' + text)
end
end
end
@@ -99,13 +101,12 @@ module EventsHelper
def event_feed_url(event)
if event.issue?
- namespace_project_issue_url(event.project.namespace, event.project,
+ project_issue_url(event.project,
event.issue)
elsif event.merge_request?
- namespace_project_merge_request_url(event.project.namespace,
- event.project, event.merge_request)
+ project_merge_request_url(event.project, event.merge_request)
elsif event.commit_note?
- namespace_project_commit_url(event.project.namespace, event.project,
+ project_commit_url(event.project,
event.note_target)
elsif event.note?
if event.note_target
@@ -119,15 +120,15 @@ module EventsHelper
def push_event_feed_url(event)
if event.push_with_commits? && event.md_ref?
if event.commits_count > 1
- namespace_project_compare_url(event.project.namespace, event.project,
+ project_compare_url(event.project,
from: event.commit_from, to:
event.commit_to)
else
- namespace_project_commit_url(event.project.namespace, event.project,
+ project_commit_url(event.project,
id: event.commit_to)
end
else
- namespace_project_commits_url(event.project.namespace, event.project,
+ project_commits_url(event.project,
event.ref_name)
end
end
@@ -146,15 +147,9 @@ module EventsHelper
def event_note_target_path(event)
if event.commit_note?
- namespace_project_commit_path(event.project.namespace,
- event.project,
- event.note_target,
- anchor: dom_id(event.target))
+ project_commit_path(event.project, event.note_target, anchor: dom_id(event.target))
elsif event.project_snippet_note?
- namespace_project_snippet_path(event.project.namespace,
- event.project,
- event.note_target,
- anchor: dom_id(event.target))
+ project_snippet_path(event.project, event.note_target, anchor: dom_id(event.target))
else
polymorphic_path([event.project.namespace.becomes(Namespace),
event.project, event.note_target],
@@ -177,17 +172,8 @@ module EventsHelper
end
end
- def event_note(text, options = {})
- text = first_line_in_markdown(text, 150, options)
-
- sanitize(
- text,
- tags: %w(a img gl-emoji b pre code p span),
- attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-name', 'data-unicode-version']
- )
- end
-
def event_commit_title(message)
+ message ||= ''
(message.split("\n").first || "").truncate(70)
rescue
"--broken encoding"
@@ -203,7 +189,7 @@ module EventsHelper
def icon_for_event(note)
icon_name = ICON_NAMES_BY_EVENT_TYPE[note]
- custom_icon(icon_name) if icon_name
+ sprite_icon(icon_name) if icon_name
end
def icon_for_profile_event(event)
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index b981a1e8242..f062a91a166 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -25,8 +25,24 @@ module ExploreHelper
controller.class.name.split("::").first == "Explore"
end
+ def explore_nav_links
+ @explore_nav_links ||= get_explore_nav_links
+ end
+
+ def explore_nav_link?(link)
+ explore_nav_links.include?(link)
+ end
+
+ def any_explore_nav_link?(links)
+ links.any? { |link| explore_nav_link?(link) }
+ end
+
private
+ def get_explore_nav_links
+ [:projects, :groups, :snippets]
+ end
+
def request_path_with_options(options = {})
request.path + "?#{options.to_param}"
end
diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb
index defd87d6bbe..8cf890b74a8 100644
--- a/app/helpers/external_wiki_helper.rb
+++ b/app/helpers/external_wiki_helper.rb
@@ -4,7 +4,7 @@ module ExternalWikiHelper
if external_wiki_service
external_wiki_service.properties['external_wiki_url']
else
- namespace_project_wiki_path(project.namespace, project, :home)
+ project_wiki_path(project, :home)
end
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 8ceb5c36bda..905e2002592 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,9 +1,9 @@
module FormHelper
- def form_errors(model)
+ def form_errors(model, type: 'form')
return unless model.errors.any?
pluralized = 'error'.pluralize(model.errors.count)
- headline = "The form contains the following #{pluralized}:"
+ headline = "The #{type} contains the following #{pluralized}:"
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
@@ -16,8 +16,8 @@ module FormHelper
end
end
- def issue_dropdown_options(issuable, has_multiple_assignees = true)
- options = {
+ def issue_assignees_dropdown_options
+ {
toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
title: 'Select assignee',
filter: true,
@@ -27,24 +27,16 @@ module FormHelper
first_user: current_user&.username,
null_user: true,
current_user: true,
- project_id: issuable.project.try(:id),
- field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]",
+ project_id: @project&.id,
+ field_name: 'issue[assignee_ids][]',
default_label: 'Unassigned',
'max-select': 1,
'dropdown-header': 'Assignee',
multi_select: true,
'input-meta': 'name',
'always-show-selectbox': true,
- current_user_info: current_user.to_json(only: [:id, :name])
+ current_user_info: UserSerializer.new.represent(current_user)
}
}
-
- if has_multiple_assignees
- options[:title] = 'Select assignee(s)'
- options[:data][:'dropdown-header'] = 'Assignee(s)'
- options[:data].delete(:'max-select')
- end
-
- options
end
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 8c7af62e199..7f3c118c7ab 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -1,144 +1,91 @@
-# Shorter routing method for project and project items
-# Since update to rails 4.1.9 we are now allowed to use `/` in project routing
-# so we use nested routing for project resources which include project and
-# project namespace. To avoid writing long methods every time we define shortcuts for
-# some of routing.
-#
-# For example instead of this:
-#
-# namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
-#
-# We can simply use shortcut:
-#
-# merge_request_path(merge_request)
-#
+# Shorter routing method for some project items
module GitlabRoutingHelper
- # Project
- def project_path(project, *args)
- namespace_project_path(project.namespace, project, *args)
- end
-
- def project_url(project, *args)
- namespace_project_url(project.namespace, project, *args)
- end
-
- def edit_project_path(project, *args)
- edit_namespace_project_path(project.namespace, project, *args)
- end
-
- def edit_project_url(project, *args)
- edit_namespace_project_url(project.namespace, project, *args)
- end
+ extend ActiveSupport::Concern
- def project_files_path(project, *args)
- namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref)
+ included do
+ Gitlab::Routing.includes_helpers(self)
end
- def project_commits_path(project, *args)
- namespace_project_commits_path(project.namespace, project, @ref || project.repository.root_ref)
- end
-
- def project_pipelines_path(project, *args)
- namespace_project_pipelines_path(project.namespace, project, *args)
- end
-
- def project_environments_path(project, *args)
- namespace_project_environments_path(project.namespace, project, *args)
- end
-
- def project_cycle_analytics_path(project, *args)
- namespace_project_cycle_analytics_path(project.namespace, project, *args)
+ # Project
+ def project_tree_path(project, ref = nil, *args)
+ namespace_project_tree_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
end
- def project_jobs_path(project, *args)
- namespace_project_jobs_path(project.namespace, project, *args)
+ def project_commits_path(project, ref = nil, *args)
+ namespace_project_commits_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
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)
- namespace_project_container_registry_index_path(project.namespace, project, *args)
- end
-
- def activity_project_path(project, *args)
- activity_namespace_project_path(project.namespace, project, *args)
+ project_commits_path(project, ref_name, *args)
end
def runners_path(project, *args)
- namespace_project_runners_path(project.namespace, project, *args)
+ project_runners_path(project, *args)
end
def runner_path(runner, *args)
- namespace_project_runner_path(@project.namespace, @project, runner, *args)
+ project_runner_path(@project, runner, *args)
end
def environment_path(environment, *args)
- namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
+ project_environment_path(environment.project, environment, *args)
end
def environment_metrics_path(environment, *args)
- metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
+ metrics_project_environment_path(environment.project, environment, *args)
end
def issue_path(entity, *args)
- namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args)
+ project_issue_path(entity.project, entity, *args)
end
def merge_request_path(entity, *args)
- namespace_project_merge_request_path(entity.project.namespace, entity.project, entity, *args)
+ project_merge_request_path(entity.project, entity, *args)
end
def pipeline_path(pipeline, *args)
- namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
- end
-
- def milestone_path(entity, *args)
- namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args)
+ project_pipeline_path(pipeline.project, pipeline.id, *args)
end
def issue_url(entity, *args)
- namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args)
+ project_issue_url(entity.project, entity, *args)
end
def merge_request_url(entity, *args)
- namespace_project_merge_request_url(entity.project.namespace, entity.project, entity, *args)
+ project_merge_request_url(entity.project, entity, *args)
end
def pipeline_url(pipeline, *args)
- namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
+ project_pipeline_url(pipeline.project, pipeline.id, *args)
end
def pipeline_job_url(pipeline, build, *args)
- namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args)
+ project_job_url(pipeline.project, build.id, *args)
end
def commits_url(entity, *args)
- namespace_project_commits_url(entity.project.namespace, entity.project, entity.ref, *args)
+ project_commits_url(entity.project, entity.ref, *args)
end
def commit_url(entity, *args)
- namespace_project_commit_url(entity.project.namespace, entity.project, entity.sha, *args)
+ project_commit_url(entity.project, entity.sha, *args)
end
- def project_snippet_url(entity, *args)
- namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args)
- end
+ def preview_markdown_path(parent, *args)
+ return group_preview_markdown_path(parent) if parent.is_a?(Group)
- def preview_markdown_path(project, *args)
if @snippet.is_a?(PersonalSnippet)
preview_markdown_snippets_path
else
- preview_markdown_namespace_project_path(project.namespace, project, *args)
+ preview_markdown_project_path(parent, *args)
end
end
def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue)
- toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity)
+ toggle_subscription_project_issue_path(entity.project, entity)
else
- toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity)
+ toggle_subscription_project_merge_request_path(entity.project, entity)
end
end
@@ -152,32 +99,27 @@ module GitlabRoutingHelper
## Members
def project_members_url(project, *args)
- namespace_project_project_members_url(project.namespace, project)
+ project_project_members_url(project, *args)
end
def project_member_path(project_member, *args)
- namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
+ project_project_member_path(project_member.source, project_member)
end
def request_access_project_members_path(project, *args)
- request_access_namespace_project_project_members_path(project.namespace, project)
+ request_access_project_project_members_path(project)
end
def leave_project_members_path(project, *args)
- leave_namespace_project_project_members_path(project.namespace, project)
+ leave_project_project_members_path(project)
end
def approve_access_request_project_member_path(project_member, *args)
- approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
+ approve_access_request_project_project_member_path(project_member.source, project_member)
end
def resend_invite_project_member_path(project_member, *args)
- resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
- end
-
- # Snippets
- def personal_snippet_url(snippet, *args)
- snippet_url(snippet)
+ resend_invite_project_project_member_path(project_member.source, project_member)
end
# Groups
@@ -211,50 +153,42 @@ module GitlabRoutingHelper
def artifacts_action_path(path, project, build)
action, path_params = path.split('/', 2)
- args = [project.namespace, project, build, path_params]
+ args = [project, build, path_params]
case action
when 'download'
- download_namespace_project_job_artifacts_path(*args)
+ download_project_job_artifacts_path(*args)
when 'browse'
- browse_namespace_project_job_artifacts_path(*args)
+ browse_project_job_artifacts_path(*args)
when 'file'
- file_namespace_project_job_artifacts_path(*args)
+ file_project_job_artifacts_path(*args)
when 'raw'
- raw_namespace_project_job_artifacts_path(*args)
+ raw_project_job_artifacts_path(*args)
end
end
# Pipeline Schedules
def pipeline_schedules_path(project, *args)
- namespace_project_pipeline_schedules_path(project.namespace, project, *args)
+ project_pipeline_schedules_path(project, *args)
end
def pipeline_schedule_path(schedule, *args)
project = schedule.project
- namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args)
+ project_pipeline_schedule_path(project, schedule, *args)
end
def edit_pipeline_schedule_path(schedule)
project = schedule.project
- edit_namespace_project_pipeline_schedule_path(project.namespace, project, schedule)
+ edit_project_pipeline_schedule_path(project, schedule)
end
- def take_ownership_pipeline_schedule_path(schedule, *args)
+ def play_pipeline_schedule_path(schedule, *args)
project = schedule.project
- take_ownership_namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args)
- end
-
- # Settings
- def project_settings_integrations_path(project, *args)
- namespace_project_settings_integrations_path(project.namespace, project, *args)
+ play_project_pipeline_schedule_path(project, schedule, *args)
end
- def project_settings_members_path(project, *args)
- namespace_project_settings_members_path(project.namespace, project, *args)
- end
-
- def project_settings_ci_cd_path(project, *args)
- namespace_project_settings_ci_cd_path(project.namespace, project, *args)
+ def take_ownership_pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ take_ownership_project_pipeline_schedule_path(project, schedule, *args)
end
end
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index 2e9b72e9613..1022070ab6f 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -1,13 +1,10 @@
module GraphHelper
- def get_refs(repo, commit)
- refs = ""
- # Commit::ref_names already strips the refs/XXX from important refs (e.g. refs/heads/XXX)
- # so anything leftover is internally used by GitLab
- commit_refs = commit.ref_names(repo).reject{ |name| name.starts_with?('refs/') }
- refs << commit_refs.join(' ')
+ def refs(repo, commit)
+ refs = commit.ref_names(repo).join(' ')
# append note count
- refs << "[#{@graph.notes[commit.id]}]" if @graph.notes[commit.id] > 0
+ notes_count = @graph.notes[commit.id]
+ refs << "[#{pluralize(notes_count, 'note')}]" if notes_count > 0
refs
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index eb45241615f..16eceb3f48f 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -1,9 +1,44 @@
module GroupsHelper
+ def group_nav_link_paths
+ %w[groups#projects groups#edit ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
+ end
+
+ def group_sidebar_links
+ @group_sidebar_links ||= get_group_sidebar_links
+ end
+
+ def group_sidebar_link?(link)
+ group_sidebar_links.include?(link)
+ end
+
def can_change_group_visibility_level?(group)
can?(current_user, :change_visibility_level, group)
end
- def group_icon(group)
+ def can_change_share_with_group_lock?(group)
+ can?(current_user, :change_share_with_group_lock, group)
+ end
+
+ def group_issues_count(state:)
+ IssuesFinder
+ .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
+ .execute
+ .count
+ end
+
+ def group_merge_requests_count(state:)
+ MergeRequestsFinder
+ .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
+ .execute
+ .count
+ end
+
+ def group_icon(group, options = {})
+ img_path = group_icon_url(group, options)
+ image_tag img_path, options
+ end
+
+ def group_icon_url(group, options = {})
if group.is_a?(String)
group = Group.find_by_full_path(group)
end
@@ -15,17 +50,20 @@ module GroupsHelper
@has_group_title = true
full_title = ''
- group.ancestors.reverse.each do |parent|
- full_title += link_to(simple_sanitize(parent.name), group_path(parent), class: 'group-path hidable')
- full_title += '<span class="hidable"> / </span>'.html_safe
+ group.ancestors.reverse.each_with_index do |parent, index|
+ if index > 0
+ add_to_breadcrumb_dropdown(group_title_link(parent, hidable: false, show_avatar: true, for_dropdown: true), location: :before)
+ else
+ full_title += breadcrumb_list_item group_title_link(parent, hidable: false)
+ end
end
- full_title += link_to(simple_sanitize(group.name), group_path(group), class: 'group-path')
- full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path') if name
+ full_title += render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :before, title: _("Show parent subgroups")
- content_tag :span, class: 'group-title' do
- full_title.html_safe
- end
+ full_title += breadcrumb_list_item group_title_link(group)
+ full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text') if name
+
+ full_title.html_safe
end
def projects_lfs_status(group)
@@ -53,7 +91,106 @@ module GroupsHelper
end
end
- def group_issues(group)
- IssuesFinder.new(current_user, group_id: group.id).execute
+ def remove_group_message(group)
+ _("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") %
+ { group_name: group.name }
+ end
+
+ def share_with_group_lock_help_text(group)
+ return default_help unless group.parent&.share_with_group_lock?
+
+ if group.share_with_group_lock?
+ if can?(current_user, :change_share_with_group_lock, group.parent)
+ ancestor_locked_but_you_can_override(group)
+ else
+ ancestor_locked_so_ask_the_owner(group)
+ end
+ else
+ ancestor_locked_and_has_been_overridden(group)
+ end
+ end
+
+ def parent_group_options(current_group)
+ groups = current_user.owned_groups.sort_by(&:human_name).map do |group|
+ { id: group.id, text: group.human_name }
+ end
+
+ groups.delete_if { |group| group[:id] == current_group.id }
+ groups.to_json
+ end
+
+ def supports_nested_groups?
+ Group.supports_nested_groups?
+ end
+
+ private
+
+ def get_group_sidebar_links
+ links = [:overview, :group_members]
+
+ if can?(current_user, :read_cross_project)
+ links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests]
+ end
+
+ if can?(current_user, :admin_group, @group)
+ links << :settings
+ end
+
+ links
+ end
+
+ def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false)
+ link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do
+ output =
+ if (group.try(:avatar_url) || show_avatar) && !Rails.env.test?
+ group_icon(group, class: "avatar-tile", width: 15, height: 15)
+ else
+ ""
+ end
+
+ output << simple_sanitize(group.name)
+ output.html_safe
+ end
+ end
+
+ def ancestor_group(group)
+ ancestor = oldest_consecutively_locked_ancestor(group)
+ if can?(current_user, :read_group, ancestor)
+ link_to ancestor.name, group_path(ancestor)
+ else
+ ancestor.name
+ end
+ end
+
+ def remove_the_share_with_group_lock_from_ancestor(group)
+ ancestor = oldest_consecutively_locked_ancestor(group)
+ text = s_("GroupSettings|remove the share with group lock from %{ancestor_group_name}") % { ancestor_group_name: ancestor.name }
+ if can?(current_user, :admin_group, ancestor)
+ link_to text, edit_group_path(ancestor)
+ else
+ text
+ end
+ end
+
+ def oldest_consecutively_locked_ancestor(group)
+ group.ancestors.find do |group|
+ !group.has_parent? || !group.parent.share_with_group_lock?
+ end
+ end
+
+ def default_help
+ s_("GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually.")
+ end
+
+ def ancestor_locked_but_you_can_override(group)
+ s_("GroupSettings|This setting is applied on %{ancestor_group}. You can override the setting or %{remove_ancestor_share_with_group_lock}.").html_safe % { ancestor_group: ancestor_group(group), remove_ancestor_share_with_group_lock: remove_the_share_with_group_lock_from_ancestor(group) }
+ end
+
+ def ancestor_locked_so_ask_the_owner(group)
+ s_("GroupSettings|This setting is applied on %{ancestor_group}. To share projects in this group with another group, ask the owner to override the setting or %{remove_ancestor_share_with_group_lock}.").html_safe % { ancestor_group: ancestor_group(group), remove_ancestor_share_with_group_lock: remove_the_share_with_group_lock_from_ancestor(group) }
+ end
+
+ def ancestor_locked_and_has_been_overridden(group)
+ s_("GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup.").html_safe % { ancestor_group: ancestor_group(group) }
end
end
diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb
new file mode 100644
index 00000000000..551b9cca6b1
--- /dev/null
+++ b/app/helpers/hooks_helper.rb
@@ -0,0 +1,17 @@
+module HooksHelper
+ def link_to_test_hook(hook, trigger)
+ path = case hook
+ when ProjectHook
+ project = hook.project
+ test_project_hook_path(project, hook, trigger: trigger)
+ when SystemHook
+ test_admin_hook_path(hook, trigger: trigger)
+ end
+
+ trigger_human_name = trigger.to_s.tr('_', ' ').camelize
+
+ link_to path, rel: 'nofollow' do
+ content_tag(:span, trigger_human_name)
+ end
+ end
+end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index f29faeca22d..c5522ff7a69 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -1,4 +1,5 @@
module IconsHelper
+ extend self
include FontAwesome::Rails::IconHelper
# Creates an icon tag given icon name(s) and possible icon modifiers.
@@ -16,6 +17,32 @@ module IconsHelper
options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
end
+ def custom_icon(icon_name, size: 16)
+ # We can't simply do the below, because there are some .erb SVGs.
+ # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
+ render "shared/icons/#{icon_name}.svg", size: size
+ end
+
+ def sprite_icon_path
+ # SVG Sprites currently don't work across domains, so in the case of a CDN
+ # we have to set the current path deliberately to prevent addition of asset_host
+ sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
+ ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
+ end
+
+ def sprite_file_icons_path
+ # SVG Sprites currently don't work across domains, so in the case of a CDN
+ # we have to set the current path deliberately to prevent addition of asset_host
+ sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
+ ActionController::Base.helpers.image_path('file_icons.svg', host: sprite_base_url)
+ end
+
+ def sprite_icon(icon_name, size: nil, css_class: nil)
+ css_classes = size ? "s#{size}" : ""
+ css_classes << " #{css_class}" unless css_class.blank?
+ content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
+ end
+
def audit_icon(names, options = {})
case names
when "standard"
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index a57b5a8fea5..4664b1728c4 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -1,29 +1,94 @@
module ImportHelper
+ include ::Gitlab::Utils::StrongMemoize
+
+ def has_ci_cd_only_params?
+ false
+ end
+
def import_project_target(owner, name)
namespace = current_user.can_create_group? ? owner : current_user.namespace_path
"#{namespace}/#{name}"
end
- def provider_project_link(provider, path_with_namespace)
- url = __send__("#{provider}_project_url", path_with_namespace)
+ def provider_project_link(provider, full_path)
+ url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend
+
+ link_to full_path, url, target: '_blank', rel: 'noopener noreferrer'
+ end
+
+ def import_will_timeout_message(_ci_cd_only)
+ timeout = time_interval_in_words(Gitlab.config.gitlab_shell.git_timeout)
+ _('The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination.') % { timeout: timeout }
+ end
+
+ def import_svn_message(_ci_cd_only)
+ svn_link = link_to _('this document'), help_page_path('user/project/import/svn')
+ _('To import an SVN repository, check out %{svn_link}.').html_safe % { svn_link: svn_link }
+ end
+
+ def import_in_progress_title
+ if @project.forked?
+ _('Forking in progress')
+ else
+ _('Import in progress')
+ end
+ end
+
+ def import_wait_and_refresh_message
+ _('Please wait while we import the repository for you. Refresh at will.')
+ end
+
+ def import_github_title
+ _('Import repositories from GitHub')
+ end
+
+ def import_github_authorize_message
+ _('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:')
+ end
+
+ def import_github_personal_access_token_message
+ personal_access_token_link = link_to _('Personal Access Token'), 'https://github.com/settings/tokens'
+
+ if github_import_configured?
+ _('Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link }
+ else
+ _('To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link }
+ end
+ end
+
+ def import_configure_github_admin_message
+ github_integration_link = link_to 'GitHub integration', help_page_path('integration/github')
+
+ if current_user.admin?
+ _('Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
+ else
+ _('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
+ end
+ end
+
+ def import_githubish_choose_repository_message
+ _('Choose which repositories you want to import.')
+ end
- link_to path_with_namespace, url, target: '_blank', rel: 'noopener noreferrer'
+ def import_all_githubish_repositories_button_label
+ _('Import all repositories')
end
private
- def github_project_url(path_with_namespace)
- "#{github_root_url}/#{path_with_namespace}"
+ def github_project_url(full_path)
+ URI.join(github_root_url, full_path).to_s
end
def github_root_url
- return @github_url if defined?(@github_url)
+ strong_memoize(:github_url) do
+ provider = Gitlab::Auth::OAuth::Provider.config_for('github')
- provider = Gitlab.config.omniauth.providers.find { |p| p.name == 'github' }
- @github_url = provider.fetch('url', 'https://github.com') if provider
+ provider&.dig('url').presence || 'https://github.com'
+ end
end
- def gitea_project_url(path_with_namespace)
- "#{@gitea_host_url.sub(%r{/+\z}, '')}/#{path_with_namespace}"
+ def gitea_project_url(full_path)
+ URI.join(@gitea_host_url, full_path).to_s
end
end
diff --git a/app/helpers/instance_configuration_helper.rb b/app/helpers/instance_configuration_helper.rb
new file mode 100644
index 00000000000..cee319f20bc
--- /dev/null
+++ b/app/helpers/instance_configuration_helper.rb
@@ -0,0 +1,18 @@
+module InstanceConfigurationHelper
+ def instance_configuration_cell_html(value, &block)
+ return '-' unless value.to_s.presence
+
+ block_given? ? yield(value) : value
+ end
+
+ def instance_configuration_host(host)
+ @instance_configuration_host ||= instance_configuration_cell_html(host).capitalize
+ end
+
+ # Value must be in bytes
+ def instance_configuration_human_size_cell(value)
+ instance_configuration_cell_html(value) do |v|
+ number_to_human_size(v, strip_insignificant_zeros: true, significant: false)
+ end
+ end
+end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 3259a9c1933..6d6b840f485 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -26,22 +26,24 @@ module IssuablesHelper
project = issuable.project
if issuable.is_a?(MergeRequest)
- namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json)
+ project_merge_request_path(project, issuable.iid, :json)
else
- namespace_project_issue_path(project.namespace, project, issuable.iid, :json)
+ project_issue_path(project, issuable.iid, :json)
end
end
- def serialize_issuable(issuable)
- case issuable
- when Issue
- IssueSerializer.new.represent(issuable).to_json
- when MergeRequest
- MergeRequestSerializer
- .new(current_user: current_user, project: issuable.project)
- .represent(issuable)
- .to_json
- end
+ def serialize_issuable(issuable, serializer: nil)
+ serializer_klass = case issuable
+ when Issue
+ IssueSerializer
+ when MergeRequest
+ MergeRequestSerializer
+ end
+
+ serializer_klass
+ .new(current_user: current_user, project: issuable.project)
+ .represent(issuable, serializer: serializer)
+ .to_json
end
def template_dropdown_tag(issuable, &block)
@@ -97,7 +99,7 @@ module IssuablesHelper
project = Project.find_by(id: project_id)
if project
- project.name_with_namespace
+ project.full_name
else
default_label
end
@@ -126,22 +128,20 @@ module IssuablesHelper
end
def issuable_meta(issuable, project, text)
- output = content_tag(:strong, class: "identifier") do
- concat("#{text} ")
- concat(to_url_reference(issuable))
- end
-
- output << " opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
+ output = ""
+ output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "hidden-xs", tooltip: true)
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg")
end
output << "&ensp;".html_safe
+ output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!'))
+
output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "hidden-xs hidden-sm")
output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "hidden-md hidden-lg")
- output
+ output.html_safe
end
def issuable_todo(issuable)
@@ -151,7 +151,7 @@ module IssuablesHelper
end
def issuable_labels_tooltip(labels, limit: 5)
- first, last = labels.partition.with_index{ |_, i| i < limit }
+ first, last = labels.partition.with_index { |_, i| i < limit }
label_names = first.collect(&:name)
label_names << "and #{last.size} more" unless last.empty?
@@ -165,11 +165,7 @@ module IssuablesHelper
}
state_title = titles[state] || state.to_s.humanize
-
- count =
- Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
- issuables_count_for_state(issuable_type, state)
- end
+ count = issuables_count_for_state(issuable_type, state)
html = content_tag(:span, state_title)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
@@ -177,8 +173,22 @@ module IssuablesHelper
html.html_safe
end
+ def issuable_first_contribution_icon
+ content_tag(:span, class: 'fa-stack') do
+ concat(icon('certificate', class: "fa-stack-2x"))
+ concat(content_tag(:strong, '1', class: 'fa-inverse fa-stack-1x'))
+ end
+ end
+
def assigned_issuables_count(issuable_type)
- current_user.public_send("assigned_open_#{issuable_type}_count")
+ case issuable_type
+ when :issues
+ current_user.assigned_open_issues_count
+ when :merge_requests
+ current_user.assigned_open_merge_requests_count
+ else
+ raise ArgumentError, "invalid issuable `#{issuable_type}`"
+ end
end
def issuable_filter_params
@@ -201,18 +211,14 @@ module IssuablesHelper
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,
+ endpoint: issuable_path(issuable),
+ updateEndpoint: "#{issuable_path(issuable)}.json",
+ canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
+ canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
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),
+ markdownPreviewPath: preview_markdown_path(parent),
+ markdownDocsPath: help_page_path('user/markdown'),
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),
@@ -220,16 +226,22 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status
}
+ if parent.is_a?(Group)
+ data[:groupPath] = parent.path
+ else
+ data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path)
+ end
+
data.merge!(updated_at_by(issuable))
- data.to_json
+ data
end
def updated_at_by(issuable)
- return {} unless issuable.is_edited?
+ return {} unless issuable.edited?
{
- updatedAt: issuable.updated_at.to_time.iso8601,
+ updatedAt: issuable.last_edited_at.to_time.iso8601,
updatedBy: {
name: issuable.last_edited_by.name,
path: user_path(issuable.last_edited_by)
@@ -237,64 +249,85 @@ module IssuablesHelper
}
end
- private
+ def issuables_count_for_state(issuable_type, state)
+ Gitlab::IssuablesCountForState.new(finder)[state]
+ end
- def sidebar_gutter_collapsed?
- cookies[:collapsed_gutter] == 'true'
+ def close_issuable_path(issuable)
+ issuable_path(issuable, close_reopen_params(issuable, :close))
end
- def base_issuable_scope(issuable)
- issuable.project.send(issuable.class.table_name).send(issuable_state_scope(issuable))
+ def reopen_issuable_path(issuable)
+ issuable_path(issuable, close_reopen_params(issuable, :reopen))
end
- def issuable_state_scope(issuable)
- if issuable.respond_to?(:merged?) && issuable.merged?
- :merged
- else
- issuable.open? ? :opened : :closed
+ def close_reopen_issuable_path(issuable, should_inverse = false)
+ issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable)
+ end
+
+ def issuable_path(issuable, *options)
+ polymorphic_path(issuable, *options)
+ end
+
+ def issuable_url(issuable, *options)
+ case issuable
+ when Issue
+ issue_url(issuable, *options)
+ when MergeRequest
+ merge_request_url(issuable, *options)
end
end
- def issuables_count_for_state(issuable_type, state)
- @counts ||= {}
- @counts[issuable_type] ||= public_send("#{issuable_type}_finder").count_by_state
- @counts[issuable_type][state]
+ def issuable_button_visibility(issuable, closed)
+ case issuable
+ when Issue
+ issue_button_visibility(issuable, closed)
+ when MergeRequest
+ merge_request_button_visibility(issuable, closed)
+ end
end
- IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page].freeze
- private_constant :IRRELEVANT_PARAMS_FOR_CACHE_KEY
+ def issuable_close_reopen_button_method(issuable)
+ case issuable
+ when Issue
+ ''
+ when MergeRequest
+ 'put'
+ end
+ end
+
+ def issuable_author_is_current_user(issuable)
+ issuable.author == current_user
+ end
+
+ def issuable_display_type(issuable)
+ issuable.model_name.human.downcase
+ end
+
+ def selected_labels
+ Array(params[:label_name]).map do |label_name|
+ Label.new(title: label_name)
+ end
+ end
- def issuables_state_counter_cache_key(issuable_type, state)
- opts = params.with_indifferent_access
- opts[:state] = state
- opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
- opts.delete_if { |_, value| value.blank? }
+ private
- hexdigest(['issuables_count', issuable_type, opts.sort].flatten.join('-'))
+ def sidebar_gutter_collapsed?
+ cookies[:collapsed_gutter] == 'true'
end
def issuable_templates(issuable)
@issuable_templates ||=
case issuable
when Issue
- issue_template_names
+ ref_project.repository.issue_template_names
when MergeRequest
- merge_request_template_names
- else
- raise 'Unknown issuable type!'
+ ref_project.repository.merge_request_template_names
end
end
- def merge_request_template_names
- @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project)
- end
-
- def issue_template_names
- @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project)
- end
-
def selected_template(issuable)
- params[:issuable_template] if issuable_templates(issuable).any?{ |template| template[:name] == params[:issuable_template] }
+ params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] }
end
def issuable_todo_button_data(issuable, todo, is_collapsed)
@@ -305,10 +338,50 @@ module IssuablesHelper
mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil),
issuable_id: issuable.id,
issuable_type: issuable.class.name.underscore,
- url: namespace_project_todos_path(@project.namespace, @project),
+ url: project_todos_path(@project),
delete_path: (dashboard_todo_path(todo) if todo),
placement: (is_collapsed ? 'left' : nil),
container: (is_collapsed ? 'body' : nil)
}
end
+
+ def close_reopen_params(issuable, action)
+ {
+ issuable.model_name.to_s.underscore => { state_event: action }
+ }.tap do |params|
+ params[:format] = :json if issuable.is_a?(Issue)
+ end
+ end
+
+ def labels_path
+ if @project
+ project_labels_path(@project)
+ elsif @group
+ group_labels_path(@group)
+ end
+ end
+
+ def issuable_sidebar_options(issuable, can_edit_issuable)
+ {
+ endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar",
+ toggleSubscriptionEndpoint: toggle_subscription_path(issuable),
+ moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable),
+ projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
+ editable: can_edit_issuable,
+ currentUser: UserSerializer.new.represent(current_user),
+ rootPath: root_path,
+ fullPath: @project.full_path
+ }
+ end
+
+ def parent
+ @project || @group
+ end
+
+ def issuable_milestone_tooltip_title(issuable)
+ if issuable.milestone
+ milestone_tooltip = milestone_tooltip_title(issuable.milestone)
+ _('Milestone') + (milestone_tooltip ? ': ' + milestone_tooltip : '')
+ end
+ end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 82288f1da35..0f25d401406 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -17,10 +17,10 @@ module IssuesHelper
return '' if project.nil?
url =
- if options[:only_path]
- project.issues_tracker.issue_path(issue_iid)
+ if options[:internal]
+ url_for_internal_issue(issue_iid, project, options)
else
- project.issues_tracker.issue_url(issue_iid)
+ url_for_tracker_issue(issue_iid, project, options)
end
# Ensure we return a valid URL to prevent possible XSS.
@@ -29,41 +29,31 @@ module IssuesHelper
''
end
- def bulk_update_milestone_options
- milestones = @project.milestones.active.reorder(due_date: :asc, title: :asc).to_a
- milestones.unshift(Milestone::None)
-
- options_from_collection_for_select(milestones, 'id', 'title', params[:milestone_id])
+ def url_for_tracker_issue(issue_iid, project, options)
+ if options[:only_path]
+ project.issues_tracker.issue_path(issue_iid)
+ else
+ project.issues_tracker.issue_url(issue_iid)
+ end
end
- def milestone_options(object)
- milestones = object.project.milestones.active.reorder(due_date: :asc, title: :asc).to_a
- milestones.unshift(object.milestone) if object.milestone.present? && object.milestone.closed?
- milestones.unshift(Milestone::None)
+ def url_for_internal_issue(issue_iid, project = @project, options = {})
+ helpers = Gitlab::Routing.url_helpers
- options_from_collection_for_select(milestones, 'id', 'title', object.milestone_id)
- end
-
- def project_options(issuable, current_user, ability: :read_project)
- projects = current_user.authorized_projects
- projects = projects.select do |project|
- current_user.can?(ability, project)
+ if options[:only_path]
+ helpers.namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: issue_iid)
+ else
+ helpers.namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: issue_iid)
end
-
- no_project = OpenStruct.new(id: 0, name_with_namespace: 'No project')
- projects.unshift(no_project)
- projects.delete(issuable.project)
-
- options_from_collection_for_select(projects, :id, :name_with_namespace)
end
def status_box_class(item)
if item.try(:expired?)
'status-box-expired'
elsif item.try(:merged?)
- 'status-box-merged'
+ 'status-box-mr-merged'
elsif item.closed?
- 'status-box-closed'
+ 'status-box-mr-closed'
elsif item.try(:upcoming?)
'status-box-upcoming'
else
@@ -75,14 +65,6 @@ module IssuesHelper
return 'hidden' if issue.closed? == closed
end
- def merge_requests_sentence(merge_requests)
- # Sorting based on the `!123` or `group/project!123` reference will sort
- # local merge requests first.
- merge_requests.map do |merge_request|
- merge_request.to_reference(@project)
- end.sort.to_sentence(last_word_connector: ', or ')
- end
-
def confidential_icon(issue)
icon('eye-slash') if issue.confidential?
end
@@ -119,7 +101,7 @@ module IssuesHelper
end
def awards_sort(awards)
- awards.sort_by do |award, notes|
+ awards.sort_by do |award, award_emojis|
if award == "thumbsup"
0
elsif award == "thumbsdown"
@@ -130,18 +112,6 @@ module IssuesHelper
end.to_h
end
- def due_date_options
- options = [
- Issue::AnyDueDate,
- Issue::NoDueDate,
- Issue::DueThisWeek,
- Issue::DueThisMonth,
- Issue::Overdue
- ]
-
- options_from_collection_for_select(options, 'name', 'title', params[:due_date])
- end
-
def link_to_discussions_to_resolve(merge_request, single_discussion = nil)
link_text = merge_request.to_reference
link_text += " (discussion #{single_discussion.first_note.id})" if single_discussion
@@ -150,7 +120,7 @@ module IssuesHelper
Gitlab::UrlBuilder.build(single_discussion.first_note)
else
project = merge_request.project
- namespace_project_merge_request_path(project.namespace, project, merge_request)
+ project_merge_request_path(project, merge_request)
end
link_to link_text, path
@@ -158,4 +128,6 @@ module IssuesHelper
# Required for Banzai::Filter::IssueReferenceFilter
module_function :url_for_issue
+ module_function :url_for_internal_issue
+ module_function :url_for_tracker_issue
end
diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb
index d5e77c7e271..cd4075b340d 100644
--- a/app/helpers/javascript_helper.rb
+++ b/app/helpers/javascript_helper.rb
@@ -2,9 +2,4 @@ module JavascriptHelper
def page_specific_javascript_tag(js)
javascript_include_tag asset_path(js)
end
-
- # deprecated; use webpack_bundle_tag directly instead
- def page_specific_javascript_bundle_tag(bundle)
- webpack_bundle_tag(bundle)
- end
end
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 6baf6f31d8f..87ff607dc3f 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -1,6 +1,14 @@
module LabelsHelper
+ extend self
include ActionView::Helpers::TagHelper
+ def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
+ return true if label.is_a?(GroupLabel)
+ return true unless project
+
+ project.feature_available?(issuables_type, current_user)
+ end
+
# Link to a Label
#
# label - Label object to link to
@@ -43,11 +51,11 @@ module LabelsHelper
def label_filter_path(subject, label, type: :issue)
case subject
when Group
- send("#{type.to_s.pluralize}_group_path",
+ send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
subject,
label_name: [label.name])
when Project
- send("namespace_project_#{type.to_s.pluralize}_path",
+ send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
subject.namespace,
subject,
label_name: [label.name])
@@ -57,14 +65,14 @@ module LabelsHelper
def edit_label_path(label)
case label
when GroupLabel then edit_group_label_path(label.group, label)
- when ProjectLabel then edit_namespace_project_label_path(label.project.namespace, label.project, label)
+ when ProjectLabel then edit_project_label_path(label.project, label)
end
end
def destroy_label_path(label)
case label
when GroupLabel then group_label_path(label.group, label)
- when ProjectLabel then namespace_project_label_path(label.project.namespace, label.project, label)
+ when ProjectLabel then project_label_path(label.project, label)
end
end
@@ -121,13 +129,14 @@ module LabelsHelper
end
end
- def labels_filter_path
- return group_labels_path(@group, :json) if @group
-
+ def labels_filter_path(only_group_labels = false)
project = @target_project || @project
if project
- namespace_project_labels_path(project.namespace, project, :json)
+ project_labels_path(project, :json)
+ elsif @group
+ options = { only_group_labels: only_group_labels } if only_group_labels
+ group_labels_path(@group, :json, options)
else
dashboard_labels_path(:json)
end
@@ -149,8 +158,8 @@ module LabelsHelper
case label_subscription_status(label, project)
when 'group-level' then toggle_subscription_group_label_path(label.group, label)
- when 'project-level' then toggle_subscription_namespace_project_label_path(project.namespace, project, label)
- when 'unsubscribed' then toggle_subscription_namespace_project_label_path(project.namespace, project, label)
+ when 'project-level' then toggle_subscription_project_label_path(project, label)
+ when 'unsubscribed' then toggle_subscription_project_label_path(project, label)
end
end
@@ -165,6 +174,39 @@ module LabelsHelper
end
end
+ def create_label_title(subject)
+ case subject
+ when Group
+ _('Create group label')
+ when Project
+ _('Create project label')
+ else
+ _('Create new label')
+ end
+ end
+
+ def manage_labels_title(subject)
+ case subject
+ when Group
+ _('Manage group labels')
+ when Project
+ _('Manage project labels')
+ else
+ _('Manage labels')
+ end
+ end
+
+ def view_labels_title(subject)
+ case subject
+ when Group
+ _('View group labels')
+ when Project
+ _('View project labels')
+ else
+ _('View labels')
+ end
+ end
+
# Required for Banzai::Filter::LabelReferenceFilter
module_function :render_colored_label, :text_color_for_bg, :escape_once
end
diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb
new file mode 100644
index 00000000000..603b9438e35
--- /dev/null
+++ b/app/helpers/lazy_image_tag_helper.rb
@@ -0,0 +1,25 @@
+module LazyImageTagHelper
+ def placeholder_image
+ "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
+ end
+
+ # Override the default ActionView `image_tag` helper to support lazy-loading
+ def image_tag(source, options = {})
+ options = options.symbolize_keys
+
+ unless options.delete(:lazy) == false
+ options[:data] ||= {}
+ options[:data][:src] = path_to_image(source)
+
+ options[:class] ||= ""
+ options[:class] << " lazy"
+
+ source = placeholder_image
+ end
+
+ super(source, options)
+ end
+
+ # Required for Banzai::Filter::ImageLazyLoadFilter
+ module_function :placeholder_image
+end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 941cfce8370..2fe1927a189 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -21,25 +21,28 @@ module MarkupHelper
end
# Use this in places where you would normally use link_to(gfm(...), ...).
- #
+ def link_to_markdown(body, url, html_options = {})
+ return '' if body.blank?
+
+ link_to_html(markdown(body, pipeline: :single_line), url, html_options)
+ end
+
+ def link_to_markdown_field(object, field, url, html_options = {})
+ rendered_field = markdown_field(object, field)
+
+ link_to_html(rendered_field, url, html_options)
+ end
+
# It solves a problem occurring with nested links (i.e.
# "<a>outer text <a>gfm ref</a> more outer text</a>"). This will not be
# interpreted as intended. Browsers will parse something like
# "<a>outer text </a><a>gfm ref</a> more outer text" (notice the last part is
- # not linked any more). link_to_gfm corrects that. It wraps all parts to
+ # not linked any more). link_to_html corrects that. It wraps all parts to
# explicitly produce the correct linking behavior (i.e.
# "<a>outer text </a><a>gfm ref</a><a> more outer text</a>").
- def link_to_gfm(body, url, html_options = {})
- return '' if body.blank?
-
- context = {
- project: @project,
- current_user: (current_user if defined?(current_user)),
- pipeline: :single_line
- }
- gfm_body = Banzai.render(body, context)
+ def link_to_html(redacted, url, html_options = {})
+ fragment = Nokogiri::HTML::DocumentFragment.parse(redacted)
- fragment = Nokogiri::HTML::DocumentFragment.parse(gfm_body)
if fragment.children.size == 1 && fragment.children[0].name == 'a'
# Fragment has only one node, and it's a link generated by `gfm`.
# Replace it with our requested link.
@@ -50,6 +53,7 @@ module MarkupHelper
# text, wrapping anything found in the requested link
fragment.children.each do |node|
next unless node.text?
+
node.replace(link_to(node.text, url, html_options))
end
end
@@ -66,26 +70,39 @@ module MarkupHelper
# as Markdown. HTML tags in the parsed output are not counted toward the
# +max_chars+ limit. If the length limit falls within a tag's contents, then
# the tag contents are truncated without removing the closing tag.
- def first_line_in_markdown(text, max_chars = nil, options = {})
- md = markdown(text, options).strip
+ def first_line_in_markdown(object, attribute, max_chars = nil, options = {})
+ md = markdown_field(object, attribute, options)
+
+ text = truncate_visible(md, max_chars || md.length) if md.present?
- truncate_visible(md, max_chars || md.length) if md.present?
+ sanitize(
+ text,
+ tags: %w(a img gl-emoji b pre code p span),
+ attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-src', 'data-name', 'data-unicode-version']
+ )
end
def markdown(text, context = {})
return '' unless text.present?
context[:project] ||= @project
+ context[:group] ||= @group
+
html = markdown_unsafe(text, context)
prepare_for_rendering(html, context)
end
- def markdown_field(object, field)
+ def markdown_field(object, field, context = {})
object = object.for_display if object.respond_to?(:for_display)
+ redacted_field_html = object.try(:"redacted_#{field}_html")
+
return '' unless object.present?
+ return redacted_field_html if redacted_field_html
- html = Banzai.render_field(object, field)
- prepare_for_rendering(html, object.banzai_render_context(field))
+ html = Banzai.render_field(object, field, context)
+ context.reverse_merge!(object.banzai_render_context(field)) if object.respond_to?(:banzai_render_context)
+
+ prepare_for_rendering(html, context)
end
def markup(file_name, text, context = {})
@@ -98,7 +115,13 @@ module MarkupHelper
text = wiki_page.content
return '' unless text.present?
- context = { pipeline: :wiki, project: @project, project_wiki: @project_wiki, page_slug: wiki_page.slug }
+ context = {
+ pipeline: :wiki,
+ project: @project,
+ project_wiki: @project_wiki,
+ page_slug: wiki_page.slug,
+ issuable_state_filter_enabled: true
+ }
html =
case wiki_page.format
@@ -180,6 +203,7 @@ module MarkupHelper
node.content = node.content.truncate(num_remaining)
truncated = true
end
+
content_length += node.content.length
end
@@ -207,12 +231,12 @@ module MarkupHelper
data = options[:data].merge({ container: 'body' })
content_tag :button,
type: 'button',
- class: 'toolbar-btn js-md has-tooltip hidden-xs',
+ class: 'toolbar-btn js-md has-tooltip',
tabindex: -1,
data: data,
title: options[:title],
aria: { label: options[:title] } do
- icon(options[:icon])
+ sprite_icon(options[:icon])
end
end
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index 41d471cc92f..a3129cac2b1 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -1,11 +1,4 @@
module MembersHelper
- # Returns a `<action>_<source>_member` association, e.g.:
- # - admin_project_member, update_project_member, destroy_project_member
- # - admin_group_member, update_group_member, destroy_group_member
- def action_member_permission(action, member)
- "#{action}_#{member.type.underscore}".to_sym
- end
-
def remove_member_message(member, user: nil)
user = current_user if defined?(current_user)
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 39d30631646..fb4fe1c40b7 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -1,8 +1,7 @@
module MergeRequestsHelper
def new_mr_path_from_push_event(event)
target_project = event.project.default_merge_request_target
- new_namespace_project_merge_request_path(
- event.project.namespace,
+ project_new_merge_request_path(
event.project,
new_mr_from_push_event(event, target_project)
)
@@ -41,15 +40,15 @@ module MergeRequestsHelper
def merge_path_description(merge_request, separator)
if merge_request.for_fork?
- "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.path_with_namespace}:#{@merge_request.target_branch}"
+ "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}"
else
"Branches: #{@merge_request.source_branch} #{separator} #{@merge_request.target_branch}"
end
end
def mr_change_branches_path(merge_request)
- new_namespace_project_merge_request_path(
- @project.namespace, @project,
+ project_new_merge_request_path(
+ @project,
merge_request: {
source_project_id: merge_request.source_project_id,
target_project_id: merge_request.target_project_id,
@@ -74,7 +73,8 @@ module MergeRequestsHelper
end
def target_projects(project)
- [project, project.default_merge_request_target].uniq
+ MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: project)
+ .execute
end
def merge_request_button_visibility(merge_request, closed)
@@ -82,9 +82,7 @@ module MergeRequestsHelper
end
def merge_request_version_path(project, merge_request, merge_request_diff, start_sha = nil)
- diffs_namespace_project_merge_request_path(
- project.namespace, project, merge_request,
- diff_id: merge_request_diff.id, start_sha: start_sha)
+ diffs_project_merge_request_path(project, merge_request, diff_id: merge_request_diff.id, start_sha: start_sha)
end
def version_index(merge_request_diff)
@@ -103,6 +101,43 @@ module MergeRequestsHelper
}.merge(merge_params_ee(merge_request))
end
+ def tab_link_for(merge_request, tab, options = {}, &block)
+ data_attrs = {
+ action: tab.to_s,
+ target: "##{tab}",
+ toggle: options.fetch(:force_link, false) ? '' : 'tab'
+ }
+
+ url = case tab
+ when :show
+ data_attrs[:target] = '#notes'
+ method(:project_merge_request_path)
+ when :commits
+ method(:commits_project_merge_request_path)
+ when :pipelines
+ method(:pipelines_project_merge_request_path)
+ when :diffs
+ method(:diffs_project_merge_request_path)
+ else
+ raise "Cannot create tab #{tab}."
+ end
+
+ link_to(url[merge_request.project, merge_request], data: data_attrs, &block)
+ end
+
+ def allow_maintainer_push_unavailable_reason(merge_request)
+ return if merge_request.can_allow_maintainer_to_push?(current_user)
+
+ minimum_visibility = [merge_request.target_project.visibility_level,
+ merge_request.source_project.visibility_level].min
+
+ if minimum_visibility < Gitlab::VisibilityLevel::INTERNAL
+ _('Not available for private projects')
+ elsif ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch)
+ _('Not available for protected branches')
+ end
+ end
+
def merge_params_ee(merge_request)
{}
end
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index a230db22fa2..be8cb358de2 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -1,7 +1,7 @@
module MilestonesHelper
def milestones_filter_path(opts = {})
if @project
- namespace_project_milestones_path(@project.namespace, @project, opts)
+ project_milestones_path(@project, opts)
elsif @group
group_milestones_path(@group, opts)
else
@@ -11,7 +11,7 @@ module MilestonesHelper
def milestones_label_path(opts = {})
if @project
- namespace_project_issues_path(@project.namespace, @project, opts)
+ project_issues_path(@project, opts)
elsif @group
issues_group_path(@group, opts)
else
@@ -32,7 +32,18 @@ module MilestonesHelper
end
def milestone_issues_by_label_count(milestone, label, state:)
- milestone.issues.with_label(label.title).send(state).size
+ issues = milestone.issues.with_label(label.title)
+ issues =
+ case state
+ when :opened
+ issues.opened
+ when :closed
+ issues.closed
+ else
+ raise ArgumentError, "invalid milestone state `#{state}`"
+ end
+
+ issues.size
end
# Returns count of milestones for different states
@@ -54,8 +65,10 @@ module MilestonesHelper
def milestone_class_for_state(param, check, match_blank_param = false)
if match_blank_param
'active' if param.blank? || param == check
+ elsif param == check
+ 'active'
else
- 'active' if param == check
+ check
end
end
@@ -73,12 +86,20 @@ module MilestonesHelper
def milestones_filter_dropdown_path
project = @target_project || @project
if project
- namespace_project_milestones_path(project.namespace, project, :json)
+ project_milestones_path(project, :json)
+ elsif @group
+ group_milestones_path(@group, :json)
else
dashboard_milestones_path(:json)
end
end
+ def milestone_tooltip_title(milestone)
+ if milestone.due_date
+ [milestone.due_date.to_s(:medium), "(#{milestone_remaining_days(milestone)})"].join(' ')
+ end
+ end
+
def milestone_remaining_days(milestone)
if milestone.expired?
content_tag(:strong, 'Past due')
@@ -118,7 +139,7 @@ module MilestonesHelper
def milestone_merge_request_tab_path(milestone)
if @project
- merge_requests_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
+ merge_requests_project_milestone_path(@project, milestone, format: :json)
elsif @group
merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
else
@@ -128,7 +149,7 @@ module MilestonesHelper
def milestone_participants_tab_path(milestone)
if @project
- participants_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
+ participants_project_milestone_path(@project, milestone, format: :json)
elsif @group
participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
else
@@ -138,11 +159,21 @@ module MilestonesHelper
def milestone_labels_tab_path(milestone)
if @project
- labels_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json)
+ labels_project_milestone_path(@project, milestone, format: :json)
elsif @group
labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
else
labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
end
end
+
+ def group_milestone_route(milestone, params = {})
+ params = nil if params.empty?
+
+ if milestone.legacy_group_milestone?
+ group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params)
+ else
+ group_milestone_path(@group, milestone.iid, milestone: params)
+ end
+ end
end
diff --git a/app/helpers/milestones_routing_helper.rb b/app/helpers/milestones_routing_helper.rb
new file mode 100644
index 00000000000..a0b2616f224
--- /dev/null
+++ b/app/helpers/milestones_routing_helper.rb
@@ -0,0 +1,17 @@
+module MilestonesRoutingHelper
+ def milestone_path(milestone, *args)
+ if milestone.group_milestone?
+ group_milestone_path(milestone.group, milestone, *args)
+ elsif milestone.project_milestone?
+ project_milestone_path(milestone.project, milestone, *args)
+ end
+ end
+
+ def milestone_url(milestone, *args)
+ if milestone.group_milestone?
+ group_milestone_url(milestone.group, milestone, *args)
+ elsif milestone.project_milestone?
+ project_milestone_url(milestone.project, milestone, *args)
+ end
+ end
+end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 7f656b8caae..40ca666f1bf 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -4,7 +4,11 @@ module NamespacesHelper
end
def namespaces_options(selected = :current_user, display_path: false, extra_group: nil)
- groups = current_user.owned_groups + current_user.masters_groups
+ groups = current_user.manageable_groups
+ .joins(:route)
+ .includes(:route)
+ .order('routes.path')
+ users = [current_user.namespace]
unless extra_group.nil? || extra_group.is_a?(Group)
extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group'
@@ -14,22 +18,9 @@ module NamespacesHelper
groups |= [extra_group]
end
- users = [current_user.namespace]
-
- data_attr_group = { 'data-options-parent' => 'groups' }
- data_attr_users = { 'data-options-parent' => 'users' }
-
- group_opts = [
- "Groups", groups.sort_by(&:human_name).map { |g| [display_path ? g.full_path : g.human_name, g.id, data_attr_group] }
- ]
-
- users_opts = [
- "Users", users.sort_by(&:human_name).map { |u| [display_path ? u.path : u.human_name, u.id, data_attr_users] }
- ]
-
options = []
- options << group_opts
- options << users_opts
+ options << options_for_group(groups, display_path: display_path, type: 'group')
+ options << options_for_group(users, display_path: display_path, type: 'user')
if selected == :current_user && current_user.namespace
selected = current_user.namespace.id
@@ -42,7 +33,26 @@ module NamespacesHelper
if namespace.is_a?(Group)
group_icon(namespace)
else
- avatar_icon(namespace.owner.email, size)
+ avatar_icon_for_user(namespace.owner, size)
end
end
+
+ private
+
+ def options_for_group(namespaces, display_path:, type:)
+ group_label = type.pluralize
+ elements = namespaces.sort_by(&:human_name).map! do |n|
+ [display_path ? n.full_path : n.human_name, n.id,
+ data: {
+ options_parent: group_label,
+ visibility_level: n.visibility_level_value,
+ visibility: n.visibility,
+ name: n.name,
+ show_path: (type == 'group') ? group_path(n) : user_path(n),
+ edit_path: (type == 'group') ? edit_group_path(n) : nil
+ }]
+ end
+
+ [group_label.camelize, elements]
+ end
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 833d3c36b28..56c88e6eab0 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -1,46 +1,73 @@
module NavHelper
+ def header_links
+ @header_links ||= get_header_links
+ end
+
+ def header_link?(link)
+ header_links.include?(link)
+ end
+
+ def page_with_sidebar_class
+ class_name = page_gutter_class
+ class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar
+ class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @left_sidebar
+
+ class_name
+ end
+
def page_gutter_class
if current_path?('merge_requests#show') ||
- current_path?('merge_requests#diffs') ||
- current_path?('merge_requests#commits') ||
- current_path?('merge_requests#builds') ||
- current_path?('merge_requests#conflicts') ||
- current_path?('merge_requests#pipelines') ||
+ current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show')
+
if cookies[:collapsed_gutter] == 'true'
- "page-gutter right-sidebar-collapsed"
+ %w[page-gutter right-sidebar-collapsed]
else
- "page-gutter right-sidebar-expanded"
+ %w[page-gutter right-sidebar-expanded]
end
elsif current_path?('jobs#show')
- "page-gutter build-sidebar right-sidebar-expanded"
- elsif current_path?('wikis#show') ||
- current_path?('wikis#edit') ||
- current_path?('wikis#update') ||
- current_path?('wikis#history') ||
- current_path?('wikis#git_access')
- "page-gutter wiki-sidebar right-sidebar-expanded"
+ %w[page-gutter build-sidebar right-sidebar-expanded]
+ elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access')
+ %w[page-gutter wiki-sidebar right-sidebar-expanded]
+ else
+ []
end
end
- def nav_header_class
- class_name = ''
- class_name << " with-horizontal-nav" if defined?(nav) && nav
- class_name << " with-peek" if peek_enabled?
-
- class_name
+ def nav_control_class
+ "nav-control" if current_user
end
- def layout_nav_class
- class_name = ''
- class_name << " page-with-layout-nav" if defined?(nav) && nav
- class_name << " page-with-sub-nav" if content_for?(:sub_nav)
+ def user_dropdown_class
+ class_names = []
+ class_names << 'header-user-dropdown-toggle'
+ class_names << 'impersonated-user' if session[:impersonator_id]
- class_name
+ class_names
end
- def nav_control_class
- "nav-control" if current_user
+ private
+
+ def get_header_links
+ links = if current_user
+ [:user_dropdown]
+ else
+ [:sign_in]
+ end
+
+ if can?(current_user, :read_cross_project)
+ links += [:issues, :merge_requests, :todos] if current_user.present?
+ end
+
+ if @project&.persisted? || can?(current_user, :read_cross_project)
+ links << :search
+ end
+
+ if session[:impersonator_id]
+ links << :admin_impersonation
+ end
+
+ links
end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 64ad7b280cb..20aed60cb7a 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -11,7 +11,7 @@ module NotesHelper
end
def note_supports_quick_actions?(note)
- Notes::QuickActionsService.supported?(note, current_user)
+ Notes::QuickActionsService.supported?(note)
end
def noteable_json(noteable)
@@ -47,17 +47,33 @@ module NotesHelper
data
end
+ def add_diff_note_button(line_code, position, line_type)
+ return if @diff_notes_disabled
+
+ button_tag '',
+ class: 'add-diff-note js-add-diff-note-button',
+ type: 'submit', name: 'button',
+ data: diff_view_line_data(line_code, position, line_type),
+ title: 'Add a comment to this line' do
+ icon('comment-o')
+ end
+ end
+
def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user
- data = { discussion_id: discussion.reply_id, line_type: line_type }
+ data = {
+ discussion_id: discussion.reply_id,
+ discussion_project_id: discussion.project&.id,
+ line_type: line_type
+ }
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
data: data, title: 'Add a reply'
end
def note_max_access_for_user(note)
- note.project.team.human_max_access(note.author_id)
+ note.project.team.max_member_access(note.author_id)
end
def discussion_path(discussion)
@@ -69,24 +85,21 @@ module NotesHelper
path_params = version_params.merge(anchor: discussion.line_code)
- diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, path_params)
+ diffs_project_merge_request_path(discussion.project, discussion.noteable, path_params)
elsif discussion.for_commit?
anchor = discussion.line_code if discussion.diff_discussion?
- namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor)
+ project_commit_path(discussion.project, discussion.noteable, anchor: anchor)
end
end
- def notes_url
+ def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet)
- snippet_notes_path(@snippet)
+ snippet_notes_path(@snippet, params)
else
- namespace_project_noteable_notes_path(
- namespace_id: @project.namespace,
- project_id: @project,
- target_id: @noteable.id,
- target_type: @noteable.class.name.underscore
- )
+ params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore)
+
+ project_noteable_notes_path(@project, params)
end
end
@@ -94,7 +107,7 @@ module NotesHelper
if note.noteable.is_a?(PersonalSnippet)
snippet_note_path(note.noteable, note)
else
- namespace_project_note_path(project.namespace, project, note)
+ project_note_path(project, note)
end
end
@@ -117,10 +130,59 @@ module NotesHelper
end
def can_create_note?
+ issuable = @issue || @merge_request
+
if @snippet.is_a?(PersonalSnippet)
can?(current_user, :comment_personal_snippet, @snippet)
+ elsif issuable
+ can?(current_user, :create_note, issuable)
else
can?(current_user, :create_note, @project)
end
end
+
+ def initial_notes_data(autocomplete)
+ {
+ notesUrl: notes_url,
+ notesIds: @notes.map(&:id),
+ now: Time.now.to_i,
+ diffView: diff_view,
+ autocomplete: autocomplete
+ }
+ end
+
+ def notes_data(issuable)
+ discussions_path =
+ if issuable.is_a?(Issue)
+ discussions_project_issue_path(@project, issuable, format: :json)
+ else
+ discussions_project_merge_request_path(@project, issuable, format: :json)
+ end
+
+ {
+ discussionsPath: discussions_path,
+ registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
+ newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
+ markdownDocsPath: help_page_path('user/markdown'),
+ quickActionsDocsPath: help_page_path('user/project/quick_actions'),
+ closePath: close_issuable_path(issuable),
+ reopenPath: reopen_issuable_path(issuable),
+ notesPath: notes_url,
+ totalNotes: issuable.discussions.length,
+ lastFetchedAt: Time.now.to_i
+
+ }.to_json
+ end
+
+ def discussion_resolved_intro(discussion)
+ discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
+ end
+
+ def has_vue_discussions_cookie?
+ cookies[:vue_mr_discussions] == 'true'
+ end
+
+ def serialize_notes?
+ has_vue_discussions_cookie? && !params['html']
+ end
end
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index fde961e2da4..3e42063224e 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -78,6 +78,7 @@ module NotificationsHelper
# Create hidden field to send notification setting source to controller
def hidden_setting_source_input(notification_setting)
return unless notification_setting.source_type
+
hidden_field_tag "#{notification_setting.source_type.downcase}_id", notification_setting.source_id
end
diff --git a/app/helpers/numbers_helper.rb b/app/helpers/numbers_helper.rb
new file mode 100644
index 00000000000..45bd3606076
--- /dev/null
+++ b/app/helpers/numbers_helper.rb
@@ -0,0 +1,11 @@
+module NumbersHelper
+ def limited_counter_with_delimiter(resource, **options)
+ limit = options.fetch(:limit, 1000).to_i
+ count = resource.limit(limit + 1).count(:all)
+ if count > limit
+ number_with_delimiter(count - 1, options) + '+'
+ else
+ number_with_delimiter(count, options)
+ end
+ end
+end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 7279d5fb2d4..bb782060e9b 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -4,8 +4,12 @@ module PageLayoutHelper
@page_title.push(*titles.compact) if titles.any?
+ if titles.any? && !defined?(@breadcrumb_title)
+ @breadcrumb_title = @page_title.last
+ end
+
# Segments are seperated by middot
- @page_title.join(" \u00b7 ")
+ @page_title.join(" · ")
end
# Define or get a description for the current page
@@ -78,7 +82,9 @@ module PageLayoutHelper
@header_title = title
@header_title_url = title_url
else
- @header_title_url ? link_to(@header_title, @header_title_url) : @header_title
+ return @header_title unless @header_title_url
+
+ breadcrumb_list_item(link_to(@header_title, @header_title_url))
end
end
diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb
new file mode 100644
index 00000000000..83dd76a01dd
--- /dev/null
+++ b/app/helpers/pagination_helper.rb
@@ -0,0 +1,21 @@
+module PaginationHelper
+ def paginate_collection(collection, remote: nil)
+ if collection.is_a?(Kaminari::PaginatableWithoutCount)
+ paginate_without_count(collection)
+ elsif collection.respond_to?(:total_pages)
+ paginate_with_count(collection, remote: remote)
+ end
+ end
+
+ def paginate_without_count(collection)
+ render(
+ 'kaminari/gitlab/without_count',
+ previous_path: path_to_prev_page(collection),
+ next_path: path_to_next_page(collection)
+ )
+ end
+
+ def paginate_with_count(collection, remote: nil)
+ paginate(collection, remote: remote, theme: 'gitlab')
+ end
+end
diff --git a/app/helpers/performance_bar_helper.rb b/app/helpers/performance_bar_helper.rb
new file mode 100644
index 00000000000..d24efe37f5f
--- /dev/null
+++ b/app/helpers/performance_bar_helper.rb
@@ -0,0 +1,7 @@
+module PerformanceBarHelper
+ # This is a hack since using `alias_method :performance_bar_enabled?, :peek_enabled?`
+ # in WithPerformanceBar breaks tests (but works in the browser).
+ def performance_bar_enabled?
+ peek_enabled?
+ end
+end
diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb
index fee1edc2a1b..6edaf78de1b 100644
--- a/app/helpers/pipeline_schedules_helper.rb
+++ b/app/helpers/pipeline_schedules_helper.rb
@@ -1,10 +1,10 @@
module PipelineSchedulesHelper
def timezone_data
- ActiveSupport::TimeZone.all.map do |timezone|
- {
- name: timezone.name,
- offset: timezone.utc_offset,
- identifier: timezone.tzinfo.identifier
+ ActiveSupport::TimeZone.all.map do |timezone|
+ {
+ name: timezone.name,
+ offset: timezone.utc_offset,
+ identifier: timezone.tzinfo.identifier
}
end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index d36bb4ab074..373dfd457f7 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -36,37 +36,16 @@ module PreferencesHelper
def project_view_choices
[
['Files and Readme (default)', :files],
- ['Activity', :activity]
+ ['Activity', :activity],
+ ['Readme', :readme]
]
end
- def user_color_scheme
- Gitlab::ColorSchemes.for_user(current_user).css_class
- end
-
- def default_project_view
- return anonymous_project_view unless current_user
-
- user_view = current_user.project_view
-
- if can?(current_user, :download_code, @project)
- user_view
- elsif user_view == "activity"
- "activity"
- elsif @project.wiki_enabled?
- "wiki"
- elsif @project.feature_available?(:issues, current_user)
- "projects/issues/issues"
- else
- "customize_workflow"
- end
+ def user_application_theme
+ @user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class
end
- def anonymous_project_view
- if !@project.empty_repo? && can?(current_user, :download_code, @project)
- 'files'
- else
- 'activity'
- end
+ def user_color_scheme
+ Gitlab::ColorSchemes.for_user(current_user).css_class
end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 45238f12ac7..e7aa92e6e5c 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -1,7 +1,12 @@
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"
+ def attribute_provider_label(attribute)
+ user_synced_attributes_metadata = current_user.user_synced_attributes_metadata
+ if user_synced_attributes_metadata&.synced?(attribute)
+ if user_synced_attributes_metadata.provider
+ Gitlab::Auth::OAuth::Provider.label_for(user_synced_attributes_metadata.provider)
+ else
+ 'LDAP'
+ end
+ end
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index c04b1419a19..15f48e43a28 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -13,13 +13,38 @@ module ProjectsHelper
end
def link_to_member_avatar(author, opts = {})
- default_opts = { avatar: true, name: true, size: 16, author_class: 'author', title: ":name" }
+ default_opts = { size: 16, lazy_load: false }
opts = default_opts.merge(opts)
- image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: "avatar avatar-inline #{"s#{opts[:size]}" if opts[:size]}", alt: '') if opts[:avatar]
+
+ classes = %W[avatar avatar-inline s#{opts[:size]}]
+ classes << opts[:avatar_class] if opts[:avatar_class]
+
+ avatar = avatar_icon_for_user(author, opts[:size])
+ src = opts[:lazy_load] ? nil : avatar
+
+ image_tag(src, width: opts[:size], class: classes, alt: '', "data-src" => avatar)
+ end
+
+ def author_content_tag(author, opts = {})
+ default_opts = { author_class: 'author', tooltip: false, by_username: false }
+ opts = default_opts.merge(opts)
+
+ has_tooltip = !opts[:by_username] && opts[:tooltip]
+
+ username = opts[:by_username] ? author.to_reference : author.name
+ name_tag_options = { class: [opts[:author_class]] }
+
+ if has_tooltip
+ name_tag_options[:title] = author.to_reference
+ name_tag_options[:data] = { placement: 'top' }
+ name_tag_options[:class] << 'has-tooltip'
+ end
+
+ content_tag(:span, sanitize(username), name_tag_options)
end
def link_to_member(project, author, opts = {}, &block)
- default_opts = { avatar: true, name: true, size: 16, author_class: 'author', title: ":name", tooltip: false }
+ default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts)
return "(deleted)" unless author
@@ -27,15 +52,10 @@ module ProjectsHelper
author_html = ""
# Build avatar image tag
- author_html << image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: "avatar avatar-inline #{"s#{opts[:size]}" if opts[:size]} #{opts[:avatar_class] if opts[:avatar_class]}", alt: '') if opts[:avatar]
+ author_html << link_to_member_avatar(author, opts) if opts[:avatar]
# Build name span tag
- if opts[:by_username]
- author_html << content_tag(:span, sanitize("@#{author.username}"), class: opts[:author_class]) if opts[:name]
- else
- tooltip_data = { placement: 'top' }
- author_html << content_tag(:span, sanitize(author.name), class: [opts[:author_class], ('has-tooltip' if opts[:tooltip])], title: (author.to_reference if opts[:tooltip]), data: (tooltip_data if opts[:tooltip])) if opts[:name]
- end
+ author_html << author_content_tag(author, opts) if opts[:name]
author_html << capture(&block) if block
@@ -52,36 +72,51 @@ module ProjectsHelper
def project_title(project)
namespace_link =
if project.group
- group_title(project.group)
+ group_title(project.group, nil, nil)
else
owner = project.namespace.owner
link_to(simple_sanitize(owner.name), user_path(owner))
end
- project_link = link_to simple_sanitize(project.name), project_path(project), { class: "project-item-select-holder" }
+ project_link = link_to project_path(project) do
+ output =
+ if project.avatar_url && !Rails.env.test?
+ project_icon(project, alt: project.name, class: 'avatar-tile', width: 15, height: 15)
+ else
+ ""
+ end
- if current_user
- project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do
- icon("chevron-down")
- end
+ output << content_tag("span", simple_sanitize(project.name), class: "breadcrumb-item-text js-breadcrumb-item-text")
+ output.html_safe
end
- "#{namespace_link} / #{project_link}".html_safe
+ namespace_link = breadcrumb_list_item(namespace_link) unless project.group
+ project_link = breadcrumb_list_item project_link
+
+ "#{namespace_link} #{project_link}".html_safe
end
def remove_project_message(project)
- _("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 }
+ _("You are going to remove %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?") %
+ { project_full_name: project.full_name }
end
def transfer_project_message(project)
- _("You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?") %
- { project_name_with_namespace: project.name_with_namespace }
+ _("You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?") %
+ { project_full_name: project.full_name }
end
def remove_fork_project_message(project)
_("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 }
+ { forked_from_project: fork_source_name(project) }
+ end
+
+ def fork_source_name(project)
+ if @project.fork_source
+ @project.fork_source.full_name
+ else
+ @project.fork_network&.deleted_root_project_name
+ end
end
def project_nav_tabs
@@ -111,43 +146,31 @@ module ProjectsHelper
def can_change_visibility_level?(project, current_user)
return false unless can?(current_user, :change_visibility_level, project)
- if project.forked?
- project.forked_from_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE
+ if project.fork_source
+ project.fork_source.visibility_level > Gitlab::VisibilityLevel::PRIVATE
else
true
end
end
- def license_short_name(project)
- 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)
- project_ids << fork.id
- end
-
- current_user.recent_push(project_ids)
+ current_user&.recent_push(@project)
end
def project_feature_access_select(field)
# Don't show option "everyone with access" if project is private
options = project_feature_options
+ level = @project.project_feature.public_send(field) # rubocop:disable GitlabSecurity/PublicSend
+
if @project.private?
- level = @project.project_feature.send(field)
disabled_option = ProjectFeature::ENABLED
highest_available_option = ProjectFeature::PRIVATE if level == disabled_option
end
options = options_for_select(
options.invert,
- selected: highest_available_option || @project.project_feature.public_send(field),
+ selected: highest_available_option || level,
disabled: disabled_option
)
@@ -184,8 +207,9 @@ module ProjectsHelper
project.cache_key,
controller.controller_name,
controller.action_name,
- current_application_settings.cache_key,
- 'v2.4'
+ Gitlab::CurrentSettings.cache_key,
+ "cross-project:#{can?(current_user, :read_cross_project)}",
+ 'v2.5'
]
key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status?
@@ -204,17 +228,50 @@ module ProjectsHelper
def show_no_password_message?
cookies[:hide_no_password_message].blank? && !current_user.hide_no_password &&
- ( current_user.require_password? || current_user.require_personal_access_token? )
+ current_user.require_extra_setup_for_git_auth?
end
def link_to_set_password
- if current_user.require_password?
+ if current_user.require_password_creation_for_git?
link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path
else
link_to s_('CreateTokenToCloneLink|create a personal access token'), profile_personal_access_tokens_path
end
end
+ # Returns true if any projects are present.
+ #
+ # If the relation has a LIMIT applied we'll cast the relation to an Array
+ # since repeated any? checks would otherwise result in multiple COUNT queries
+ # being executed.
+ #
+ # If no limit is applied we'll just issue a COUNT since the result set could
+ # be too large to load into memory.
+ def any_projects?(projects)
+ return projects.any? if projects.is_a?(Array)
+
+ if projects.limit_value
+ projects.to_a.any?
+ else
+ projects.except(:offset).any?
+ end
+ end
+
+ def show_projects?(projects, params)
+ !!(params[:personal] || params[:name] || any_projects?(projects))
+ end
+
+ def push_to_create_project_command(user = current_user)
+ repository_url =
+ if Gitlab::CurrentSettings.current_application_settings.enabled_git_access_protocol == 'http'
+ user_url(user)
+ else
+ Gitlab.config.gitlab_shell.ssh_path_prefix + user.username
+ end
+
+ "git push --set-upstream #{repository_url}/$(git rev-parse --show-toplevel | xargs basename).git $(git rev-parse --abbrev-ref HEAD)"
+ end
+
private
def repo_children_classes(field)
@@ -246,6 +303,10 @@ module ProjectsHelper
nav_tabs << :pipelines
end
+ if project.external_issue_tracker
+ nav_tabs << :external_issue_tracker
+ end
+
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
@@ -257,15 +318,16 @@ module ProjectsHelper
def tab_ability_map
{
- environments: :read_environment,
- milestones: :read_milestone,
- snippets: :read_project_snippet,
- settings: :admin_project,
- builds: :read_build,
- labels: :read_label,
- issues: :read_issue,
- team: :read_project_member,
- wiki: :read_wiki
+ environments: :read_environment,
+ milestones: :read_milestone,
+ snippets: :read_project_snippet,
+ settings: :admin_project,
+ builds: :read_build,
+ clusters: :read_cluster,
+ labels: :read_label,
+ issues: :read_issue,
+ project_members: :read_project_member,
+ wiki: :read_wiki
}
end
@@ -292,7 +354,7 @@ module ProjectsHelper
def git_user_name
if current_user
- current_user.name
+ current_user.name.gsub('"', '\"')
else
_("Your name")
end
@@ -335,107 +397,25 @@ module ProjectsHelper
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,
- branch_name: branch_name,
- context: context
- )
- end
-
- def add_koding_stack_path(project)
- namespace_project_new_blob_path(
- project.namespace,
- project,
- project.default_branch || 'master',
- file_name: '.koding.yml',
- commit_message: "Add Koding stack script",
- content: <<-CONTENT.strip_heredoc
- provider:
- aws:
- access_key: '${var.aws_access_key}'
- secret_key: '${var.aws_secret_key}'
- resource:
- aws_instance:
- #{project.path}-vm:
- instance_type: t2.nano
- user_data: |-
-
- # Created by GitLab UI for :>
-
- echo _KD_NOTIFY_@Installing Base packages...@
-
- apt-get update -y
- apt-get install git -y
-
- echo _KD_NOTIFY_@Cloning #{project.name}...@
-
- export KODING_USER=${var.koding_user_username}
- export REPO_URL=#{root_url}${var.koding_queryString_repo}.git
- export BRANCH=${var.koding_queryString_branch}
-
- sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH
-
- echo _KD_NOTIFY_@#{project.name} cloned.@
- CONTENT
- )
- end
-
def koding_project_url(project = nil, branch = nil, sha = nil)
if project
import_path = "/Home/Stacks/import"
- repo = project.path_with_namespace
+ repo = project.full_path
branch ||= project.default_branch
sha ||= project.commit.short_id
path = "#{import_path}?repo=#{repo}&branch=#{branch}&sha=#{sha}"
- return URI.join(current_application_settings.koding_url, path).to_s
- end
-
- current_application_settings.koding_url
- end
-
- def contribution_guide_path(project)
- if project && contribution_guide = project.repository.contribution_guide
- namespace_project_blob_path(
- project.namespace,
- project,
- tree_join(project.default_branch,
- contribution_guide.name)
- )
+ return URI.join(Gitlab::CurrentSettings.koding_url, path).to_s
end
- end
-
- def readme_path(project)
- filename_path(project, :readme)
- end
- def changelog_path(project)
- filename_path(project, :changelog)
- end
-
- def license_path(project)
- filename_path(project, :license_blob)
- end
-
- def version_path(project)
- filename_path(project, :version)
- end
-
- def ci_configuration_path(project)
- filename_path(project, :gitlab_ci_yml)
+ Gitlab::CurrentSettings.koding_url
end
def project_wiki_path_with_version(proj, page, version, is_newest)
url_params = is_newest ? {} : { version_id: version }
- namespace_project_wiki_path(proj.namespace, proj, page, url_params)
+ project_wiki_path(proj, page, url_params)
end
def project_status_css_class(status)
@@ -451,23 +431,13 @@ module ProjectsHelper
def readme_cache_key
sha = @project.commit.try(:sha) || 'nil'
- [@project.path_with_namespace, sha, "readme"].join('-')
+ [@project.full_path, sha, "readme"].join('-')
end
def current_ref
@ref || @repository.try(:root_ref)
end
- def filename_path(project, filename)
- if project && blob = project.repository.send(filename)
- namespace_project_blob_path(
- project.namespace,
- project,
- tree_join(project.default_branch, blob.name)
- )
- end
- end
-
def sanitize_repo_path(project, message)
return '' unless message.present?
@@ -510,6 +480,73 @@ module ProjectsHelper
def restricted_levels
return [] if current_user.admin?
- current_application_settings.restricted_visibility_levels || []
+ Gitlab::CurrentSettings.restricted_visibility_levels || []
+ end
+
+ def project_permissions_settings(project)
+ feature = project.project_feature
+ {
+ visibilityLevel: project.visibility_level,
+ requestAccessEnabled: !!project.request_access_enabled,
+ issuesAccessLevel: feature.issues_access_level,
+ repositoryAccessLevel: feature.repository_access_level,
+ mergeRequestsAccessLevel: feature.merge_requests_access_level,
+ buildsAccessLevel: feature.builds_access_level,
+ wikiAccessLevel: feature.wiki_access_level,
+ snippetsAccessLevel: feature.snippets_access_level,
+ containerRegistryEnabled: !!project.container_registry_enabled,
+ lfsEnabled: !!project.lfs_enabled
+ }
+ end
+
+ def project_permissions_panel_data(project)
+ data = {
+ currentSettings: project_permissions_settings(project),
+ canChangeVisibilityLevel: can_change_visibility_level?(project, current_user),
+ allowedVisibilityOptions: project_allowed_visibility_levels(project),
+ visibilityHelpPath: help_page_path('public_access/public_access'),
+ registryAvailable: Gitlab.config.registry.enabled,
+ registryHelpPath: help_page_path('user/project/container_registry'),
+ lfsAvailable: Gitlab.config.lfs.enabled && current_user.admin?,
+ lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
+ }
+
+ data.to_json.html_safe
+ end
+
+ def project_allowed_visibility_levels(project)
+ Gitlab::VisibilityLevel.values.select do |level|
+ project.visibility_level_allowed?(level) && !restricted_levels.include?(level)
+ end
+ end
+
+ def find_file_path
+ return unless @project && !@project.empty_repo?
+
+ ref = @ref || @project.repository.root_ref
+
+ project_find_file_path(@project, ref)
+ end
+
+ def can_show_last_commit_in_list?(project)
+ can?(current_user, :read_cross_project) && project.commit
+ end
+
+ def pages_https_only_disabled?
+ !@project.pages_domains.all?(&:https?)
+ end
+
+ def pages_https_only_title
+ return unless pages_https_only_disabled?
+
+ "You must enable HTTPS for all your domains first"
+ end
+
+ def pages_https_only_label_class
+ if pages_https_only_disabled?
+ "list-label disabled"
+ else
+ "list-label"
+ end
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 8f15904f068..761c1252fc8 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -10,6 +10,7 @@ module SearchHelper
search_pattern = Regexp.new(Regexp.escape(term), "i")
generic_results = project_autocomplete + default_autocomplete + help_autocomplete
+ generic_results.concat(default_autocomplete_admin) if current_user.admin?
generic_results.select! { |result| result[:label] =~ search_pattern }
[
@@ -41,8 +42,14 @@ 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: "Dashboard", url: root_path }
+ ]
+ end
+
+ # Autocomplete results for settings pages, for admins
+ def default_autocomplete_admin
+ [
+ { category: "Settings", label: "Admin Section", url: admin_root_path }
]
end
@@ -67,16 +74,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "Current Project", label: "Files", url: namespace_project_tree_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Network", url: namespace_project_network_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Issues", url: namespace_project_issues_path(@project.namespace, @project) },
- { category: "Current Project", label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) },
- { 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: "Files", url: project_tree_path(@project, ref) },
+ { category: "Current Project", label: "Commits", url: project_commits_path(@project, ref) },
+ { category: "Current Project", label: "Network", url: project_network_path(@project, ref) },
+ { category: "Current Project", label: "Graph", url: project_graph_path(@project, ref) },
+ { category: "Current Project", label: "Issues", url: project_issues_path(@project) },
+ { category: "Current Project", label: "Merge Requests", url: project_merge_requests_path(@project) },
+ { category: "Current Project", label: "Milestones", url: project_milestones_path(@project) },
+ { category: "Current Project", label: "Snippets", url: project_snippets_path(@project) },
+ { category: "Current Project", label: "Members", url: project_project_members_path(@project) },
+ { category: "Current Project", label: "Wiki", url: project_wikis_path(@project) }
]
else
[]
@@ -85,7 +92,7 @@ module SearchHelper
# Autocomplete results for the current user's groups
def groups_autocomplete(term, limit = 5)
- current_user.authorized_groups.search(term).limit(limit).map do |group|
+ current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group|
{
category: "Groups",
id: group.id,
@@ -97,14 +104,14 @@ module SearchHelper
# Autocomplete results for the current user's projects
def projects_autocomplete(term, limit = 5)
- current_user.authorized_projects.search_by_title(term)
+ current_user.authorized_projects.order_id_desc.search_by_title(term)
.sorted_by_stars.non_archived.limit(limit).map do |p|
{
category: "Projects",
id: p.id,
value: "#{search_result_sanitize(p.name)}",
- label: "#{search_result_sanitize(p.name_with_namespace)}",
- url: namespace_project_path(p.namespace, p)
+ label: "#{search_result_sanitize(p.full_name)}",
+ url: project_path(p)
}
end
end
@@ -126,6 +133,29 @@ module SearchHelper
search_path(options)
end
+ def search_filter_input_options(type)
+ opts =
+ {
+ id: "filtered-search-#{type}",
+ placeholder: 'Search or filter results...',
+ data: {
+ 'username-params' => UserSerializer.new.represent(@users)
+ },
+ autocomplete: 'off'
+ }
+
+ if @project.present?
+ opts[:data]['project-id'] = @project.id
+ opts[:data]['base-endpoint'] = project_path(@project)
+ else
+ # Group context
+ opts[:data]['group-id'] = @group.id
+ opts[:data]['base-endpoint'] = group_canonical_path(@group)
+ end
+
+ opts
+ end
+
# Sanitize a HTML field for search display. Most tags are stripped out and the
# maximum length is set to 200 characters.
def search_md_sanitize(object, field)
@@ -140,4 +170,8 @@ module SearchHelper
# Truncato's filtered_tags and filtered_attributes are not quite the same
sanitize(html, tags: %w(a p ol ul li pre code))
end
+
+ def limited_count(count, limit = 1000)
+ count > limit ? "#{limit}+" : count
+ end
end
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 1a4f1431bdc..6cefcde558a 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -73,7 +73,6 @@ module SelectsHelper
email_user: opts[:email_user] || false,
first_user: opts[:first_user] && current_user ? current_user.username : false,
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
}
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 3707bb5ba36..f435c80c656 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -1,31 +1,19 @@
module ServicesHelper
- def service_event_description(event)
- case event
- when "push", "push_events"
- "Event will be triggered by a push to the repository"
- when "tag_push", "tag_push_events"
- "Event will be triggered when a new tag is pushed to the repository"
- when "note", "note_events"
- "Event will be triggered when someone adds a comment"
- when "issue", "issue_events"
- "Event will be triggered when an issue is created/updated/closed"
- when "confidential_issue", "confidential_issue_events"
- "Event will be triggered when a confidential issue is created/updated/closed"
- when "merge_request", "merge_request_events"
- "Event will be triggered when a merge request is created/updated/merged"
- when "pipeline", "pipeline_events"
- "Event will be triggered when a pipeline status changes"
- when "wiki_page", "wiki_page_events"
- "Event will be triggered when a wiki page is created/updated"
- when "commit", "commit_events"
- "Event will be triggered when a commit is created/updated"
- end
- end
-
def service_event_field_name(event)
event = event.pluralize if %w[merge_request issue confidential_issue].include?(event)
"#{event}_events"
end
+ def service_save_button(service)
+ button_tag(class: 'btn btn-save', type: 'submit', disabled: service.deprecated?) do
+ icon('spinner spin', class: 'hidden js-btn-spinner') +
+ content_tag(:span, 'Save changes', class: 'js-btn-label')
+ end
+ end
+
+ def disable_fields_service?(service)
+ !current_controller?("admin/services") && service.deprecated?
+ end
+
extend self
end
diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb
index 55f4da0ef85..50aeb7f4b82 100644
--- a/app/helpers/sidekiq_helper.rb
+++ b/app/helpers/sidekiq_helper.rb
@@ -1,12 +1,12 @@
module SidekiqHelper
- SIDEKIQ_PS_REGEXP = /\A
+ SIDEKIQ_PS_REGEXP = %r{\A
(?<pid>\d+)\s+
(?<cpu>[\d\.,]+)\s+
(?<mem>[\d\.,]+)\s+
- (?<state>[DIEKNRSTVWXZNLpsl\+<>\/\d]+)\s+
+ (?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+
(?<start>.+?)\s+
(?<command>(?:ruby\d+:\s+)?sidekiq.*\].*)
- \z/x
+ \z}x
def parse_sidekiq_ps(line)
match = line.strip.match(SIDEKIQ_PS_REGEXP)
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 2fd64b3441e..00e7e4230b9 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -1,8 +1,7 @@
module SnippetsHelper
def reliable_snippet_path(snippet, opts = nil)
if snippet.project_id?
- namespace_project_snippet_path(snippet.project.namespace,
- snippet.project, snippet, opts)
+ project_snippet_path(snippet.project, snippet, opts)
else
snippet_path(snippet, opts)
end
@@ -10,7 +9,7 @@ module SnippetsHelper
def download_snippet_path(snippet)
if snippet.project_id
- raw_namespace_project_snippet_path(@project.namespace, @project, snippet, inline: false)
+ raw_project_snippet_path(@project, snippet, inline: false)
else
raw_snippet_path(snippet, inline: false)
end
@@ -21,7 +20,7 @@ module SnippetsHelper
# @returns String, path to snippet index
def subject_snippets_path(subject = nil, opts = nil)
if subject.is_a?(Project)
- namespace_project_snippets_path(subject.namespace, subject, opts)
+ project_snippets_path(subject, opts)
else # assume subject === User
dashboard_snippets_path(opts)
end
@@ -90,6 +89,7 @@ module SnippetsHelper
snippet_chunk = [lined_content[line_number]]
snippet_start_line = line_number
end
+
last_line = line_number
end
# Add final chunk to chunk array
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index b408ec0c6a4..36a311dfa8a 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -1,34 +1,38 @@
module SortingHelper
def sort_options_hash
{
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_recently_updated => sort_title_recently_updated,
- sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_created_date => sort_title_created_date,
+ sort_value_downvotes => sort_title_downvotes,
+ sort_value_due_date => sort_title_due_date,
+ sort_value_due_date_later => sort_title_due_date_later,
+ sort_value_due_date_soon => sort_title_due_date_soon,
+ sort_value_label_priority => sort_title_label_priority,
+ sort_value_largest_group => sort_title_largest_group,
+ sort_value_largest_repo => sort_title_largest_repo,
+ sort_value_milestone => sort_title_milestone,
+ sort_value_milestone_later => sort_title_milestone_later,
+ sort_value_milestone_soon => sort_title_milestone_soon,
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_oldest_signin => sort_title_oldest_signin,
+ sort_value_oldest_updated => sort_title_oldest_updated,
sort_value_recently_created => sort_title_recently_created,
- sort_value_oldest_created => sort_title_oldest_created,
- sort_value_milestone_soon => sort_title_milestone_soon,
- sort_value_milestone_later => sort_title_milestone_later,
- sort_value_due_date_soon => sort_title_due_date_soon,
- sort_value_due_date_later => sort_title_due_date_later,
- sort_value_largest_repo => sort_title_largest_repo,
- sort_value_largest_group => sort_title_largest_group,
- sort_value_recently_signin => sort_title_recently_signin,
- sort_value_oldest_signin => sort_title_oldest_signin,
- sort_value_downvotes => sort_title_downvotes,
- sort_value_upvotes => sort_title_upvotes,
- sort_value_priority => sort_title_priority,
- sort_value_label_priority => sort_title_label_priority
+ sort_value_recently_signin => sort_title_recently_signin,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_popularity => sort_title_popularity,
+ sort_value_priority => sort_title_priority,
+ sort_value_upvotes => sort_title_upvotes
}
end
def projects_sort_options_hash
options = {
- sort_value_name => sort_title_name,
- sort_value_latest_activity => sort_title_latest_activity,
- sort_value_oldest_activity => sort_title_oldest_activity,
- sort_value_recently_created => sort_title_recently_created,
- sort_value_oldest_created => sort_title_oldest_created
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_name => sort_title_name,
+ sort_value_oldest_activity => sort_title_oldest_activity,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_recently_created => sort_title_recently_created
}
if current_controller?('admin/projects')
@@ -38,162 +42,193 @@ module SortingHelper
options
end
+ def groups_sort_options_hash
+ {
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_oldest_updated => sort_title_oldest_updated
+ }
+ end
+
+ def admin_groups_sort_options_hash
+ groups_sort_options_hash.merge(
+ sort_value_largest_group => sort_title_largest_group
+ )
+ end
+
def member_sort_options_hash
{
- sort_value_access_level_asc => sort_title_access_level_asc,
+ sort_value_access_level_asc => sort_title_access_level_asc,
sort_value_access_level_desc => sort_title_access_level_desc,
- sort_value_last_joined => sort_title_last_joined,
- sort_value_oldest_joined => sort_title_oldest_joined,
- sort_value_name => sort_title_name_asc,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_recently_signin => sort_title_recently_signin,
- sort_value_oldest_signin => sort_title_oldest_signin
+ sort_value_last_joined => sort_title_last_joined,
+ sort_value_name => sort_title_name_asc,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_oldest_joined => sort_title_oldest_joined,
+ sort_value_oldest_signin => sort_title_oldest_signin,
+ sort_value_recently_signin => sort_title_recently_signin
}
end
def milestone_sort_options_hash
{
- sort_value_name => sort_title_name_asc,
- sort_value_name_desc => sort_title_name_desc,
- 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_name => sort_title_name_asc,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_due_date_later => sort_title_due_date_later,
+ sort_value_due_date_soon => sort_title_due_date_soon,
+ sort_value_start_date_later => sort_title_start_date_later,
+ sort_value_start_date_soon => sort_title_start_date_soon
}
end
def branches_sort_options_hash
{
- sort_value_name => sort_title_name,
- sort_value_recently_updated => sort_title_recently_updated,
- sort_value_oldest_updated => sort_title_oldest_updated
+ sort_value_name => sort_title_name,
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_updated => sort_title_recently_updated
}
end
def tags_sort_options_hash
{
- sort_value_name => sort_title_name,
- sort_value_recently_updated => sort_title_recently_updated,
- sort_value_oldest_updated => sort_title_oldest_updated
+ sort_value_name => sort_title_name,
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_updated => sort_title_recently_updated
}
end
- def sort_title_priority
- 'Priority'
+ def sortable_item(item, path, sorted_by)
+ link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
- def sort_title_label_priority
- 'Label priority'
+ # Titles.
+ def sort_title_access_level_asc
+ s_('SortOptions|Access level, ascending')
end
- def sort_title_oldest_updated
- 'Oldest updated'
+ def sort_title_access_level_desc
+ s_('SortOptions|Access level, descending')
end
- def sort_title_recently_updated
- 'Last updated'
+ def sort_title_created_date
+ s_('SortOptions|Created date')
end
- def sort_title_oldest_activity
- 'Oldest updated'
+ def sort_title_downvotes
+ s_('SortOptions|Least popular')
end
- def sort_title_latest_activity
- 'Last updated'
+ def sort_title_due_date
+ s_('SortOptions|Due date')
end
- def sort_title_oldest_created
- 'Oldest created'
+ def sort_title_due_date_later
+ s_('SortOptions|Due later')
end
- def sort_title_recently_created
- 'Last created'
+ def sort_title_due_date_soon
+ s_('SortOptions|Due soon')
end
- def sort_title_milestone_soon
- 'Milestone due soon'
+ def sort_title_label_priority
+ s_('SortOptions|Label priority')
end
- def sort_title_milestone_later
- 'Milestone due later'
+ def sort_title_largest_group
+ s_('SortOptions|Largest group')
end
- def sort_title_due_date_soon
- 'Due soon'
+ def sort_title_largest_repo
+ s_('SortOptions|Largest repository')
end
- def sort_title_due_date_later
- 'Due later'
+ def sort_title_last_joined
+ s_('SortOptions|Last joined')
end
- def sort_title_start_date_soon
- 'Start soon'
+ def sort_title_latest_activity
+ s_('SortOptions|Last updated')
end
- def sort_title_start_date_later
- 'Start later'
+ def sort_title_milestone
+ s_('SortOptions|Milestone')
+ end
+
+ def sort_title_milestone_later
+ s_('SortOptions|Milestone due later')
+ end
+
+ def sort_title_milestone_soon
+ s_('SortOptions|Milestone due soon')
end
def sort_title_name
- 'Name'
+ s_('SortOptions|Name')
end
- def sort_title_largest_repo
- 'Largest repository'
+ def sort_title_name_asc
+ s_('SortOptions|Name, ascending')
end
- def sort_title_largest_group
- 'Largest group'
+ def sort_title_name_desc
+ s_('SortOptions|Name, descending')
end
- def sort_title_recently_signin
- 'Recent sign in'
+ def sort_title_oldest_activity
+ s_('SortOptions|Oldest updated')
end
- def sort_title_oldest_signin
- 'Oldest sign in'
+ def sort_title_oldest_created
+ s_('SortOptions|Oldest created')
end
- def sort_title_downvotes
- 'Least popular'
+ def sort_title_oldest_joined
+ s_('SortOptions|Oldest joined')
end
- def sort_title_upvotes
- 'Most popular'
+ def sort_title_oldest_signin
+ s_('SortOptions|Oldest sign in')
end
- def sort_title_last_joined
- 'Last joined'
+ def sort_title_oldest_updated
+ s_('SortOptions|Oldest updated')
end
- def sort_title_oldest_joined
- 'Oldest joined'
+ def sort_title_popularity
+ s_('SortOptions|Popularity')
end
- def sort_title_access_level_asc
- 'Access level, ascending'
+ def sort_title_priority
+ s_('SortOptions|Priority')
end
- def sort_title_access_level_desc
- 'Access level, descending'
+ def sort_title_recently_created
+ s_('SortOptions|Last created')
end
- def sort_title_name_asc
- 'Name, ascending'
+ def sort_title_recently_signin
+ s_('SortOptions|Recent sign in')
end
- def sort_title_name_desc
- 'Name, descending'
+ def sort_title_recently_updated
+ s_('SortOptions|Last updated')
end
- def sort_value_last_joined
- 'last_joined'
+ def sort_title_start_date_later
+ s_('SortOptions|Start later')
end
- def sort_value_oldest_joined
- 'oldest_joined'
+ def sort_title_start_date_soon
+ s_('SortOptions|Start soon')
end
+ def sort_title_upvotes
+ s_('SortOptions|Most popular')
+ end
+
+ # Values.
def sort_value_access_level_asc
'access_level_asc'
end
@@ -202,88 +237,112 @@ module SortingHelper
'access_level_desc'
end
- def sort_value_name_desc
- 'name_desc'
+ def sort_value_created_date
+ 'created_date'
end
- def sort_value_priority
- 'priority'
+ def sort_value_downvotes
+ 'downvotes_desc'
+ end
+
+ def sort_value_due_date
+ 'due_date'
+ end
+
+ def sort_value_due_date_later
+ 'due_date_desc'
+ end
+
+ def sort_value_due_date_soon
+ 'due_date_asc'
end
def sort_value_label_priority
'label_priority'
end
- def sort_value_oldest_updated
- 'updated_asc'
+ def sort_value_largest_group
+ 'storage_size_desc'
end
- def sort_value_recently_updated
- 'updated_desc'
+ def sort_value_largest_repo
+ 'storage_size_desc'
end
- def sort_value_oldest_activity
- 'latest_activity_asc'
+ def sort_value_last_joined
+ 'last_joined'
end
def sort_value_latest_activity
'latest_activity_desc'
end
- def sort_value_oldest_created
- 'created_asc'
+ def sort_value_milestone
+ 'milestone'
end
- def sort_value_recently_created
- 'created_desc'
+ def sort_value_milestone_later
+ 'milestone_due_desc'
end
def sort_value_milestone_soon
'milestone_due_asc'
end
- def sort_value_milestone_later
- 'milestone_due_desc'
+ def sort_value_name
+ 'name_asc'
end
- def sort_value_due_date_soon
- 'due_date_asc'
+ def sort_value_name_desc
+ 'name_desc'
end
- def sort_value_due_date_later
- 'due_date_desc'
+ def sort_value_oldest_activity
+ 'latest_activity_asc'
end
- def sort_value_start_date_soon
- 'start_date_asc'
+ def sort_value_oldest_created
+ 'created_asc'
end
- def sort_value_start_date_later
- 'start_date_desc'
+ def sort_value_oldest_signin
+ 'oldest_sign_in'
end
- def sort_value_name
- 'name_asc'
+ def sort_value_oldest_joined
+ 'oldest_joined'
end
- def sort_value_largest_repo
- 'storage_size_desc'
+ def sort_value_oldest_updated
+ 'updated_asc'
end
- def sort_value_largest_group
- 'storage_size_desc'
+ def sort_value_popularity
+ 'popularity'
+ end
+
+ def sort_value_priority
+ 'priority'
+ end
+
+ def sort_value_recently_created
+ 'created_desc'
end
def sort_value_recently_signin
'recent_sign_in'
end
- def sort_value_oldest_signin
- 'oldest_sign_in'
+ def sort_value_recently_updated
+ 'updated_desc'
end
- def sort_value_downvotes
- 'downvotes_desc'
+ def sort_value_start_date_later
+ 'start_date_desc'
+ end
+
+ def sort_value_start_date_soon
+ 'start_date_asc'
end
def sort_value_upvotes
diff --git a/app/helpers/storage_health_helper.rb b/app/helpers/storage_health_helper.rb
new file mode 100644
index 00000000000..b76c1228220
--- /dev/null
+++ b/app/helpers/storage_health_helper.rb
@@ -0,0 +1,32 @@
+module StorageHealthHelper
+ def failing_storage_health_message(storage_health)
+ storage_name = content_tag(:strong, h(storage_health.storage_name))
+ host_names = h(storage_health.failing_on_hosts.to_sentence)
+ translation_params = { storage_name: storage_name,
+ host_names: host_names,
+ failed_attempts: storage_health.total_failures }
+
+ translation = n_('%{storage_name}: failed storage access attempt on host:',
+ '%{storage_name}: %{failed_attempts} failed storage access attempts:',
+ storage_health.total_failures) % translation_params
+
+ translation.html_safe
+ end
+
+ def message_for_circuit_breaker(circuit_breaker)
+ maximum_failures = circuit_breaker.failure_count_threshold
+ current_failures = circuit_breaker.failure_count
+
+ translation_params = { number_of_failures: current_failures,
+ maximum_failures: maximum_failures }
+
+ if circuit_breaker.circuit_broken?
+ s_("%{number_of_failures} of %{maximum_failures} failures. GitLab will not "\
+ "retry automatically. Reset storage information when the problem is "\
+ "resolved.") % translation_params
+ else
+ _("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
+ "allow access on the next attempt.") % translation_params
+ end
+ end
+end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index 8e0a1e2ecdf..9151543dfdc 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -1,5 +1,5 @@
module SubmoduleHelper
- include Gitlab::ShellAdapter
+ extend self
VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze
@@ -11,7 +11,7 @@ module SubmoduleHelper
url = File.join(Gitlab.config.gitlab.url, @project.full_path)
end
- if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
+ if url =~ %r{([^/:]+)/([^/]+(?:\.git)?)\Z}
namespace, project = $1, $2
gitlab_hosts = [Gitlab.config.gitlab.url,
Gitlab.config.gitlab_shell.ssh_path_prefix]
@@ -23,7 +23,7 @@ module SubmoduleHelper
end
end
- namespace.sub!(/\A\//, '')
+ namespace.sub!(%r{\A/}, '')
project.rstrip!
project.sub!(/\.git\z/, '')
@@ -47,24 +47,25 @@ module SubmoduleHelper
protected
def github_dot_com_url?(url)
- url =~ /github\.com[\/:][^\/]+\/[^\/]+\Z/
+ url =~ %r{github\.com[/:][^/]+/[^/]+\Z}
end
def gitlab_dot_com_url?(url)
- url =~ /gitlab\.com[\/:][^\/]+\/[^\/]+\Z/
+ url =~ %r{gitlab\.com[/:][^/]+/[^/]+\Z}
end
def self_url?(url, namespace, project)
url_no_dotgit = url.chomp('.git')
return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/',
project].join('')
+
url_with_dotgit = url_no_dotgit + '.git'
- url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join(''))
+ url_with_dotgit == Gitlab::Shell.new.url_to_repo([namespace, '/', project].join(''))
end
def relative_self_url?(url)
# (./)?(../repo.git) || (./)?(../../project/repo.git) )
- url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*(\.git)?\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*(\.git)?\z/
+ url =~ %r{\A((\./)?(\.\./))(?!(\.\.)|(.*/)).*(\.git)?\z} || url =~ %r{\A((\./)?(\.\./){2})(?!(\.\.))([^/]*)/(?!(\.\.)|(.*/)).*(\.git)?\z}
end
def standard_links(host, namespace, project, commit)
@@ -73,6 +74,7 @@ module SubmoduleHelper
end
def relative_self_links(url, commit)
+ url.rstrip!
# Map relative links to a namespace and project
# For example:
# ../bar.git -> same namespace, repo bar
@@ -86,10 +88,14 @@ module SubmoduleHelper
namespace = @project.namespace.full_path
end
- [
- namespace_project_path(namespace, base),
- namespace_project_tree_path(namespace, base, commit)
- ]
+ begin
+ [
+ namespace_project_path(namespace, base),
+ namespace_project_tree_path(namespace, base, commit)
+ ]
+ rescue ActionController::UrlGenerationError
+ [nil, nil]
+ end
end
def sanitize_submodule_url(url)
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 209bd56b78a..00fe67d6ffb 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -1,28 +1,37 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
- 'commit' => 'icon_commit',
- 'description' => 'icon_edit',
- 'merge' => 'icon_merge',
- 'merged' => 'icon_merged',
- 'opened' => 'icon_status_open',
- 'closed' => 'icon_status_closed',
- 'time_tracking' => 'icon_stopwatch',
- 'assignee' => 'icon_user',
- 'title' => 'icon_edit',
- 'task' => 'icon_check_square_o',
- 'label' => 'icon_tags',
- 'cross_reference' => 'icon_random',
- 'branch' => 'icon_code_fork',
- 'confidential' => 'icon_eye_slash',
- 'visible' => 'icon_eye',
- 'milestone' => 'icon_clock_o',
- 'discussion' => 'icon_comment_o',
- 'moved' => 'icon_arrow_circle_o_right',
- 'outdated' => 'icon_edit'
+ 'commit' => 'commit',
+ 'description' => 'pencil',
+ 'merge' => 'git-merge',
+ 'merged' => 'git-merge',
+ 'opened' => 'issue-open',
+ 'closed' => 'issue-close',
+ 'time_tracking' => 'timer',
+ 'assignee' => 'user',
+ 'title' => 'pencil',
+ 'task' => 'task-done',
+ 'label' => 'label',
+ 'cross_reference' => 'comment-dots',
+ 'branch' => 'fork',
+ 'confidential' => 'eye-slash',
+ 'visible' => 'eye',
+ 'milestone' => 'clock',
+ 'discussion' => 'comment',
+ 'moved' => 'arrow-right',
+ 'outdated' => 'pencil',
+ 'duplicate' => 'issue-duplicate',
+ 'locked' => 'lock',
+ 'unlocked' => 'lock-open'
}.freeze
+ def system_note_icon_name(note)
+ ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ end
+
def icon_for_system_note(note)
- icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
- custom_icon(icon_name) if icon_name
+ icon_name = system_note_icon_name(note)
+ sprite_icon(icon_name) if icon_name
end
+
+ extend self
end
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index 1a55ee05996..ee701076a14 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -107,8 +107,7 @@ module TabHelper
def branches_tab_class
if current_controller?(:protected_branches) ||
current_controller?(:branches) ||
- current_page?(namespace_project_repository_path(@project.namespace,
- @project))
+ current_page?(project_repository_path(@project))
'active'
end
end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index 31aaf9e5607..d000d6b1c0a 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -10,7 +10,7 @@ module TagsHelper
}
options = exist_opts.merge(options)
- namespace_project_tags_path(@project.namespace, @project, @id, options)
+ project_tags_path(@project, @id, options)
end
def tag_list(project)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 3d1b3a4711a..f7620e0b6b8 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -30,6 +30,7 @@ module TodosHelper
else
todo.target_reference
end
+
link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title
end
@@ -39,7 +40,7 @@ module TodosHelper
anchor = dom_id(todo.note) if todo.note.present?
if todo.for_commit?
- namespace_project_commit_path(todo.project.namespace.becomes(Namespace), todo.project,
+ project_commit_path(todo.project,
todo.target, anchor: anchor)
else
path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target]
@@ -53,8 +54,16 @@ module TodosHelper
def todo_target_state_pill(todo)
return unless show_todo_state?(todo)
+ type =
+ case todo.target
+ when MergeRequest
+ 'mr'
+ when Issue
+ 'issue'
+ end
+
content_tag(:span, nil, class: 'target-status') do
- content_tag(:span, nil, class: "status-box status-box-#{todo.target.state.dasherize}") do
+ content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.dasherize}") do
todo.target.state.capitalize
end
end
@@ -105,7 +114,7 @@ module TodosHelper
projects = current_user.authorized_projects.sorted_by_activity.non_archived.with_route
projects = projects.map do |project|
- { id: project.id, text: project.name_with_namespace }
+ { id: project.id, text: project.full_name }
end
projects.unshift({ id: '', text: 'Any Project' }).to_json
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index e0d3e9b88f3..b64be89c181 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -1,14 +1,23 @@
module TreeHelper
+ FILE_LIMIT = 1_000
+
# Sorts a repository's tree so that folders are before files and renders
# their corresponding partials
#
- # contents - A Grit::Tree object for the current tree
+ # tree - A `Tree` object for the current tree
def render_tree(tree)
# Sort submodules and folders together by name ahead of files
folders, files, submodules = tree.trees, tree.blobs, tree.submodules
- tree = ""
+ tree = ''
items = (folders + submodules).sort_by(&:name) + files
- tree << render(partial: "projects/tree/tree_row", collection: items) if items.present?
+
+ if items.size > FILE_LIMIT
+ tree << render(partial: 'projects/tree/truncated_notice_tree_row',
+ locals: { limit: FILE_LIMIT, total: items.size })
+ items = items.take(FILE_LIMIT)
+ end
+
+ tree << render(partial: 'projects/tree/tree_row', collection: items) if items.present?
tree.html_safe
end
@@ -40,13 +49,13 @@ module TreeHelper
return false unless on_top_of_branch?(project, ref)
- can_collaborate_with_project?(project)
+ can_collaborate_with_project?(project, ref: ref)
end
def tree_edit_branch(project = @project, ref = @ref)
return unless can_edit_tree?(project, ref)
- if can_push_branch?(project, ref)
+ if user_access(project).can_push_to_branch?(ref)
ref
else
project = tree_edit_project(project)
@@ -72,8 +81,21 @@ module TreeHelper
" A fork of this project has been created that you can make changes in, so you can submit a merge request."
end
+ def edit_in_new_fork_notice_action(action)
+ edit_in_new_fork_notice + " Try to #{action} this file again."
+ end
+
def commit_in_fork_help
- "A new branch will be created in your fork and a new merge request will be started."
+ _("A new branch will be created in your fork and a new merge request will be started.")
+ end
+
+ def commit_in_single_accessible_branch
+ branch_name = html_escape(selected_branch)
+
+ message = _("Your changes can be committed to %{branch_name} because a merge "\
+ "request is open.") % { branch_name: "<strong>#{branch_name}</strong>" }
+
+ message.html_safe
end
def path_breadcrumbs(max_links = 6)
@@ -88,6 +110,7 @@ module TreeHelper
part_path = part if part_path.empty?
next if parts.count > max_links && !parts.last(2).include?(part)
+
yield(part, part_path)
end
end
@@ -99,12 +122,18 @@ module TreeHelper
end
# returns the relative path of the first subdir that doesn't have only one directory descendant
- def flatten_tree(tree)
+ def flatten_tree(root_path, tree)
+ return tree.flat_path.sub(%r{\A#{root_path}/}, '') if tree.flat_path.present?
+
subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path)
if subtree.count == 1 && subtree.first.dir?
- return tree_join(tree.name, flatten_tree(subtree.first))
+ return tree_join(tree.name, flatten_tree(root_path, subtree.first))
else
return tree.name
end
end
+
+ def selected_branch
+ @branch_name || tree_edit_branch
+ end
end
diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb
index a48d4475e97..ce435ca2241 100644
--- a/app/helpers/triggers_helper.rb
+++ b/app/helpers/triggers_helper.rb
@@ -8,6 +8,6 @@ module TriggersHelper
end
def service_trigger_url(service)
- "#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger"
+ "#{Settings.gitlab.url}/api/v4/projects/#{service.project_id}/services/#{service.to_param}/trigger"
end
end
diff --git a/app/helpers/u2f_helper.rb b/app/helpers/u2f_helper.rb
deleted file mode 100644
index 81bfe5d4eeb..00000000000
--- a/app/helpers/u2f_helper.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-module U2fHelper
- def inject_u2f_api?
- ((browser.chrome? && browser.version.to_i >= 41) || (browser.opera? && browser.version.to_i >= 40)) && !browser.device.mobile?
- end
-end
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
new file mode 100644
index 00000000000..36abfaf19a5
--- /dev/null
+++ b/app/helpers/user_callouts_helper.rb
@@ -0,0 +1,14 @@
+module UserCalloutsHelper
+ GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze
+
+ def show_gke_cluster_integration_callout?(project)
+ can?(current_user, :create_cluster, project) &&
+ !user_dismissed?(GKE_CLUSTER_INTEGRATION)
+ end
+
+ private
+
+ def user_dismissed?(feature_name)
+ current_user&.callouts&.find_by(feature_name: UserCallout.feature_names[feature_name])
+ end
+end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index b5f54d3e154..01af68088df 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -14,4 +14,18 @@ module UsersHelper
content_tag(:strong) { user.unconfirmed_email } + h('.') +
content_tag(:p) { confirmation_link }
end
+
+ def profile_tabs
+ @profile_tabs ||= get_profile_tabs
+ end
+
+ def profile_tab?(tab)
+ profile_tabs.include?(tab)
+ end
+
+ private
+
+ def get_profile_tabs
+ [:activity, :groups, :contributed, :projects, :snippets]
+ end
end
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index 456598b4c28..c20753ece72 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -1,6 +1,6 @@
module VersionCheckHelper
def version_status_badge
- if Rails.env.production? && current_application_settings.version_check_enabled
+ if Rails.env.production? && Gitlab::CurrentSettings.version_check_enabled
image_url = VersionCheck.new.url
image_tag image_url, class: 'js-version-status-badge'
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 35755bc149b..e395cda03d3 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -63,6 +63,68 @@ module VisibilityLevelHelper
end
end
+ def restricted_visibility_level_description(level)
+ level_name = Gitlab::VisibilityLevel.level_name(level)
+ "#{level_name.capitalize} visibility has been restricted by the administrator."
+ end
+
+ def disallowed_visibility_level_description(level, form_model)
+ case form_model
+ when Project
+ disallowed_project_visibility_level_description(level, form_model)
+ when Group
+ disallowed_group_visibility_level_description(level, form_model)
+ end
+ end
+
+ # Note: these messages closely mirror the form validation strings found in the project
+ # model and any changes or additons to these may also need to be made there.
+ def disallowed_project_visibility_level_description(level, project)
+ level_name = Gitlab::VisibilityLevel.level_name(level).downcase
+ reasons = []
+ instructions = ''
+
+ unless project.visibility_level_allowed_as_fork?(level)
+ reasons << "the fork source project has lower visibility"
+ end
+
+ unless project.visibility_level_allowed_by_group?(level)
+ errors = visibility_level_errors_for_group(project.group, level_name)
+
+ reasons << errors[:reason]
+ instructions << errors[:instruction]
+ end
+
+ reasons = reasons.any? ? ' because ' + reasons.to_sentence : ''
+ "This project cannot be #{level_name}#{reasons}.#{instructions}".html_safe
+ end
+
+ # Note: these messages closely mirror the form validation strings found in the group
+ # model and any changes or additons to these may also need to be made there.
+ def disallowed_group_visibility_level_description(level, group)
+ level_name = Gitlab::VisibilityLevel.level_name(level).downcase
+ reasons = []
+ instructions = ''
+
+ unless group.visibility_level_allowed_by_projects?(level)
+ reasons << "it contains projects with higher visibility"
+ end
+
+ unless group.visibility_level_allowed_by_sub_groups?(level)
+ reasons << "it contains sub-groups with higher visibility"
+ end
+
+ unless group.visibility_level_allowed_by_parent?(level)
+ errors = visibility_level_errors_for_group(group.parent, level_name)
+
+ reasons << errors[:reason]
+ instructions << errors[:instruction]
+ end
+
+ reasons = reasons.any? ? ' because ' + reasons.to_sentence : ''
+ "This group cannot be #{level_name}#{reasons}.#{instructions}".html_safe
+ end
+
def visibility_icon_description(form_model)
case form_model
when Project
@@ -88,14 +150,27 @@ module VisibilityLevelHelper
def restricted_visibility_levels(show_all = false)
return [] if current_user.admin? && !show_all
- current_application_settings.restricted_visibility_levels || []
+
+ Gitlab::CurrentSettings.restricted_visibility_levels || []
end
delegate :default_project_visibility,
:default_group_visibility,
- to: :current_application_settings
+ to: :'Gitlab::CurrentSettings.current_application_settings'
+
+ def disallowed_visibility_level?(form_model, level)
+ return false unless form_model.respond_to?(:visibility_level_allowed?)
+
+ !form_model.visibility_level_allowed?(level)
+ end
+
+ private
+
+ def visibility_level_errors_for_group(group, level_name)
+ group_name = link_to group.name, group_path(group)
+ change_visiblity = link_to 'change the visibility', edit_group_path(group)
- def skip_level?(form_model, level)
- form_model.is_a?(Project) && !form_model.visibility_level_allowed?(level)
+ { reason: "the visibility of #{group_name} is #{group.visibility}",
+ instruction: " To make this group #{level_name}, you must first #{change_visiblity} of the parent group." }
end
end
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
index 6bacda9fe75..8bcced70d63 100644
--- a/app/helpers/webpack_helper.rb
+++ b/app/helpers/webpack_helper.rb
@@ -1,30 +1,68 @@
require 'webpack/rails/manifest'
module WebpackHelper
- def webpack_bundle_tag(bundle)
- javascript_include_tag(*gitlab_webpack_asset_paths(bundle))
+ def webpack_bundle_tag(bundle, force_same_domain: false)
+ javascript_include_tag(*gitlab_webpack_asset_paths(bundle, force_same_domain: force_same_domain))
+ end
+
+ def webpack_controller_bundle_tags
+ bundles = []
+
+ action = case controller.action_name
+ when 'create' then 'new'
+ when 'update' then 'edit'
+ else controller.action_name
+ end
+
+ route = [*controller.controller_path.split('/'), action].compact
+
+ until route.empty?
+ begin
+ asset_paths = gitlab_webpack_asset_paths("pages.#{route.join('.')}", extension: 'js')
+ bundles.unshift(*asset_paths)
+ rescue Webpack::Rails::Manifest::EntryPointMissingError
+ # no bundle exists for this path
+ end
+
+ route.pop
+ end
+
+ javascript_include_tag(*bundles)
end
# override webpack-rails gem helper until changes can make it upstream
- def gitlab_webpack_asset_paths(source, extension: nil)
+ def gitlab_webpack_asset_paths(source, extension: nil, force_same_domain: false)
return "" unless source.present?
paths = Webpack::Rails::Manifest.asset_paths(source)
if extension
- paths = paths.select { |p| p.ends_with? ".#{extension}" }
+ paths.select! { |p| p.ends_with? ".#{extension}" }
end
- # include full webpack-dev-server url for rspec tests running locally
+ unless force_same_domain
+ force_host = webpack_public_host
+ if force_host
+ paths.map! { |p| "#{force_host}#{p}" }
+ end
+ end
+
+ paths
+ end
+
+ def webpack_public_host
if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled
host = Rails.configuration.webpack.dev_server.host
port = Rails.configuration.webpack.dev_server.port
protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http'
-
- paths.map! do |p|
- "#{protocol}://#{host}:#{port}#{p}"
- end
+ "#{protocol}://#{host}:#{port}"
+ else
+ ActionController::Base.asset_host.try(:chomp, '/')
end
+ end
- paths
+ def webpack_public_path
+ relative_path = Rails.application.config.relative_url_root
+ webpack_path = Rails.application.config.webpack.public_path
+ File.join(webpack_public_host.to_s, relative_path.to_s, webpack_path.to_s, '')
end
end
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 99212a3438f..41f9eedd4bd 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -10,4 +10,33 @@ module WikiHelper
.map { |dir_or_page| WikiPage.unhyphenize(dir_or_page).capitalize }
.join(' / ')
end
+
+ def wiki_breadcrumb_dropdown_links(page_slug)
+ page_slug_split = page_slug.split('/')
+ page_slug_split.pop(1)
+ current_slug = ""
+ page_slug_split
+ .map do |dir_or_page|
+ current_slug = "#{current_slug}#{dir_or_page}/"
+ add_to_breadcrumb_dropdown link_to(WikiPage.unhyphenize(dir_or_page).capitalize, project_wiki_path(@project, current_slug)), location: :after
+ end
+ end
+
+ def wiki_page_errors(error)
+ return unless error
+
+ content_tag(:div, class: 'alert alert-danger') do
+ case error
+ when WikiPage::PageChangedError
+ page_link = link_to s_("WikiPageConflictMessage|the page"), project_wiki_path(@project, @page), target: "_blank"
+ concat(
+ (s_("WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs.") % { page_link: page_link }).html_safe
+ )
+ when WikiPage::PageRenameError
+ s_("WikiEdit|There is already a page with the same title in that path.")
+ else
+ error.message
+ end
+ end
+ end
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index d0ce827a595..fe5f68ba3d5 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -1,13 +1,11 @@
class AbuseReportMailer < BaseMailer
- include Gitlab::CurrentSettings
-
def notify(abuse_report_id)
return unless deliverable?
@abuse_report = AbuseReport.find(abuse_report_id)
mail(
- to: current_application_settings.admin_notification_email,
+ to: Gitlab::CurrentSettings.admin_notification_email,
subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
)
end
@@ -15,6 +13,6 @@ class AbuseReportMailer < BaseMailer
private
def deliverable?
- current_application_settings.admin_notification_email.present?
+ Gitlab::CurrentSettings.admin_notification_email.present?
end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 0f847841295..b33131becd3 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -1,54 +1,54 @@
module Emails
module Issues
- def new_issue_email(recipient_id, issue_id)
+ def new_issue_email(recipient_id, issue_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id))
+ mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason))
end
- def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id)
+ def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
+ def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@previous_assignees = []
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def closed_issue_email(recipient_id, issue_id, updated_by_user_id)
+ def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id)
+ def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@label_names = label_names
- @labels_url = namespace_project_labels_url(@project.namespace, @project)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ @labels_url = project_labels_url(@project)
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id)
+ def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@issue_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def issue_moved_email(recipient, issue, new_issue, updated_by_user)
+ def issue_moved_email(recipient, issue, new_issue, updated_by_user, reason = nil)
setup_issue_mail(issue.id, recipient.id)
@new_issue = new_issue
@new_project = new_issue.project
- mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id))
+ mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end
private
@@ -56,16 +56,17 @@ module Emails
def setup_issue_mail(issue_id, recipient_id)
@issue = Issue.find(issue_id)
@project = @issue.project
- @target_url = namespace_project_issue_url(@project.namespace, @project, @issue)
+ @target_url = project_issue_url(@project, @issue)
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
- def issue_thread_options(sender_id, recipient_id)
+ def issue_thread_options(sender_id, recipient_id, reason)
{
from: sender(sender_id),
to: recipient(recipient_id),
- subject: subject("#{@issue.title} (##{@issue.iid})")
+ subject: subject("#{@issue.title} (##{@issue.iid})"),
+ 'X-GitLab-NotificationReason' => reason
}
end
end
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 7b617b359ea..75cf56a51f2 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -7,18 +7,11 @@ module Emails
helper_method :member_source, :member
end
- def member_access_requested_email(member_source_type, member_id)
+ def member_access_requested_email(member_source_type, member_id, recipient_notification_email)
@member_source_type = member_source_type
@member_id = member_id
- admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email)
- # A project in a group can have no explicit owners/masters, in that case
- # we fallbacks to the group's owners/masters.
- if admins.empty? && member_source.respond_to?(:group) && member_source.group
- admins = member_source.group.members.owners_and_masters.includes(:user).pluck(:notification_email)
- end
-
- mail(to: admins,
+ mail(to: recipient_notification_email,
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index ec27ac517db..5fe09cea83f 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -1,57 +1,57 @@
module Emails
module MergeRequests
- def new_merge_request_email(recipient_id, merge_request_id)
+ def new_merge_request_email(recipient_id, merge_request_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id))
+ mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason))
end
- def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id)
+ def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id)
+ def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@label_names = label_names
- @labels_url = namespace_project_labels_url(@project.namespace, @project)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ @labels_url = project_labels_url(@project)
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id)
+ def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@mr_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id)
+ def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@resolved_by = User.find(resolved_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id, reason))
end
private
@@ -59,16 +59,17 @@ module Emails
def setup_merge_request_mail(merge_request_id, recipient_id)
@merge_request = MergeRequest.find(merge_request_id)
@project = @merge_request.project
- @target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request)
+ @target_url = project_merge_request_url(@project, @merge_request)
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end
- def merge_request_thread_options(sender_id, recipient_id)
+ def merge_request_thread_options(sender_id, recipient_id, reason = nil)
{
from: sender(sender_id),
to: recipient(recipient_id),
- subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})")
+ subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"),
+ 'X-GitLab-NotificationReason' => reason
}
end
end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 00707a0023e..50e17fe7717 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -4,32 +4,32 @@ module Emails
setup_note_mail(note_id, recipient_id)
@commit = @note.noteable
- @target_url = namespace_project_commit_url(*note_target_url_options)
- mail_answer_thread(@commit, note_thread_options(recipient_id))
+ @target_url = project_commit_url(*note_target_url_options)
+ mail_answer_note_thread(@commit, @note, note_thread_options(recipient_id))
end
def note_issue_email(recipient_id, note_id)
setup_note_mail(note_id, recipient_id)
@issue = @note.noteable
- @target_url = namespace_project_issue_url(*note_target_url_options)
- mail_answer_thread(@issue, note_thread_options(recipient_id))
+ @target_url = project_issue_url(*note_target_url_options)
+ mail_answer_note_thread(@issue, @note, note_thread_options(recipient_id))
end
def note_merge_request_email(recipient_id, note_id)
setup_note_mail(note_id, recipient_id)
@merge_request = @note.noteable
- @target_url = namespace_project_merge_request_url(*note_target_url_options)
- mail_answer_thread(@merge_request, note_thread_options(recipient_id))
+ @target_url = project_merge_request_url(*note_target_url_options)
+ mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id))
end
def note_snippet_email(recipient_id, note_id)
setup_note_mail(note_id, recipient_id)
@snippet = @note.noteable
- @target_url = namespace_project_snippet_url(*note_target_url_options)
- mail_answer_thread(@snippet, note_thread_options(recipient_id))
+ @target_url = project_snippet_url(*note_target_url_options)
+ mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id))
end
def note_personal_snippet_email(recipient_id, note_id)
@@ -37,13 +37,13 @@ module Emails
@snippet = @note.noteable
@target_url = snippet_url(@note.noteable)
- mail_answer_thread(@snippet, note_thread_options(recipient_id))
+ mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id))
end
private
def note_target_url_options
- [@project.namespace, @project, @note.noteable, anchor: "note_#{@note.id}"]
+ [@project, @note.noteable, anchor: "note_#{@note.id}"]
end
def note_thread_options(recipient_id)
diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb
new file mode 100644
index 00000000000..0027dfdc36b
--- /dev/null
+++ b/app/mailers/emails/pages_domains.rb
@@ -0,0 +1,43 @@
+module Emails
+ module PagesDomains
+ def pages_domain_enabled_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
+ )
+ end
+
+ def pages_domain_disabled_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
+ )
+ end
+
+ def pages_domain_verification_succeeded_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
+ )
+ end
+
+ def pages_domain_verification_failed_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
+ )
+ end
+ end
+end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 256cbcd73a1..4f5edeb9bda 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -7,14 +7,8 @@ module Emails
mail(to: @user.notification_email, subject: subject("Account was created for you"))
end
- def new_email_email(email_id)
- @email = Email.find(email_id)
- @current_user = @user = @email.user
- mail(to: @user.notification_email, subject: subject("Email was added to your account"))
- end
-
def new_ssh_key_email(key_id)
- @key = Key.find_by_id(key_id)
+ @key = Key.find_by(id: key_id)
return unless @key
@@ -22,5 +16,15 @@ module Emails
@target_url = user_url(@user)
mail(to: @user.notification_email, subject: subject("SSH key was added to your account"))
end
+
+ def new_gpg_key_email(gpg_key_id)
+ @gpg_key = GpgKey.find_by(id: gpg_key_id)
+
+ return unless @gpg_key
+
+ @current_user = @user = @gpg_key.user
+ @target_url = user_url(@user)
+ mail(to: @user.notification_email, subject: subject("GPG key was added to your account"))
+ end
end
end
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index e0af7081411..761d873c01c 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -3,7 +3,7 @@ module Emails
def project_was_moved_email(project_id, user_id, old_path_with_namespace)
@current_user = @user = User.find user_id
@project = Project.find project_id
- @target_url = namespace_project_url(@project.namespace, @project)
+ @target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
mail(to: @user.notification_email,
subject: subject("Project was moved"))
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index f315e38bcaa..e4212775956 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -1,9 +1,11 @@
class Notify < BaseMailer
include ActionDispatch::Routing::PolymorphicRoutes
+ include GitlabRoutingHelper
include Emails::Issues
include Emails::MergeRequests
include Emails::Notes
+ include Emails::PagesDomains
include Emails::Projects
include Emails::Profile
include Emails::Pipelines
@@ -111,15 +113,17 @@ class Notify < BaseMailer
headers["X-GitLab-#{model.class.name}-ID"] = model.id
headers['X-GitLab-Reply-Key'] = reply_key
+ @reason = headers['X-GitLab-NotificationReason']
+
if Gitlab::IncomingEmail.enabled? && @sent_notification
address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key))
- address.display_name = @project.name_with_namespace
+ address.display_name = @project.full_name
headers['Reply-To'] = address
fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze
- headers['References'] ||= ''
- headers['References'] << ' ' << fallback_reply_message_id
+ headers['References'] ||= []
+ headers['References'] << fallback_reply_message_id
@reply_by_email = true
end
@@ -155,6 +159,18 @@ class Notify < BaseMailer
mail_thread(model, headers)
end
+ def mail_answer_note_thread(model, note, headers = {})
+ headers['Message-ID'] = message_id(note)
+ headers['In-Reply-To'] = message_id(note.references.last)
+ headers['References'] = note.references.map { |ref| message_id(ref) }
+
+ headers['X-GitLab-Discussion-ID'] = note.discussion.id if note.part_of_discussion?
+
+ headers[:subject]&.prepend('Re: ')
+
+ mail_thread(model, headers)
+ end
+
def reply_key
@reply_key ||= SentNotification.reply_key
end
@@ -164,7 +180,7 @@ class Notify < BaseMailer
headers['X-GitLab-Project'] = @project.name
headers['X-GitLab-Project-Id'] = @project.id
- headers['X-GitLab-Project-Path'] = @project.path_with_namespace
+ headers['X-GitLab-Project-Path'] = @project.full_path
end
def add_unsubscription_headers_and_links
diff --git a/app/models/ability.rb b/app/models/ability.rb
index f3692a5a067..6dae49f38dc 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -1,35 +1,20 @@
+require_dependency 'declarative_policy'
+
class Ability
class << self
# Given a list of users and a project this method returns the users that can
# read the given project.
def users_that_can_read_project(users, project)
- if project.public?
- users
- else
- users.select do |user|
- if user.admin?
- true
- elsif project.internal? && !user.external?
- true
- elsif project.owner == user
- true
- elsif project.team.members.include?(user)
- true
- else
- false
- end
- end
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :read_project, project) }
end
end
# Given a list of users and a snippet this method returns the users that can
# read the given snippet.
def users_that_can_read_personal_snippet(users, snippet)
- case snippet.visibility_level
- when Snippet::INTERNAL, Snippet::PUBLIC
- users
- when Snippet::PRIVATE
- users.include?(snippet.author) ? [snippet.author] : []
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :read_personal_snippet, snippet) }
end
end
@@ -37,43 +22,64 @@ class Ability
#
# issues - The issues to reduce down to those readable by the user.
# user - The User for which to check the issues
- def issues_readable_by_user(issues, user = nil)
- return issues if user && user.admin?
+ # filters - A hash of abilities and filters to apply if the user lacks this
+ # ability
+ def issues_readable_by_user(issues, user = nil, filters: {})
+ issues = apply_filters_if_needed(issues, user, filters)
- issues.select { |issue| issue.visible_to_user?(user) }
+ DeclarativePolicy.user_scope do
+ issues.select { |issue| issue.visible_to_user?(user) }
+ end
end
- # TODO: make this private and use the actual abilities stuff for this
- def can_edit_note?(user, note)
- return false if !note.editable? || !user.present?
- return true if note.author == user || user.admin?
+ # Returns an Array of MergeRequests that can be read by the given user.
+ #
+ # merge_requests - MRs out of which to collect mr's readable by the user.
+ # user - The User for which to check the merge_requests
+ # filters - A hash of abilities and filters to apply if the user lacks this
+ # ability
+ def merge_requests_readable_by_user(merge_requests, user = nil, filters: {})
+ merge_requests = apply_filters_if_needed(merge_requests, user, filters)
- if note.project
- max_access_level = note.project.team.max_member_access(user.id)
- max_access_level >= Gitlab::Access::MASTER
- else
- false
+ DeclarativePolicy.user_scope do
+ merge_requests.select { |mr| allowed?(user, :read_merge_request, mr) }
end
end
- def allowed?(user, action, subject = :global)
- allowed(user, subject).include?(action)
+ def can_edit_note?(user, note)
+ allowed?(user, :edit_note, note)
end
- def allowed(user, subject = :global)
- return BasePolicy::RuleSet.none if subject.nil?
- return uncached_allowed(user, subject) unless RequestStore.active?
+ def allowed?(user, action, subject = :global, opts = {})
+ if subject.is_a?(Hash)
+ opts, subject = subject, :global
+ end
- user_key = user ? user.id : 'anonymous'
- subject_key = subject == :global ? 'global' : "#{subject.class.name}/#{subject.id}"
- key = "/ability/#{user_key}/#{subject_key}"
- RequestStore[key] ||= uncached_allowed(user, subject).freeze
+ policy = policy_for(user, subject)
+
+ case opts[:scope]
+ when :user
+ DeclarativePolicy.user_scope { policy.can?(action) }
+ when :subject
+ DeclarativePolicy.subject_scope { policy.can?(action) }
+ else
+ policy.can?(action)
+ end
+ end
+
+ def policy_for(user, subject = :global)
+ cache = RequestStore.active? ? RequestStore : {}
+ DeclarativePolicy.policy_for(user, subject, cache: cache)
end
private
- def uncached_allowed(user, subject)
- BasePolicy.class_for(subject).abilities(user, subject)
+ def apply_filters_if_needed(elements, user, filters)
+ filters.each do |ability, filter|
+ elements = filter.call(elements) unless allowed?(user, ability)
+ end
+
+ elements
end
end
end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index c79326e8427..dcd14c08f3c 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -2,13 +2,33 @@ class Appearance < ActiveRecord::Base
include CacheMarkdownField
cache_markdown_field :description
+ cache_markdown_field :new_project_guidelines
- validates :title, presence: true
- validates :description, presence: true
validates :logo, file_size: { maximum: 1.megabyte }
validates :header_logo, file_size: { maximum: 1.megabyte }
+ validate :single_appearance_row, on: :create
+
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
- has_many :uploads, as: :model, dependent: :destroy
+
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ CACHE_KEY = 'current_appearance'.freeze
+
+ after_commit :flush_redis_cache
+
+ def self.current
+ Rails.cache.fetch(CACHE_KEY) { first }
+ end
+
+ def flush_redis_cache
+ Rails.cache.delete(CACHE_KEY)
+ end
+
+ def single_appearance_row
+ if self.class.any?
+ errors.add(:single_appearance_row, 'Only 1 appearances row can exist')
+ end
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 668caef0d2c..862933bf127 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,13 +13,18 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters
}x
- 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
+ # Setting a key restriction to `-1` means that all keys of this type are
+ # forbidden.
+ FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
+ SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+
+ 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
@@ -28,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base
attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
+ default_value_for :id, 1
+
validates :uuid, presence: true
validates :session_expire_delay,
@@ -110,6 +117,11 @@ class ApplicationSetting < ActiveRecord::Base
validates :repository_storages, presence: true
validate :check_repository_storages
+ validates :auto_devops_domain,
+ allow_blank: true,
+ hostname: { allow_numeric_hostname: true, require_valid_tld: true },
+ if: :auto_devops_enabled?
+
validates :enabled_git_access_protocol,
inclusion: { in: %w(ssh http), allow_blank: true, allow_nil: true }
@@ -132,11 +144,11 @@ class ApplicationSetting < ActiveRecord::Base
validates :housekeeping_full_repack_period,
presence: true,
- numericality: { only_integer: true, greater_than: :housekeeping_incremental_repack_period }
+ numericality: { only_integer: true, greater_than_or_equal_to: :housekeeping_incremental_repack_period }
validates :housekeeping_gc_period,
presence: true,
- numericality: { only_integer: true, greater_than: :housekeeping_full_repack_period }
+ numericality: { only_integer: true, greater_than_or_equal_to: :housekeeping_full_repack_period }
validates :terminal_max_session_time,
presence: true,
@@ -146,6 +158,44 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0 }
+ validates :circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_storage_timeout,
+ :circuitbreaker_check_interval,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :circuitbreaker_access_retries,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 1 }
+
+ validates :gitaly_timeout_default,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :gitaly_timeout_medium,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :gitaly_timeout_medium,
+ numericality: { less_than_or_equal_to: :gitaly_timeout_default },
+ if: :gitaly_timeout_default
+ validates :gitaly_timeout_medium,
+ numericality: { greater_than_or_equal_to: :gitaly_timeout_fast },
+ if: :gitaly_timeout_fast
+
+ validates :gitaly_timeout_fast,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :gitaly_timeout_fast,
+ numericality: { less_than_or_equal_to: :gitaly_timeout_default },
+ if: :gitaly_timeout_default
+
+ SUPPORTED_KEY_TYPES.each do |type|
+ validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
+ end
+
+ validates :allowed_key_types, presence: true
+
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level)
@@ -171,6 +221,7 @@ class ApplicationSetting < ActiveRecord::Base
end
before_validation :ensure_uuid!
+
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -182,8 +233,14 @@ class ApplicationSetting < ActiveRecord::Base
ensure_cache_setup
Rails.cache.fetch(CACHE_KEY) do
- ApplicationSetting.last
+ ApplicationSetting.last.tap do |settings|
+ # do not cache nils
+ raise 'missing settings' unless settings
+ end
end
+ rescue
+ # Fall back to an uncached value if there are any problems (e.g. redis down)
+ ApplicationSetting.last
end
def self.expire
@@ -209,6 +266,7 @@ class ApplicationSetting < ActiveRecord::Base
{
after_sign_up_text: nil,
akismet_enabled: false,
+ authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
container_registry_token_expire_delay: 5,
default_artifacts_expire_in: '30 days',
default_branch_protection: Settings.gitlab['default_branch_protection'],
@@ -218,6 +276,9 @@ class ApplicationSetting < ActiveRecord::Base
default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
disabled_oauth_sign_in_sources: [],
domain_whitelist: Settings.gitlab['domain_whitelist'],
+ dsa_key_restriction: 0,
+ ecdsa_key_restriction: 0,
+ ed25519_key_restriction: 0,
gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil,
help_page_hide_commercial_content: false,
@@ -229,13 +290,18 @@ class ApplicationSetting < ActiveRecord::Base
housekeeping_full_repack_period: 50,
housekeeping_gc_period: 200,
housekeeping_incremental_repack_period: 10,
- import_sources: Gitlab::ImportSources.values,
+ import_sources: Settings.gitlab['import_sources'],
koding_enabled: false,
koding_url: nil,
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
+ password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
+ password_authentication_enabled_for_git: true,
+ performance_bar_allowed_group_id: nil,
+ rsa_key_restriction: 0,
plantuml_enabled: false,
plantuml_url: nil,
+ project_export_enabled: true,
recaptcha_enabled: false,
repository_checks_enabled: true,
repository_storages: ['default'],
@@ -247,13 +313,25 @@ class ApplicationSetting < ActiveRecord::Base
shared_runners_text: nil,
sidekiq_throttling_enabled: false,
sign_in_text: nil,
- signin_enabled: Settings.gitlab['signin_enabled'],
signup_enabled: Settings.gitlab['signup_enabled'],
terminal_max_session_time: 0,
+ throttle_unauthenticated_enabled: false,
+ throttle_unauthenticated_requests_per_period: 3600,
+ throttle_unauthenticated_period_in_seconds: 3600,
+ throttle_authenticated_web_enabled: false,
+ throttle_authenticated_web_requests_per_period: 7200,
+ throttle_authenticated_web_period_in_seconds: 3600,
+ throttle_authenticated_api_enabled: false,
+ throttle_authenticated_api_requests_per_period: 7200,
+ throttle_authenticated_api_period_in_seconds: 3600,
two_factor_grace_period: 48,
user_default_external: false,
polling_interval_multiplier: 1,
- usage_ping_enabled: Settings.gitlab['usage_ping_enabled']
+ usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
+ gitaly_timeout_fast: 10,
+ gitaly_timeout_medium: 30,
+ gitaly_timeout_default: 55,
+ allow_local_requests_from_hooks_and_services: false
}
end
@@ -270,15 +348,15 @@ class ApplicationSetting < ActiveRecord::Base
end
def home_page_url_column_exists?
- ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url)
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url)
end
def help_page_support_url_column_exists?
- ActiveRecord::Base.connection.column_exists?(:application_settings, :help_page_support_url)
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url)
end
def sidekiq_throttling_column_exists?
- ActiveRecord::Base.connection.column_exists?(:application_settings, :sidekiq_throttling_enabled)
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :sidekiq_throttling_enabled)
end
def domain_whitelist_raw
@@ -311,7 +389,9 @@ class ApplicationSetting < ActiveRecord::Base
Array(read_attribute(:repository_storages))
end
+ # DEPRECATED
# repository_storage is still required in the API. Remove in 9.0
+ # Still used in API v3
def repository_storage
repository_storages.first
end
@@ -336,6 +416,49 @@ class ApplicationSetting < ActiveRecord::Base
super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) })
end
+ def performance_bar_allowed_group_id=(group_full_path)
+ group_full_path = nil if group_full_path.blank?
+
+ if group_full_path.nil?
+ if group_full_path != performance_bar_allowed_group_id
+ super(group_full_path)
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+
+ return
+ end
+
+ group = Group.find_by_full_path(group_full_path)
+
+ if group
+ if group.id != performance_bar_allowed_group_id
+ super(group.id)
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+ else
+ super(nil)
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+ end
+
+ def performance_bar_allowed_group
+ Group.find_by_id(performance_bar_allowed_group_id)
+ end
+
+ # Return true if the Performance Bar is enabled for a given group
+ def performance_bar_enabled
+ performance_bar_allowed_group_id.present?
+ end
+
+ # - If `enable` is true, we early return since the actual attribute that holds
+ # the enabling/disabling is `performance_bar_allowed_group_id`
+ # - If `enable` is false, we set `performance_bar_allowed_group_id` to `nil`
+ def performance_bar_enabled=(enable)
+ return if Gitlab::Utils.to_boolean(enable)
+
+ self.performance_bar_allowed_group_id = nil
+ end
+
# Choose one of the available repository storage options. Currently all have
# equal weighting.
def pick_repository_storage
@@ -364,6 +487,26 @@ class ApplicationSetting < ActiveRecord::Base
usage_ping_can_be_configured? && super
end
+ def allowed_key_types
+ SUPPORTED_KEY_TYPES.select do |type|
+ key_restriction_for(type) != FORBIDDEN_KEY_VALUE
+ end
+ end
+
+ def key_restriction_for(type)
+ attr_name = "#{type}_key_restriction"
+
+ has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def allow_signup?
+ signup_enabled? && password_authentication_enabled_for_web?
+ end
+
+ def password_authentication_enabled?
+ password_authentication_enabled_for_web? || password_authentication_enabled_for_git?
+ end
+
private
def ensure_uuid!
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 46d412fbd72..112a8778b4e 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -1,5 +1,5 @@
class AuditEvent < ActiveRecord::Base
- serialize :details, Hash # rubocop:disable Cop/ActiverecordSerialize
+ 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 91b62dabbcd..4d1a15c53aa 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base
scope :downvotes, -> { where(name: DOWNVOTE_NAME) }
scope :upvotes, -> { where(name: UPVOTE_NAME) }
+ after_save :expire_etag_cache
+ after_destroy :expire_etag_cache
+
class << self
def votes_for_collection(ids, type)
select('name', 'awardable_id', 'COUNT(*) as count')
@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base
def upvote?
self.name == UPVOTE_NAME
end
+
+ def expire_etag_cache
+ awardable.try(:expire_etag_cache)
+ end
end
diff --git a/app/models/badge.rb b/app/models/badge.rb
new file mode 100644
index 00000000000..f7e10c2ebfc
--- /dev/null
+++ b/app/models/badge.rb
@@ -0,0 +1,51 @@
+class Badge < ActiveRecord::Base
+ # This structure sets the placeholders that the urls
+ # can have. This hash also sets which action to ask when
+ # the placeholder is found.
+ PLACEHOLDERS = {
+ 'project_path' => :full_path,
+ 'project_id' => :id,
+ 'default_branch' => :default_branch,
+ 'commit_sha' => ->(project) { project.commit&.sha }
+ }.freeze
+
+ # This regex is built dynamically using the keys from the PLACEHOLDER struct.
+ # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
+ # This regex will build the new PLACEHOLDER_REGEX with the new information
+ PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
+
+ default_scope { order_created_at_asc }
+
+ scope :order_created_at_asc, -> { reorder(created_at: :asc) }
+
+ validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX }
+ validates :type, presence: true
+
+ def rendered_link_url(project = nil)
+ build_rendered_url(link_url, project)
+ end
+
+ def rendered_image_url(project = nil)
+ build_rendered_url(image_url, project)
+ end
+
+ private
+
+ def build_rendered_url(url, project = nil)
+ return url unless valid? && project
+
+ Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg|
+ replace_placeholder_action(PLACEHOLDERS[arg], project)
+ end
+ end
+
+ # The action param represents the :symbol or Proc to call in order
+ # to retrieve the return value from the project.
+ # This method checks if it is a Proc and use the call method, and if it is
+ # a symbol just send the action
+ def replace_placeholder_action(action, project)
+ return unless project
+
+ action.is_a?(Proc) ? action.call(project) : project.public_send(action) # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/models/badges/group_badge.rb b/app/models/badges/group_badge.rb
new file mode 100644
index 00000000000..f4b2bdecdcc
--- /dev/null
+++ b/app/models/badges/group_badge.rb
@@ -0,0 +1,5 @@
+class GroupBadge < Badge
+ belongs_to :group
+
+ validates :group, presence: true
+end
diff --git a/app/models/badges/project_badge.rb b/app/models/badges/project_badge.rb
new file mode 100644
index 00000000000..3945b376052
--- /dev/null
+++ b/app/models/badges/project_badge.rb
@@ -0,0 +1,15 @@
+class ProjectBadge < Badge
+ belongs_to :project
+
+ validates :project, presence: true
+
+ def rendered_link_url(project = nil)
+ project ||= self.project
+ super
+ end
+
+ def rendered_image_url(project = nil)
+ project ||= self.project
+ super
+ end
+end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 954d4e4d779..71c974b4c09 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -76,12 +76,30 @@ class Blob < SimpleDelegator
new(blob, project)
end
+ def self.lazy(project, commit_id, path)
+ BatchLoader.for({ project: project, commit_id: commit_id, path: path }).batch do |items, loader|
+ items_by_project = items.group_by { |i| i[:project] }
+
+ items_by_project.each do |project, items|
+ items = items.map { |i| i.values_at(:commit_id, :path) }
+
+ project.repository.blobs_at(items).each do |blob|
+ loader.call({ project: blob.project, commit_id: blob.commit_id, path: blob.path }, blob) if blob
+ end
+ end
+ end
+ end
+
def initialize(blob, project = nil)
@project = project
super(blob)
end
+ def inspect
+ "#<#{self.class.name} oid:#{id[0..8]} commit:#{commit_id[0..8]} path:#{path}>"
+ end
+
# Returns the data of the blob.
#
# If the blob is a text based blob the content is converted to UTF-8 and any
@@ -95,7 +113,10 @@ class Blob < SimpleDelegator
end
def load_all_data!
- super(project.repository) if project
+ # Endpoint needed: gitlab-org/gitaly#756
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ super(project.repository) if project
+ end
end
def no_highlighting?
@@ -139,7 +160,7 @@ class Blob < SimpleDelegator
if stored_externally?
if rich_viewer
rich_viewer.binary?
- elsif Linguist::Language.find_by_filename(name).any?
+ elsif Linguist::Language.find_by_extension(name).any?
false
elsif _mime_type
_mime_type.binary?
@@ -156,7 +177,9 @@ class Blob < SimpleDelegator
end
def file_type
- Gitlab::FileDetector.type_of(path)
+ name = File.basename(path)
+
+ Gitlab::FileDetector.type_of(path) || Gitlab::FileDetector.type_of(name)
end
def video?
diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb
index 35965d01692..bf3453b3063 100644
--- a/app/models/blob_viewer/base.rb
+++ b/app/models/blob_viewer/base.rb
@@ -82,7 +82,7 @@ module BlobViewer
# format of the blob.
#
# Prefer to implement a client-side viewer, where the JS component loads the
- # binary from `blob_raw_url` and does its own format validation and error
+ # binary from `blob_raw_path` and does its own format validation and error
# rendering, especially for potentially large binary formats.
def render_error
if too_large?
diff --git a/app/models/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb
index ef8b4aef8e8..def4879fbb5 100644
--- a/app/models/blob_viewer/composer_json.rb
+++ b/app/models/blob_viewer/composer_json.rb
@@ -9,7 +9,7 @@ module BlobViewer
end
def manager_url
- 'https://getcomposer.com/'
+ 'https://getcomposer.org/'
end
def package_name
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
index a8d9be945dc..cc4950240af 100644
--- a/app/models/blob_viewer/dependency_manager.rb
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -27,10 +27,17 @@ module BlobViewer
private
- def package_name_from_json(key)
- prepare!
+ def json_data
+ @json_data ||= begin
+ prepare!
+ JSON.parse(blob.data)
+ rescue
+ {}
+ end
+ end
- JSON.parse(blob.data)[key] rescue nil
+ def package_name_from_json(key)
+ json_data[key]
end
def package_name_from_method_call(name)
diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb
index 7267c3965d3..53bc247dec1 100644
--- a/app/models/blob_viewer/gitlab_ci_yml.rb
+++ b/app/models/blob_viewer/gitlab_ci_yml.rb
@@ -13,7 +13,7 @@ module BlobViewer
prepare!
- @validation_message = Ci::GitlabCiYamlProcessor.validation_message(blob.data)
+ @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data)
end
def valid?
diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb
index 8632b8a9885..e00b47e6c17 100644
--- a/app/models/blob_viewer/notebook.rb
+++ b/app/models/blob_viewer/notebook.rb
@@ -2,7 +2,7 @@ module BlobViewer
class Notebook < Base
include Rich
include ClientSide
-
+
self.partial_name = 'notebook'
self.extensions = %w(ipynb)
self.binary = false
diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb
index 09221efb56c..46cd2f04f4d 100644
--- a/app/models/blob_viewer/package_json.rb
+++ b/app/models/blob_viewer/package_json.rb
@@ -16,7 +16,25 @@ module BlobViewer
@package_name ||= package_name_from_json('name')
end
+ def package_type
+ private? ? 'private package' : super
+ end
+
def package_url
+ private? ? homepage : npm_url
+ end
+
+ private
+
+ def private?
+ !!json_data['private']
+ end
+
+ def homepage
+ json_data['homepage']
+ end
+
+ def npm_url
"https://www.npmjs.com/package/#{package_name}"
end
end
diff --git a/app/models/blob_viewer/readme.rb b/app/models/blob_viewer/readme.rb
index 75c373a03bb..4604a9934a0 100644
--- a/app/models/blob_viewer/readme.rb
+++ b/app/models/blob_viewer/readme.rb
@@ -10,5 +10,11 @@ module BlobViewer
def visible_to?(current_user)
can?(current_user, :read_wiki, project)
end
+
+ def render_error
+ return if project.has_external_wiki? || (project.wiki_enabled? && project.wiki.has_home_page?)
+
+ :no_wiki
+ end
end
end
diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb
index fbc1b520c01..86afcc86aa0 100644
--- a/app/models/blob_viewer/server_side.rb
+++ b/app/models/blob_viewer/server_side.rb
@@ -17,7 +17,7 @@ module BlobViewer
# 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.
+ # `blob_raw_path` using AJAX.
return :server_side_but_stored_externally if blob.stored_externally?
super
diff --git a/app/models/board.rb b/app/models/board.rb
index 18081a32157..3cede6fc99a 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,9 +1,23 @@
class Board < ActiveRecord::Base
+ belongs_to :group
belongs_to :project
- has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
+ has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- validates :project, presence: true
+ validates :project, presence: true, if: :project_needed?
+ validates :group, presence: true, unless: :project
+
+ def project_needed?
+ !group
+ end
+
+ def parent
+ @parent ||= group || project
+ end
+
+ def group_board?
+ group_id.present?
+ end
def backlog_list
lists.merge(List.backlog).take
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 944725d91c3..0b561203914 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -14,10 +14,26 @@ class BroadcastMessage < ActiveRecord::Base
default_value_for :color, '#E75E40'
default_value_for :font, '#FFFFFF'
+ CACHE_KEY = 'broadcast_message_current'.freeze
+
+ after_commit :flush_redis_cache
+
def self.current
- Rails.cache.fetch("broadcast_message_current", expires_in: 1.minute) do
- where('ends_at > :now AND starts_at <= :now', now: Time.zone.now).order([:created_at, :id]).to_a
- end
+ messages = Rails.cache.fetch(CACHE_KEY) { current_and_future_messages.to_a }
+
+ return messages if messages.empty?
+
+ now_or_future = messages.select(&:now_or_future?)
+
+ # If there are cached entries but none are to be displayed we'll purge the
+ # cache so we don't keep running this code all the time.
+ Rails.cache.delete(CACHE_KEY) if now_or_future.empty?
+
+ now_or_future.select(&:now?)
+ end
+
+ def self.current_and_future_messages
+ where('ends_at > :now', now: Time.zone.now).order_id_asc
end
def active?
@@ -31,4 +47,20 @@ class BroadcastMessage < ActiveRecord::Base
def ended?
ends_at < Time.zone.now
end
+
+ def now?
+ (starts_at..ends_at).cover?(Time.zone.now)
+ end
+
+ def future?
+ starts_at > Time.zone.now
+ end
+
+ def now_or_future?
+ now? || future?
+ end
+
+ def flush_redis_cache
+ Rails.cache.delete(CACHE_KEY)
+ end
end
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index f321db75eeb..fbd0f123341 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -1,4 +1,6 @@
class ChatName < ActiveRecord::Base
+ LAST_USED_AT_INTERVAL = 1.hour
+
belongs_to :service
belongs_to :user
@@ -9,4 +11,23 @@ class ChatName < ActiveRecord::Base
validates :user_id, uniqueness: { scope: [:service_id] }
validates :chat_id, uniqueness: { scope: [:service_id, :team_id] }
+
+ # Updates the "last_used_timestamp" but only if it wasn't already updated
+ # recently.
+ #
+ # The throttling this method uses is put in place to ensure that high chat
+ # traffic doesn't result in many UPDATE queries being performed.
+ def update_last_used_at
+ return unless update_last_used_at?
+
+ obtained = Gitlab::ExclusiveLease
+ .new("chat_name/last_used_at/#{id}", timeout: LAST_USED_AT_INTERVAL.to_i)
+ .try_obtain
+
+ touch(:last_used_at) if obtained
+ end
+
+ def update_last_used_at?
+ last_used_at.nil? || last_used_at > LAST_USED_AT_INTERVAL.ago
+ end
end
diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb
index c52b6f15913..25ecf2d5937 100644
--- a/app/models/chat_team.rb
+++ b/app/models/chat_team.rb
@@ -3,4 +3,13 @@ class ChatTeam < ActiveRecord::Base
validates :namespace, uniqueness: true
belongs_to :namespace
+
+ def remove_mattermost_team(current_user)
+ Mattermost::Team.new(current_user).destroy(team_id: team_id)
+ rescue Mattermost::ClientError => e
+ # Either the group is not found, or the user doesn't have the proper
+ # access on the mattermost instance. In the first case, we're done either way
+ # in the latter case, we can't recover by retrying, so we just log what happened
+ Rails.logger.error("Mattermost team deletion failed: #{e}")
+ end
end
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
index b35febc9ac5..ec56cc53aea 100644
--- a/app/models/ci/artifact_blob.rb
+++ b/app/models/ci/artifact_blob.rb
@@ -2,6 +2,8 @@ module Ci
class ArtifactBlob
include BlobLike
+ EXTENSIONS_SERVED_BY_PAGES = %w[.html .htm .txt .json].freeze
+
attr_reader :entry
def initialize(entry)
@@ -17,6 +19,7 @@ module Ci
def size
entry.metadata[:size]
end
+ alias_method :external_size, :size
def data
"Build artifact #{path}"
@@ -30,6 +33,32 @@ module Ci
:build_artifact
end
- alias_method :external_size, :size
+ def external_url(project, job)
+ return unless external_link?(job)
+
+ full_path_parts = project.full_path_components
+ top_level_group = full_path_parts.shift
+
+ artifact_path = [
+ '-', *full_path_parts, '-',
+ 'jobs', job.id,
+ 'artifacts', path
+ ].join('/')
+
+ "#{pages_config.protocol}://#{top_level_group}.#{pages_config.host}/#{artifact_path}"
+ end
+
+ def external_link?(job)
+ pages_config.enabled &&
+ pages_config.artifacts_server &&
+ EXTENSIONS_SERVED_BY_PAGES.include?(File.extname(name)) &&
+ job.project.public?
+ end
+
+ private
+
+ def pages_config
+ Gitlab.config.pages
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index a300536532b..1e066b69c6e 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -1,15 +1,27 @@
module Ci
class Build < CommitStatus
+ prepend ArtifactMigratable
include TokenAuthenticatable
include AfterCommitQueue
include Presentable
+ include Importable
+ MissingDependenciesError = Class.new(StandardError)
+
+ belongs_to :project, inverse_of: :builds
belongs_to :runner
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
has_many :deployments, as: :deployable
+
has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment'
+ has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
+
+ has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
+ has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
+ has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
# The "environment" field for builds is a String, and is the unexpanded name
def persisted_environment
@@ -19,8 +31,8 @@ module Ci
)
end
- serialize :options # rubocop:disable Cop/ActiverecordSerialize
- serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiverecordSerialize
+ serialize :options # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize
delegate :name, to: :project, prefix: true
@@ -29,14 +41,37 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
- scope :with_artifacts, ->() { where.not(artifacts_file: [nil, '']) }
- scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
- scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
+ scope :with_artifacts_archive, ->() do
+ where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
+ '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ end
+ scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
+ scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) }
+ scope :ref_protected, -> { where(protected: true) }
+
+ scope :matches_tag_ids, -> (tag_ids) do
+ matcher = ::ActsAsTaggableOn::Tagging
+ .where(taggable_type: CommitStatus)
+ .where(context: 'tags')
+ .where('taggable_id = ci_builds.id')
+ .where.not(tag_id: tag_ids).select('1')
+
+ where("NOT EXISTS (?)", matcher)
+ end
- mount_uploader :artifacts_file, ArtifactUploader
- mount_uploader :artifacts_metadata, ArtifactUploader
+ scope :with_any_tags, -> do
+ matcher = ::ActsAsTaggableOn::Tagging
+ .where(taggable_type: CommitStatus)
+ .where(context: 'tags')
+ .where('taggable_id = ci_builds.id').select('1')
+
+ where("EXISTS (?)", matcher)
+ end
+
+ mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file
+ mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata
acts_as_taggable
@@ -46,7 +81,10 @@ module Ci
before_save :ensure_token
before_destroy { unscoped_project }
- after_create :execute_hooks
+ after_create unless: :importing? do |build|
+ run_after_commit { BuildHooksWorker.perform_async(build.id) }
+ end
+
after_commit :update_project_statistics_after_save, on: [:create, :update]
after_commit :update_project_statistics, on: :destroy
@@ -96,6 +134,23 @@ module Ci
BuildSuccessWorker.perform_async(id)
end
end
+
+ before_transition any => [:failed] do |build|
+ next unless build.project
+ next if build.retries_max.zero?
+
+ if build.retries_count < build.retries_max
+ begin
+ Ci::Build.retry(build, build.user)
+ rescue Gitlab::Access::AccessDeniedError => ex
+ Rails.logger.error "Unable to auto-retry job #{build.id}: #{ex}"
+ end
+ end
+ end
+
+ before_transition any => [:running] do |build|
+ build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies')
+ end
end
def detailed_status(current_user)
@@ -130,6 +185,14 @@ module Ci
success? || failed? || canceled?
end
+ def retries_count
+ pipeline.builds.retried.where(name: self.name).count
+ end
+
+ def retries_max
+ self.options.fetch(:retry, 0).to_i
+ end
+
def latest?
!retried?
end
@@ -170,36 +233,50 @@ module Ci
project.build_timeout
end
+ def triggered_by?(current_user)
+ user == current_user
+ end
+
# A slugified version of the build ref, suitable for inclusion in URLs and
# domain names. Rules:
#
# * Lowercased
# * Anything not matching [a-z0-9-] is replaced with a -
# * Maximum length is 63 bytes
+ # * First/Last Character is not a hyphen
def ref_slug
- slugified = ref.to_s.downcase
- slugified.gsub(/[^a-z0-9]/, '-')[0..62]
+ Gitlab::Utils.slugify(ref.to_s)
end
# Variables whose value does not depend on environment
def simple_variables
- variables = predefined_variables
- variables += project.predefined_variables
- variables += pipeline.predefined_variables
- variables += runner.predefined_variables if runner
- variables += project.container_registry_variables
- variables += project.deployment_variables if has_environment?
- variables += yaml_variables
- variables += user_variables
- variables += project.secret_variables_for(ref).map(&:to_runner_variable)
- variables += trigger_request.user_variables if trigger_request
- variables
+ variables(environment: nil)
end
# All variables, including those dependent on environment, which could
# contain unexpanded variables.
- def variables
- simple_variables.concat(persisted_environment_variables)
+ def variables(environment: persisted_environment)
+ collection = Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.concat(predefined_variables)
+ variables.concat(project.predefined_variables)
+ variables.concat(pipeline.predefined_variables)
+ variables.concat(runner.predefined_variables) if runner
+ variables.concat(project.deployment_variables(environment: environment)) if has_environment?
+ variables.concat(yaml_variables)
+ variables.concat(user_variables)
+ variables.concat(project.group.secret_variables_for(ref, project)) if project.group
+ variables.concat(secret_variables(environment: environment))
+ variables.concat(trigger_request.user_variables) if trigger_request
+ variables.concat(pipeline.variables)
+ variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
+ variables.concat(persisted_environment_variables) if environment
+ end
+
+ collection.to_runner_variables
+ end
+
+ def features
+ { trace_sections: true }
end
def merge_request
@@ -207,20 +284,20 @@ module Ci
@merge_request ||=
begin
- merge_requests = MergeRequest.includes(:merge_request_diff)
+ merge_requests = MergeRequest.includes(:latest_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)
+ merge_request.commit_shas.include?(pipeline.sha)
end
end
end
def repo_url
auth = "gitlab-ci-token:#{ensure_token!}@"
- project.http_url_to_repo.sub(/^https?:\/\//) do |prefix|
+ project.http_url_to_repo.sub(%r{^https?://}) do |prefix|
prefix + auth
end
end
@@ -234,6 +311,10 @@ module Ci
update_attributes(coverage: coverage) if coverage.present?
end
+ def parse_trace_sections!
+ ExtractSectionsFromBuildTraceService.new(project, user).execute(self)
+ end
+
def trace
Gitlab::Ci::Trace.new(self)
end
@@ -251,8 +332,7 @@ module Ci
end
def erase_old_trace!
- write_attribute(:trace, nil)
- save
+ update_column(:trace, nil)
end
def needs_touch?
@@ -277,6 +357,7 @@ module Ci
def execute_hooks
return unless project
+
build_data = Gitlab::DataBuilder::Build.build(self)
project.execute_hooks(build_data.dup, :job_hooks)
project.execute_services(build_data.dup, :job_hooks)
@@ -284,14 +365,6 @@ module Ci
project.running_or_pending_build_count(force: true)
end
- def artifacts?
- !artifacts_expired? && artifacts_file.exists?
- end
-
- def artifacts_metadata?
- artifacts? && artifacts_metadata.exists?
- end
-
def artifacts_metadata_entry(path, **options)
metadata = Gitlab::Ci::Build::Artifacts::Metadata.new(
artifacts_metadata.path,
@@ -344,6 +417,7 @@ module Ci
def keep_artifacts!
self.update(artifacts_expire_at: nil)
+ self.job_artifacts.update_all(expire_at: nil)
end
def coverage_regex
@@ -359,12 +433,19 @@ module Ci
end
def user_variables
- return [] if user.blank?
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables if user.blank?
- [
- { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true },
- { key: 'GITLAB_USER_EMAIL', value: user.email, public: true }
- ]
+ variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
+ variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
+ variables.append(key: 'GITLAB_USER_LOGIN', value: user.username)
+ variables.append(key: 'GITLAB_USER_NAME', value: user.name)
+ end
+ end
+
+ def secret_variables(environment: persisted_environment)
+ project.secret_variables_for(ref: ref, environment: environment)
+ .map(&:to_runner_variable)
end
def steps
@@ -385,7 +466,14 @@ module Ci
end
def cache
- [options[:cache]]
+ cache = options[:cache]
+
+ if cache && project.jobs_cache_index
+ cache = cache.merge(
+ key: "#{cache[:key]}-#{project.jobs_cache_index}")
+ end
+
+ [cache]
end
def credentials
@@ -408,23 +496,36 @@ module Ci
options[:dependencies]&.empty?
end
+ def validates_dependencies!
+ dependencies.each do |dependency|
+ raise MissingDependenciesError unless dependency.valid_dependency?
+ end
+ end
+
+ def valid_dependency?
+ return false if artifacts_expired?
+ return false if erased?
+
+ true
+ end
+
def hide_secrets(trace)
return unless trace
trace = trace.dup
- Ci::MaskSecret.mask!(trace, project.runners_token) if project
- Ci::MaskSecret.mask!(trace, token)
+ Gitlab::Ci::MaskSecret.mask!(trace, project.runners_token) if project
+ Gitlab::Ci::MaskSecret.mask!(trace, token)
trace
end
+ def serializable_hash(options = {})
+ super(options).merge(when: read_attribute(:when))
+ end
+
private
def update_artifacts_size
- self.artifacts_size = if artifacts_file.exists?
- artifacts_file.size
- else
- nil
- end
+ self.artifacts_size = legacy_artifacts_file&.size
end
def erase_trace!
@@ -442,59 +543,57 @@ module Ci
CI_REGISTRY_USER = 'gitlab-ci-token'.freeze
def predefined_variables
- variables = [
- { key: 'CI', value: 'true', public: true },
- { key: 'GITLAB_CI', value: 'true', public: true },
- { key: 'CI_SERVER_NAME', value: 'GitLab', public: true },
- { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true },
- { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true },
- { key: 'CI_JOB_ID', value: id.to_s, public: true },
- { key: 'CI_JOB_NAME', value: name, public: true },
- { key: 'CI_JOB_STAGE', value: stage, public: true },
- { key: 'CI_JOB_TOKEN', value: token, public: false },
- { key: 'CI_COMMIT_SHA', value: sha, public: true },
- { key: 'CI_COMMIT_REF_NAME', value: ref, public: true },
- { key: 'CI_COMMIT_REF_SLUG', value: ref_slug, public: true },
- { key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER, public: true },
- { key: 'CI_REGISTRY_PASSWORD', value: token, public: false },
- { key: 'CI_REPOSITORY_URL', value: repo_url, public: false }
- ]
-
- variables << { key: "CI_COMMIT_TAG", value: ref, public: true } if tag?
- variables << { key: "CI_PIPELINE_TRIGGERED", value: 'true', public: true } if trigger_request
- variables << { key: "CI_JOB_MANUAL", value: 'true', public: true } if action?
- variables.concat(legacy_variables)
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI', value: 'true')
+ variables.append(key: 'GITLAB_CI', value: 'true')
+ variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(','))
+ variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
+ variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
+ variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION)
+ variables.append(key: 'CI_JOB_ID', value: id.to_s)
+ variables.append(key: 'CI_JOB_NAME', value: name)
+ variables.append(key: 'CI_JOB_STAGE', value: stage)
+ variables.append(key: 'CI_JOB_TOKEN', value: token, public: false)
+ variables.append(key: 'CI_COMMIT_SHA', value: sha)
+ variables.append(key: 'CI_COMMIT_REF_NAME', value: ref)
+ variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug)
+ variables.append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
+ variables.append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false)
+ variables.append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false)
+ variables.append(key: "CI_COMMIT_TAG", value: ref) if tag?
+ variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request
+ variables.append(key: "CI_JOB_MANUAL", value: 'true') if action?
+ variables.concat(legacy_variables)
+ end
end
def persisted_environment_variables
- return [] unless persisted_environment
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables unless persisted_environment
- variables = persisted_environment.predefined_variables
+ variables.concat(persisted_environment.predefined_variables)
- # Here we're passing unexpanded environment_url for runner to expand,
- # and we need to make sure that CI_ENVIRONMENT_NAME and
- # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
- variables << { key: 'CI_ENVIRONMENT_URL', value: environment_url, public: true } if environment_url
-
- variables
+ # Here we're passing unexpanded environment_url for runner to expand,
+ # and we need to make sure that CI_ENVIRONMENT_NAME and
+ # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
+ variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url
+ end
end
def legacy_variables
- variables = [
- { key: 'CI_BUILD_ID', value: id.to_s, public: true },
- { key: 'CI_BUILD_TOKEN', value: token, public: false },
- { key: 'CI_BUILD_REF', value: sha, public: true },
- { key: 'CI_BUILD_BEFORE_SHA', value: before_sha, public: true },
- { key: 'CI_BUILD_REF_NAME', value: ref, public: true },
- { key: 'CI_BUILD_REF_SLUG', value: ref_slug, public: true },
- { key: 'CI_BUILD_NAME', value: name, public: true },
- { key: 'CI_BUILD_STAGE', value: stage, public: true }
- ]
-
- variables << { key: "CI_BUILD_TAG", value: ref, public: true } if tag?
- variables << { key: "CI_BUILD_TRIGGERED", value: 'true', public: true } if trigger_request
- variables << { key: "CI_BUILD_MANUAL", value: 'true', public: true } if action?
- variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_BUILD_ID', value: id.to_s)
+ variables.append(key: 'CI_BUILD_TOKEN', value: token, public: false)
+ variables.append(key: 'CI_BUILD_REF', value: sha)
+ variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha)
+ variables.append(key: 'CI_BUILD_REF_NAME', value: ref)
+ variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug)
+ variables.append(key: 'CI_BUILD_NAME', value: name)
+ variables.append(key: 'CI_BUILD_STAGE', value: stage)
+ variables.append(key: "CI_BUILD_TAG", value: ref) if tag?
+ variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request
+ variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action?
+ end
end
def environment_url
diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb
new file mode 100644
index 00000000000..ccdb95546c8
--- /dev/null
+++ b/app/models/ci/build_trace_section.rb
@@ -0,0 +1,11 @@
+module Ci
+ class BuildTraceSection < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+
+ belongs_to :build, class_name: 'Ci::Build'
+ belongs_to :project
+ belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName'
+
+ validates :section_name, :build, :project, presence: true, allow_blank: false
+ end
+end
diff --git a/app/models/ci/build_trace_section_name.rb b/app/models/ci/build_trace_section_name.rb
new file mode 100644
index 00000000000..0fdcb1ea329
--- /dev/null
+++ b/app/models/ci/build_trace_section_name.rb
@@ -0,0 +1,11 @@
+module Ci
+ class BuildTraceSectionName < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+
+ belongs_to :project
+ has_many :trace_sections, class_name: 'Ci::BuildTraceSection', foreign_key: :section_name_id
+
+ validates :name, :project, presence: true, allow_blank: false
+ validates :name, uniqueness: { scope: :project_id }
+ end
+end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
new file mode 100644
index 00000000000..62d768cc6cf
--- /dev/null
+++ b/app/models/ci/group_variable.rb
@@ -0,0 +1,18 @@
+module Ci
+ class GroupVariable < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+ include HasVariable
+ include Presentable
+
+ belongs_to :group
+
+ alias_attribute :secret_value, :value
+
+ validates :key, uniqueness: {
+ scope: :group_id,
+ message: "(%{value}) has already been taken"
+ }
+
+ scope :unprotected, -> { where(protected: false) }
+ end
+end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
new file mode 100644
index 00000000000..0a599f72bc7
--- /dev/null
+++ b/app/models/ci/job_artifact.rb
@@ -0,0 +1,39 @@
+module Ci
+ class JobArtifact < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+
+ belongs_to :project
+ belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+
+ before_save :set_size, if: :file_changed?
+
+ mount_uploader :file, JobArtifactUploader
+
+ delegate :open, :exists?, to: :file
+
+ enum file_type: {
+ archive: 1,
+ metadata: 2,
+ trace: 3
+ }
+
+ def self.artifacts_size_for(project)
+ self.where(project: project).sum(:size)
+ end
+
+ def set_size
+ self.size = file.size
+ end
+
+ def expire_in
+ expire_at - Time.now if expire_at
+ end
+
+ def expire_in=(value)
+ self.expire_at =
+ if value
+ ChronicDuration.parse(value)&.seconds&.from_now
+ end
+ end
+ end
+end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 1b3e5a25ac2..44f9bdf111e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -1,27 +1,29 @@
module Ci
class Pipeline < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
include HasStatus
include Importable
include AfterCommitQueue
include Presentable
+ include Gitlab::OptimisticLocking
- belongs_to :project
+ belongs_to :project, inverse_of: :pipelines
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
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
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :variables, class_name: 'Ci::PipelineVariable'
# 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 :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, 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.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'
@@ -30,6 +32,7 @@ module Ci
has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
delegate :id, to: :project, prefix: true
+ delegate :full_path, to: :project, prefix: true
validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
validates :sha, presence: { unless: :importing? }
@@ -49,10 +52,21 @@ module Ci
external: 6
}
+ enum config_source: {
+ unknown_source: nil,
+ repository_source: 1,
+ auto_devops_source: 2
+ }
+
+ enum failure_reason: {
+ unknown_failure: 0,
+ config_error: 1
+ }
+
state_machine :status, initial: :created do
event :enqueue do
- transition created: :pending
- transition [:success, :failed, :canceled, :skipped] => :running
+ transition [:created, :skipped] => :pending
+ transition [:success, :failed, :canceled] => :running
end
event :run do
@@ -100,6 +114,12 @@ module Ci
pipeline.auto_canceled_by = nil
end
+ before_transition any => :failed do |pipeline, transition|
+ transition.args.first.try do |reason|
+ pipeline.failure_reason = reason
+ end
+ end
+
after_transition [:created, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
@@ -128,33 +148,70 @@ module Ci
end
end
- # ref can't be HEAD or SHA, can only be branch/tag name
- scope :latest, ->(ref = nil) do
- max_id = unscope(:select)
- .select("max(#{quoted_table_name}.id)")
- .group(:ref, :sha)
+ scope :internal, -> { where(source: internal_sources) }
- if ref
- where(ref: ref, id: max_id.where(ref: ref))
- else
- where(id: max_id)
- end
+ # Returns the pipelines in descending order (= newest first), optionally
+ # limited to a number of references.
+ #
+ # ref - The name (or names) of the branch(es)/tag(s) to limit the list of
+ # pipelines to.
+ def self.newest_first(ref = nil)
+ relation = order(id: :desc)
+
+ ref ? relation.where(ref: ref) : relation
end
def self.latest_status(ref = nil)
- latest(ref).status
+ newest_first(ref).pluck(:status).first
end
def self.latest_successful_for(ref)
- success.latest(ref).order(id: :desc).first
+ newest_first(ref).success.take
end
def self.latest_successful_for_refs(refs)
- success.latest(refs).order(id: :desc).each_with_object({}) do |pipeline, hash|
+ relation = newest_first(refs).success
+
+ relation.each_with_object({}) do |pipeline, hash|
hash[pipeline.ref] ||= pipeline
end
end
+ # Returns a Hash containing the latest pipeline status for every given
+ # commit.
+ #
+ # The keys of this Hash are the commit SHAs, the values the statuses.
+ #
+ # commits - The list of commit SHAs to get the status for.
+ # ref - The ref to scope the data to (e.g. "master"). If the ref is not
+ # given we simply get the latest status for the commits, regardless
+ # of what refs their pipelines belong to.
+ def self.latest_status_per_commit(commits, ref = nil)
+ p1 = arel_table
+ p2 = arel_table.alias
+
+ # This LEFT JOIN will filter out all but the newest row for every
+ # combination of (project_id, sha) or (project_id, sha, ref) if a ref is
+ # given.
+ cond = p1[:sha].eq(p2[:sha])
+ .and(p1[:project_id].eq(p2[:project_id]))
+ .and(p1[:id].lt(p2[:id]))
+
+ cond = cond.and(p1[:ref].eq(p2[:ref])) if ref
+ join = p1.join(p2, Arel::Nodes::OuterJoin).on(cond)
+
+ relation = select(:sha, :status)
+ .where(sha: commits)
+ .where(p2[:id].eq(nil))
+ .joins(join.join_sources)
+
+ relation = relation.where(ref: ref) if ref
+
+ relation.each_with_object({}) do |row, hash|
+ hash[row[:sha]] = row[:status]
+ end
+ end
+
def self.truncate_sha(sha)
sha[0...8]
end
@@ -163,10 +220,18 @@ module Ci
where.not(duration: nil).sum(:duration)
end
+ def self.internal_sources
+ sources.reject { |source| source == "external" }.values
+ end
+
def stages_count
statuses.select(:stage).distinct.count
end
+ def total_size
+ statuses.count(:id)
+ end
+
def stages_names
statuses.order(:stage_idx).distinct
.pluck(:stage, :stage_idx).map(&:first)
@@ -222,10 +287,12 @@ module Ci
Ci::Pipeline.truncate_sha(sha)
end
+ # NOTE: This is loaded lazily and will never be nil, even if the commit
+ # cannot be found.
+ #
+ # Use constructs like: `pipeline.commit.present?`
def commit
- @commit ||= project.commit(sha)
- rescue
- nil
+ @commit ||= Commit.lazy(project, sha)
end
def branch?
@@ -249,7 +316,7 @@ module Ci
end
def cancel_running
- Gitlab::OptimisticLocking.retry_lock(cancelable_statuses) do |cancelable|
+ retry_optimistic_lock(cancelable_statuses) do |cancelable|
cancelable.find_each do |job|
yield(job) if block_given?
job.cancel
@@ -275,10 +342,9 @@ module Ci
end
def latest?
- return false unless ref
- commit = project.commit(ref)
- return false unless commit
- commit.sha == sha
+ return false unless ref && commit.present?
+
+ project.commit(ref) == commit
end
def retried
@@ -298,6 +364,14 @@ module Ci
@stage_seeds ||= config_processor.stage_seeds(self)
end
+ def seeds_size
+ @seeds_size ||= stage_seeds.sum(&:size)
+ end
+
+ def has_kubernetes_active?
+ project.deployment_platform&.active?
+ end
+
def has_stage_seeds?
stage_seeds.any?
end
@@ -306,13 +380,21 @@ module Ci
builds.latest.failed_but_allowed.any?
end
+ def set_config_source
+ if ci_yaml_from_repo
+ self.config_source = :repository_source
+ elsif implied_ci_yaml_file
+ self.config_source = :auto_devops_source
+ end
+ end
+
def config_processor
return unless ci_yaml_file
return @config_processor if defined?(@config_processor)
@config_processor ||= begin
- Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace)
- rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e
+ Gitlab::Ci::YamlProcessor.new(ci_yaml_file)
+ rescue Gitlab::Ci::YamlProcessor::ValidationError => e
self.yaml_errors = e.message
nil
rescue
@@ -321,10 +403,30 @@ module Ci
end
end
+ def ci_yaml_file_path
+ if project.ci_config_path.blank?
+ '.gitlab-ci.yml'
+ else
+ project.ci_config_path
+ end
+ end
+
def ci_yaml_file
return @ci_yaml_file if defined?(@ci_yaml_file)
- @ci_yaml_file = project.repository.gitlab_ci_yml_for(sha) rescue nil
+ @ci_yaml_file =
+ if auto_devops_source?
+ implied_ci_yaml_file
+ else
+ ci_yaml_from_repo
+ end
+
+ if @ci_yaml_file
+ @ci_yaml_file
+ else
+ self.yaml_errors = "Failed to load CI/CD config file for #{sha}"
+ nil
+ end
end
def has_yaml_errors?
@@ -349,7 +451,7 @@ module Ci
end
def notes
- Note.for_commit_id(sha)
+ project.notes.for_commit_id(sha)
end
def process!
@@ -357,7 +459,7 @@ module Ci
end
def update_status
- Gitlab::OptimisticLocking.retry_lock(self) do
+ retry_optimistic_lock(self) do
case latest_builds_status
when 'pending' then enqueue
when 'running' then run
@@ -371,9 +473,10 @@ module Ci
end
def predefined_variables
- [
- { key: 'CI_PIPELINE_ID', value: id.to_s, public: true }
- ]
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_PIPELINE_ID', value: id.to_s)
+ .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path)
+ .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s)
end
def queued_duration
@@ -386,7 +489,7 @@ module Ci
def update_duration
return unless started_at
- self.duration = Gitlab::Ci::PipelineDuration.from_pipeline(self)
+ self.duration = Gitlab::Ci::Pipeline::Duration.from_pipeline(self)
end
def execute_hooks
@@ -406,8 +509,32 @@ module Ci
.fabricate!
end
+ def latest_builds_with_artifacts
+ # We purposely cast the builds to an Array here. Because we always use the
+ # rows if there are more than 0 this prevents us from having to run two
+ # queries: one to get the count and one to get the rows.
+ @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a
+ end
+
private
+ def ci_yaml_from_repo
+ return unless project
+ return unless sha
+
+ project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path)
+ rescue GRPC::NotFound, GRPC::Internal
+ nil
+ end
+
+ def implied_ci_yaml_file
+ return unless project
+
+ if project.auto_devops_enabled?
+ Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps').content
+ end
+ end
+
def pipeline_data
Gitlab::DataBuilder::Pipeline.build(self)
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 45d8cd34359..b6abc3d7681 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -1,25 +1,30 @@
module Ci
class PipelineSchedule < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
include Importable
+ include IgnorableColumn
- acts_as_paranoid
+ ignore_column :deleted_at
belongs_to :project
belongs_to :owner, class_name: 'User'
has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
has_many :pipelines
+ has_many :variables, class_name: 'Ci::PipelineScheduleVariable', validate: false
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
+ validates :variables, variable_duplicates: true
before_save :set_next_run_at
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
+ accepts_nested_attributes_for :variables, allow_destroy: true
+
def owned_by?(current_user)
owner == current_user
end
@@ -36,10 +41,6 @@ module Ci
update_attribute(:active, false)
end
- def runnable_by_owner?
- Ability.allowed?(owner, :create_pipeline, project)
- end
-
def set_next_run_at
self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
end
@@ -56,5 +57,9 @@ module Ci
Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone)
.next_time_from(next_run_at)
end
+
+ def job_variables
+ variables&.map(&:to_runner_variable) || []
+ end
end
end
diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb
new file mode 100644
index 00000000000..03df4e3e638
--- /dev/null
+++ b/app/models/ci/pipeline_schedule_variable.rb
@@ -0,0 +1,12 @@
+module Ci
+ class PipelineScheduleVariable < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+ include HasVariable
+
+ belongs_to :pipeline_schedule
+
+ alias_attribute :secret_value, :value
+
+ validates :key, uniqueness: { scope: :pipeline_schedule_id }
+ end
+end
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
new file mode 100644
index 00000000000..de5aae17a15
--- /dev/null
+++ b/app/models/ci/pipeline_variable.rb
@@ -0,0 +1,10 @@
+module Ci
+ class PipelineVariable < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+ include HasVariable
+
+ belongs_to :pipeline
+
+ validates :key, uniqueness: { scope: :pipeline_id }
+ end
+end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index d12f96f3d0b..7173f88f1c7 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -1,14 +1,17 @@
module Ci
class Runner < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
+ include Gitlab::SQL::Pattern
+ include RedisCacheable
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
- LAST_CONTACT_TIME = 1.hour.ago
+ ONLINE_CONTACT_TIMEOUT = 1.hour
+ UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes
AVAILABLE_SCOPES = %w[specific shared active paused online].freeze
- FORM_EDITABLE = %i[description tag_list active run_untagged locked].freeze
+ FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze
has_many :builds
- has_many :runner_projects, dependent: :destroy
+ has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
@@ -19,7 +22,7 @@ module Ci
scope :shared, ->() { where(is_shared: true) }
scope :active, ->() { where(active: true) }
scope :paused, ->() { where(active: false) }
- scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) }
+ scope :online, ->() { where('contacted_at > ?', contact_time_deadline) }
scope :ordered, ->() { order(id: :desc) }
scope :owned_or_shared, ->(project_id) do
@@ -35,11 +38,19 @@ module Ci
end
validate :tag_constraints
+ validates :access_level, presence: true
acts_as_taggable
after_destroy :cleanup_runner_queue
+ enum access_level: {
+ not_protected: 0,
+ ref_protected: 1
+ }
+
+ cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address
+
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
@@ -53,10 +64,11 @@ module Ci
#
# Returns an ActiveRecord::Relation.
def self.search(query)
- t = arel_table
- pattern = "%#{query}%"
+ fuzzy_search(query, [:token, :description])
+ end
- where(t[:token].matches(pattern).or(t[:description].matches(pattern)))
+ def self.contact_time_deadline
+ ONLINE_CONTACT_TIMEOUT.ago
end
def set_default_values
@@ -80,7 +92,7 @@ module Ci
end
def online?
- contacted_at && contacted_at > LAST_CONTACT_TIME
+ contacted_at && contacted_at > self.class.contact_time_deadline
end
def status
@@ -102,7 +114,9 @@ module Ci
end
def can_pick?(build)
- assignable_for?(build.project) && accepting_tags?(build)
+ return false if self.ref_protected? && !build.protected?
+
+ assignable_for?(build.project_id) && accepting_tags?(build)
end
def only_for?(project)
@@ -118,11 +132,10 @@ module Ci
end
def predefined_variables
- [
- { key: 'CI_RUNNER_ID', value: id.to_s, public: true },
- { key: 'CI_RUNNER_DESCRIPTION', value: description, public: true },
- { key: 'CI_RUNNER_TAGS', value: tag_list.to_s, public: true }
- ]
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_RUNNER_ID', value: id.to_s)
+ .append(key: 'CI_RUNNER_DESCRIPTION', value: description)
+ .append(key: 'CI_RUNNER_TAGS', value: tag_list.to_s)
end
def tick_runner_queue
@@ -138,14 +151,26 @@ module Ci
expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: false)
end
- def is_runner_queue_value_latest?(value)
+ def runner_queue_value_latest?(value)
ensure_runner_queue_value == value if value.present?
end
+ def update_cached_info(values)
+ values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {}
+ values[:contacted_at] = Time.now
+
+ cache_attributes(values)
+
+ if persist_cached_data?
+ self.assign_attributes(values)
+ self.save if self.changed?
+ end
+ end
+
private
def cleanup_runner_queue
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Queues.with do |redis|
redis.del(runner_queue_key)
end
end
@@ -154,6 +179,17 @@ module Ci
"runner:build_queue:#{self.token}"
end
+ def persist_cached_data?
+ # Use a random threshold to prevent beating DB updates.
+ # It generates a distribution between [40m, 80m].
+
+ contacted_at_max_age = UPDATE_DB_RUNNER_INFO_EVERY + Random.rand(UPDATE_DB_RUNNER_INFO_EVERY)
+
+ real_contacted_at = read_attribute(:contacted_at)
+ real_contacted_at.nil? ||
+ (Time.now - real_contacted_at) >= contacted_at_max_age
+ end
+
def tag_constraints
unless has_tags? || run_untagged?
errors.add(:tags_list,
@@ -161,8 +197,8 @@ module Ci
end
end
- def assignable_for?(project)
- !locked? || projects.exists?(id: project.id)
+ def assignable_for?(project_id)
+ is_shared? || projects.exists?(id: project_id)
end
def accepting_tags?(build)
diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb
index 5f01a0daae9..505d178ba8e 100644
--- a/app/models/ci/runner_project.rb
+++ b/app/models/ci/runner_project.rb
@@ -1,6 +1,6 @@
module Ci
class RunnerProject < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
belongs_to :runner
belongs_to :project
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 59570924c8d..75b8ea2a371 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -1,11 +1,70 @@
module Ci
class Stage < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
+ include Importable
+ include HasStatus
+ include Gitlab::OptimisticLocking
+
+ enum status: HasStatus::STATUSES_ENUM
belongs_to :project
belongs_to :pipeline
- has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
- has_many :builds, foreign_key: :commit_id
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
+ has_many :builds, foreign_key: :stage_id
+
+ validates :project, presence: true, unless: :importing?
+ validates :pipeline, presence: true, unless: :importing?
+ validates :name, presence: true, unless: :importing?
+
+ after_initialize do |stage|
+ self.status = DEFAULT_STATUS if self.status.nil?
+ end
+
+ state_machine :status, initial: :created do
+ event :enqueue do
+ transition created: :pending
+ transition [:success, :failed, :canceled, :skipped] => :running
+ end
+
+ event :run do
+ transition any - [:running] => :running
+ end
+
+ event :skip do
+ transition any - [:skipped] => :skipped
+ end
+
+ event :drop do
+ transition any - [:failed] => :failed
+ end
+
+ event :succeed do
+ transition any - [:success] => :success
+ end
+
+ event :cancel do
+ transition any - [:canceled] => :canceled
+ end
+
+ event :block do
+ transition any - [:manual] => :manual
+ end
+ end
+
+ def update_status
+ retry_optimistic_lock(self) do
+ case statuses.latest.status
+ when 'pending' then enqueue
+ when 'running' then run
+ when 'success' then succeed
+ when 'failed' then drop
+ when 'canceled' then cancel
+ when 'manual' then block
+ when 'skipped' then skip
+ else skip
+ end
+ end
+ end
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 6df41a3f301..aa065e33739 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -1,8 +1,9 @@
module Ci
class Trigger < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
+ include IgnorableColumn
- acts_as_paranoid
+ ignore_column :deleted_at
belongs_to :project
belongs_to :owner, class_name: "User"
diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb
index 564334ad1ad..215b1cf6753 100644
--- a/app/models/ci/trigger_request.rb
+++ b/app/models/ci/trigger_request.rb
@@ -1,12 +1,16 @@
module Ci
class TriggerRequest < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
belongs_to :trigger
belongs_to :pipeline, foreign_key: :commit_id
has_many :builds
- serialize :variables # rubocop:disable Cop/ActiverecordSerialize
+ # We switched to Ci::PipelineVariable from Ci::TriggerRequest.variables.
+ # Ci::TriggerRequest doesn't save variables anymore.
+ validates :variables, absence: true
+
+ 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 f235260208f..452cb910bca 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -1,27 +1,18 @@
module Ci
class Variable < ActiveRecord::Base
- extend Ci::Model
+ extend Gitlab::Ci::Model
+ include HasVariable
+ include Presentable
belongs_to :project
- validates :key,
- presence: true,
- uniqueness: { scope: :project_id },
- length: { maximum: 255 },
- format: { with: /\A[a-zA-Z0-9_]+\z/,
- message: "can contain only letters, digits and '_'." }
+ alias_attribute :secret_value, :value
- 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'
+ validates :key, uniqueness: {
+ scope: [:project_id, :environment_scope],
+ message: "(%{value}) has already been taken"
+ }
- def to_runner_variable
- { key: key, value: value, public: false }
- end
+ scope :unprotected, -> { where(protected: false) }
end
end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
new file mode 100644
index 00000000000..58de3448577
--- /dev/null
+++ b/app/models/clusters/applications/helm.rb
@@ -0,0 +1,22 @@
+module Clusters
+ module Applications
+ class Helm < ActiveRecord::Base
+ self.table_name = 'clusters_applications_helm'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+
+ default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION
+
+ def set_initial_status
+ return unless not_installable?
+
+ self.status = 'installable' if cluster&.platform_kubernetes_active?
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InitCommand.new(name)
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
new file mode 100644
index 00000000000..27fc3b85465
--- /dev/null
+++ b/app/models/clusters/applications/ingress.rb
@@ -0,0 +1,49 @@
+module Clusters
+ module Applications
+ class Ingress < ActiveRecord::Base
+ self.table_name = 'clusters_applications_ingress'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+ include ::Clusters::Concerns::ApplicationData
+ include AfterCommitQueue
+
+ default_value_for :ingress_type, :nginx
+ default_value_for :version, :nginx
+
+ enum ingress_type: {
+ nginx: 1
+ }
+
+ FETCH_IP_ADDRESS_DELAY = 30.seconds
+
+ state_machine :status do
+ before_transition any => [:installed] do |application|
+ application.run_after_commit do
+ ClusterWaitForIngressIpAddressWorker.perform_in(
+ FETCH_IP_ADDRESS_DELAY, application.name, application.id)
+ end
+ end
+ end
+
+ def chart
+ 'stable/nginx-ingress'
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name,
+ chart: chart,
+ values: values
+ )
+ end
+
+ def schedule_status_update
+ return unless installed?
+ return if external_ip
+
+ ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
new file mode 100644
index 00000000000..7b25d8c4089
--- /dev/null
+++ b/app/models/clusters/applications/prometheus.rb
@@ -0,0 +1,61 @@
+module Clusters
+ module Applications
+ class Prometheus < ActiveRecord::Base
+ include PrometheusAdapter
+
+ VERSION = "2.0.0".freeze
+
+ self.table_name = 'clusters_applications_prometheus'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+ include ::Clusters::Concerns::ApplicationData
+
+ default_value_for :version, VERSION
+
+ state_machine :status do
+ after_transition any => [:installed] do |application|
+ application.cluster.projects.each do |project|
+ project.find_or_initialize_service('prometheus').update(active: true)
+ end
+ end
+ end
+
+ def chart
+ 'stable/prometheus'
+ end
+
+ def service_name
+ 'prometheus-prometheus-server'
+ end
+
+ def service_port
+ 80
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name,
+ chart: chart,
+ values: values
+ )
+ end
+
+ def prometheus_client
+ return unless kube_client
+
+ proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE)
+
+ # ensures headers containing auth data are appended to original k8s client options
+ options = kube_client.rest_client.options.merge(headers: kube_client.headers)
+ RestClient::Resource.new(proxy_url, options)
+ end
+
+ private
+
+ def kube_client
+ cluster&.kubeclient
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
new file mode 100644
index 00000000000..16efe90fa27
--- /dev/null
+++ b/app/models/clusters/applications/runner.rb
@@ -0,0 +1,69 @@
+module Clusters
+ module Applications
+ class Runner < ActiveRecord::Base
+ VERSION = '0.1.13'.freeze
+
+ self.table_name = 'clusters_applications_runners'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+ include ::Clusters::Concerns::ApplicationData
+
+ belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
+ delegate :project, to: :cluster
+
+ default_value_for :version, VERSION
+
+ def chart
+ "#{name}/gitlab-runner"
+ end
+
+ def repository
+ 'https://charts.gitlab.io'
+ end
+
+ def values
+ content_values.to_yaml
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name,
+ chart: chart,
+ values: values,
+ repository: repository
+ )
+ end
+
+ private
+
+ def ensure_runner
+ runner || create_and_assign_runner
+ end
+
+ def create_and_assign_runner
+ transaction do
+ project.runners.create!(name: 'kubernetes-cluster', tag_list: %w(kubernetes cluster)).tap do |runner|
+ update!(runner_id: runner.id)
+ end
+ end
+ end
+
+ def gitlab_url
+ Gitlab::Routing.url_helpers.root_url(only_path: false)
+ end
+
+ def specification
+ {
+ "gitlabUrl" => gitlab_url,
+ "runnerToken" => ensure_runner.token,
+ "runners" => { "privileged" => privileged }
+ }
+ end
+
+ def content_values
+ YAML.load_file(chart_values_file).deep_merge!(specification)
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
new file mode 100644
index 00000000000..49eb069016a
--- /dev/null
+++ b/app/models/clusters/cluster.rb
@@ -0,0 +1,109 @@
+module Clusters
+ class Cluster < ActiveRecord::Base
+ include Presentable
+
+ self.table_name = 'clusters'
+
+ APPLICATIONS = {
+ Applications::Helm.application_name => Applications::Helm,
+ Applications::Ingress.application_name => Applications::Ingress,
+ Applications::Prometheus.application_name => Applications::Prometheus,
+ Applications::Runner.application_name => Applications::Runner
+ }.freeze
+
+ belongs_to :user
+
+ has_many :cluster_projects, class_name: 'Clusters::Project'
+ has_many :projects, through: :cluster_projects, class_name: '::Project'
+
+ # we force autosave to happen when we save `Cluster` model
+ has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
+
+ has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', autosave: true
+
+ has_one :application_helm, class_name: 'Clusters::Applications::Helm'
+ has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
+ has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus'
+ has_one :application_runner, class_name: 'Clusters::Applications::Runner'
+
+ accepts_nested_attributes_for :provider_gcp, update_only: true
+ accepts_nested_attributes_for :platform_kubernetes, update_only: true
+
+ validates :name, cluster_name: true
+ validate :restrict_modification, on: :update
+
+ delegate :status, to: :provider, allow_nil: true
+ delegate :status_reason, to: :provider, allow_nil: true
+ delegate :on_creation?, to: :provider, allow_nil: true
+
+ delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true
+ delegate :installed?, to: :application_helm, prefix: true, allow_nil: true
+
+ enum platform_type: {
+ kubernetes: 1
+ }
+
+ enum provider_type: {
+ user: 0,
+ gcp: 1
+ }
+
+ scope :enabled, -> { where(enabled: true) }
+ scope :disabled, -> { where(enabled: false) }
+
+ def status_name
+ if provider
+ provider.status_name
+ else
+ :created
+ end
+ end
+
+ def created?
+ status_name == :created
+ end
+
+ def applications
+ [
+ application_helm || build_application_helm,
+ application_ingress || build_application_ingress,
+ application_prometheus || build_application_prometheus,
+ application_runner || build_application_runner
+ ]
+ end
+
+ def provider
+ return provider_gcp if gcp?
+ end
+
+ def platform
+ return platform_kubernetes if kubernetes?
+ end
+
+ def managed?
+ !user?
+ end
+
+ def first_project
+ return @first_project if defined?(@first_project)
+
+ @first_project = projects.first
+ end
+ alias_method :project, :first_project
+
+ def kubeclient
+ platform_kubernetes.kubeclient if kubernetes?
+ end
+
+ private
+
+ def restrict_modification
+ if provider&.on_creation?
+ errors.add(:base, "cannot modify during creation")
+ return false
+ end
+
+ true
+ end
+ end
+end
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
new file mode 100644
index 00000000000..623b836c0ed
--- /dev/null
+++ b/app/models/clusters/concerns/application_core.rb
@@ -0,0 +1,34 @@
+module Clusters
+ module Concerns
+ module ApplicationCore
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
+
+ validates :cluster, presence: true
+
+ after_initialize :set_initial_status
+
+ def set_initial_status
+ return unless not_installable?
+
+ self.status = 'installable' if cluster&.application_helm_installed?
+ end
+
+ def self.application_name
+ self.to_s.demodulize.underscore
+ end
+
+ def name
+ self.class.application_name
+ end
+
+ def schedule_status_update
+ # Override if you need extra data synchronized
+ # from K8s after installation
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
new file mode 100644
index 00000000000..96ac757e99e
--- /dev/null
+++ b/app/models/clusters/concerns/application_data.rb
@@ -0,0 +1,23 @@
+module Clusters
+ module Concerns
+ module ApplicationData
+ extend ActiveSupport::Concern
+
+ included do
+ def repository
+ nil
+ end
+
+ def values
+ File.read(chart_values_file)
+ end
+
+ private
+
+ def chart_values_file
+ "#{Rails.root}/vendor/#{name}/values.yaml"
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
new file mode 100644
index 00000000000..7b7c8eac773
--- /dev/null
+++ b/app/models/clusters/concerns/application_status.rb
@@ -0,0 +1,43 @@
+module Clusters
+ module Concerns
+ module ApplicationStatus
+ extend ActiveSupport::Concern
+
+ included do
+ state_machine :status, initial: :not_installable do
+ state :not_installable, value: -2
+ state :errored, value: -1
+ state :installable, value: 0
+ state :scheduled, value: 1
+ state :installing, value: 2
+ state :installed, value: 3
+
+ event :make_scheduled do
+ transition [:installable, :errored] => :scheduled
+ end
+
+ event :make_installing do
+ transition [:scheduled] => :installing
+ end
+
+ event :make_installed do
+ transition [:installing] => :installed
+ end
+
+ event :make_errored do
+ transition any => :errored
+ end
+
+ before_transition any => [:scheduled] do |app_status, _|
+ app_status.status_reason = nil
+ end
+
+ before_transition any => [:errored] do |app_status, transition|
+ status_reason = transition.args.first
+ app_status.status_reason = status_reason if status_reason
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
new file mode 100644
index 00000000000..ba6552f238f
--- /dev/null
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -0,0 +1,191 @@
+module Clusters
+ module Platforms
+ class Kubernetes < ActiveRecord::Base
+ include Gitlab::Kubernetes
+ include ReactiveCaching
+
+ self.table_name = 'cluster_platforms_kubernetes'
+ self.reactive_cache_key = ->(kubernetes) { [kubernetes.class.model_name.singular, kubernetes.id] }
+
+ belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster'
+
+ attr_encrypted :password,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ before_validation :enforce_namespace_to_lower_case
+
+ validates :namespace,
+ allow_blank: true,
+ length: 1..63,
+ format: {
+ with: Gitlab::Regex.kubernetes_namespace_regex,
+ message: Gitlab::Regex.kubernetes_namespace_regex_message
+ }
+
+ # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned)
+ validates :api_url, url: true, presence: true
+ validates :token, presence: true
+
+ validate :prevent_modification, on: :update
+
+ after_save :clear_reactive_cache!
+
+ alias_attribute :ca_pem, :ca_cert
+
+ delegate :project, to: :cluster, allow_nil: true
+ delegate :enabled?, to: :cluster, allow_nil: true
+ delegate :managed?, to: :cluster, allow_nil: true
+
+ alias_method :active?, :enabled?
+
+ def actual_namespace
+ if namespace.present?
+ namespace
+ else
+ default_namespace
+ end
+ end
+
+ def predefined_variables
+ config = YAML.dump(kubeconfig)
+
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables
+ .append(key: 'KUBE_URL', value: api_url)
+ .append(key: 'KUBE_TOKEN', value: token, public: false)
+ .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
+ .append(key: 'KUBECONFIG', value: config, public: false, file: true)
+
+ if ca_pem.present?
+ variables
+ .append(key: 'KUBE_CA_PEM', value: ca_pem)
+ .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true)
+ end
+ end
+ end
+
+ # Constructs a list of terminals from the reactive cache
+ #
+ # Returns nil if the cache is empty, in which case you should try again a
+ # short time later
+ def terminals(environment)
+ with_reactive_cache do |data|
+ pods = filter_by_label(data[:pods], app: environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }
+ terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
+ end
+ end
+
+ # Caches resources in the namespace so other calls don't need to block on
+ # network access
+ def calculate_reactive_cache
+ return unless enabled? && project && !project.pending_delete?
+
+ # We may want to cache extra things in the future
+ { pods: read_pods }
+ end
+
+ def kubeclient
+ @kubeclient ||= build_kubeclient!
+ end
+
+ private
+
+ def kubeconfig
+ to_kubeconfig(
+ url: api_url,
+ namespace: actual_namespace,
+ token: token,
+ ca_pem: ca_pem)
+ end
+
+ def default_namespace
+ return unless project
+
+ slug = "#{project.path}-#{project.id}".downcase
+ slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '')
+ end
+
+ def build_kubeclient!(api_path: 'api', api_version: 'v1')
+ raise "Incomplete settings" unless api_url && actual_namespace
+
+ unless (username && password) || token
+ raise "Either username/password or token is required to access API"
+ end
+
+ ::Kubeclient::Client.new(
+ join_api_url(api_path),
+ api_version,
+ auth_options: kubeclient_auth_options,
+ ssl_options: kubeclient_ssl_options,
+ http_proxy_uri: ENV['http_proxy']
+ )
+ end
+
+ # Returns a hash of all pods in the namespace
+ def read_pods
+ kubeclient = build_kubeclient!
+
+ kubeclient.get_pods(namespace: actual_namespace).as_json
+ rescue Kubeclient::HttpError => err
+ raise err unless err.error_code == 404
+
+ []
+ end
+
+ def kubeclient_ssl_options
+ opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
+
+ if ca_pem.present?
+ opts[:cert_store] = OpenSSL::X509::Store.new
+ opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
+ end
+
+ opts
+ end
+
+ def kubeclient_auth_options
+ { bearer_token: token }
+ end
+
+ def join_api_url(api_path)
+ url = URI.parse(api_url)
+ prefix = url.path.sub(%r{/+\z}, '')
+
+ url.path = [prefix, api_path].join("/")
+
+ url.to_s
+ end
+
+ def terminal_auth
+ {
+ token: token,
+ ca_pem: ca_pem,
+ max_session_time: Gitlab::CurrentSettings.terminal_max_session_time
+ }
+ end
+
+ def enforce_namespace_to_lower_case
+ self.namespace = self.namespace&.downcase
+ end
+
+ def prevent_modification
+ return unless managed?
+
+ if api_url_changed? || token_changed? || ca_pem_changed?
+ errors.add(:base, _('Cannot modify managed Kubernetes cluster'))
+ return false
+ end
+
+ true
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb
new file mode 100644
index 00000000000..eeb734b20b8
--- /dev/null
+++ b/app/models/clusters/project.rb
@@ -0,0 +1,8 @@
+module Clusters
+ class Project < ActiveRecord::Base
+ self.table_name = 'cluster_projects'
+
+ belongs_to :cluster, class_name: 'Clusters::Cluster'
+ belongs_to :project, class_name: '::Project'
+ end
+end
diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb
new file mode 100644
index 00000000000..7fac32466ab
--- /dev/null
+++ b/app/models/clusters/providers/gcp.rb
@@ -0,0 +1,80 @@
+module Clusters
+ module Providers
+ class Gcp < ActiveRecord::Base
+ self.table_name = 'cluster_providers_gcp'
+
+ belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster'
+
+ default_value_for :zone, 'us-central1-a'
+ default_value_for :num_nodes, 3
+ default_value_for :machine_type, 'n1-standard-2'
+
+ attr_encrypted :access_token,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ validates :gcp_project_id,
+ length: 1..63,
+ format: {
+ with: Gitlab::Regex.kubernetes_namespace_regex,
+ message: Gitlab::Regex.kubernetes_namespace_regex_message
+ }
+
+ validates :zone, presence: true
+
+ validates :num_nodes,
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than: 0
+ }
+
+ state_machine :status, initial: :scheduled do
+ state :scheduled, value: 1
+ state :creating, value: 2
+ state :created, value: 3
+ state :errored, value: 4
+
+ event :make_creating do
+ transition any - [:creating] => :creating
+ end
+
+ event :make_created do
+ transition any - [:created] => :created
+ end
+
+ event :make_errored do
+ transition any - [:errored] => :errored
+ end
+
+ before_transition any => [:errored, :created] do |provider|
+ provider.access_token = nil
+ provider.operation_id = nil
+ end
+
+ before_transition any => [:creating] do |provider, transition|
+ operation_id = transition.args.first
+ raise ArgumentError.new('operation_id is required') unless operation_id.present?
+
+ provider.operation_id = operation_id
+ end
+
+ before_transition any => [:errored] do |provider, transition|
+ status_reason = transition.args.first
+ provider.status_reason = status_reason if status_reason
+ end
+ end
+
+ def on_creation?
+ scheduled? || creating?
+ end
+
+ def api_client
+ return unless access_token
+
+ @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil)
+ end
+ end
+ end
+end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 20206d57c4c..cceae5efb72 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -1,5 +1,7 @@
+# coding: utf-8
class Commit
extend ActiveModel::Naming
+ extend Gitlab::Cache::RequestCache
include ActiveModel::Conversion
include Noteable
@@ -7,6 +9,7 @@ class Commit
include Mentionable
include Referable
include StaticModel
+ include ::Gitlab::Utils::StrongMemoize
attr_mentionable :safe_message, pipeline: :single_line
@@ -15,6 +18,9 @@ class Commit
participant :notes_with_associations
attr_accessor :project, :author
+ attr_accessor :redacted_description_html
+ attr_accessor :redacted_title_html
+ attr_reader :gpg_commit
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
@@ -22,8 +28,15 @@ class Commit
DIFF_HARD_LIMIT_FILES = 1000
DIFF_HARD_LIMIT_LINES = 50000
- # The SHA can be between 7 and 40 hex characters.
- COMMIT_SHA_PATTERN = '\h{7,40}'.freeze
+ MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
+ COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
+
+ def banzai_render_context(field)
+ context = { pipeline: :single_line, project: self.project }
+ context[:author] = self.author if self.author
+
+ context
+ end
class << self
def decorate(commits, project)
@@ -41,9 +54,23 @@ class Commit
diffs.reduce(0) { |sum, d| sum + Gitlab::Git::Util.count_lines(d.diff) }
end
+ def order_by(collection:, order_by:, sort:)
+ return collection unless %w[email name commits].include?(order_by)
+ return collection unless %w[asc desc].include?(sort)
+
+ collection.sort do |a, b|
+ operands = [a, b].tap { |o| o.reverse! if sort == 'desc' }
+
+ attr1, attr2 = operands.first.public_send(order_by), operands.second.public_send(order_by) # rubocop:disable PublicSend
+
+ # use case insensitive comparison for string values
+ order_by.in?(%w[email name]) ? attr1.casecmp(attr2) : attr1 <=> attr2
+ end
+ end
+
# Truncate sha to 8 characters
def truncate_sha(sha)
- sha[0..7]
+ sha[0..MIN_SHA_LENGTH]
end
def max_diff_options
@@ -54,12 +81,27 @@ class Commit
end
def from_hash(hash, project)
- new(Gitlab::Git::Commit.new(hash), project)
+ raw_commit = Gitlab::Git::Commit.new(project.repository.raw, hash)
+ new(raw_commit, project)
end
def valid_hash?(key)
!!(/\A#{COMMIT_SHA_PATTERN}\z/ =~ key)
end
+
+ def lazy(project, oid)
+ BatchLoader.for({ project: project, oid: oid }).batch do |items, loader|
+ items_by_project = items.group_by { |i| i[:project] }
+
+ items_by_project.each do |project, commit_ids|
+ oids = commit_ids.map { |i| i[:oid] }
+
+ project.repository.commits_by(oids: oids).each do |commit|
+ loader.call({ project: commit.project, oid: commit.id }, commit) if commit
+ end
+ end
+ end
+ end
end
attr_accessor :raw
@@ -69,14 +111,20 @@ class Commit
@raw = raw_commit
@project = project
+ @statuses = {}
+ @gpg_commit = Gitlab::Gpg::Commit.new(self) if project
end
def id
- @raw.id
+ raw.id
+ end
+
+ def project_id
+ project.id
end
def ==(other)
- (self.class === other) && (raw == other.raw)
+ other.is_a?(self.class) && raw == other.raw
end
def self.reference_prefix
@@ -89,7 +137,7 @@ class Commit
def self.reference_pattern
@reference_pattern ||= %r{
(?:#{Project.reference_pattern}#{reference_prefix})?
- (?<commit>\h{7,40})
+ (?<commit>#{COMMIT_SHA_PATTERN})
}x
end
@@ -97,12 +145,12 @@ class Commit
@link_reference_pattern ||= super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})/)
end
- def to_reference(from_project = nil, full: false)
- commit_reference(from_project, id, full: full)
+ def to_reference(from = nil, full: false)
+ commit_reference(from, id, full: full)
end
- def reference_link_text(from_project = nil, full: false)
- commit_reference(from_project, short_id, full: full)
+ def reference_link_text(from = nil, full: false)
+ commit_reference(from, short_id, full: full)
end
def diff_line_count
@@ -138,7 +186,7 @@ class Commit
safe_message.split("\n", 2)[1].try(:chomp)
end
-
+
def description?
description.present?
end
@@ -169,30 +217,22 @@ class Commit
end
def author
- 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.key?(key)
- @author = RequestStore.store[key]
- else
- @author = find_author_by_any_email
- RequestStore.store[key] = @author
- end
- else
- @author ||= find_author_by_any_email
- end
+ User.find_by_any_email(author_email.downcase)
end
+ request_cache(:author) { author_email.downcase }
def committer
@committer ||= User.find_by_any_email(committer_email.downcase)
end
def parents
- @parents ||= parent_ids.map { |id| project.commit(id) }
+ @parents ||= parent_ids.map { |oid| Commit.lazy(project, oid) }
end
def parent
- @parent ||= project.commit(self.parent_id) if self.parent_id
+ strong_memoize(:parent) do
+ project.commit_by(oid: self.parent_id) if self.parent_id
+ end
end
def notes
@@ -207,17 +247,20 @@ class Commit
notes.includes(:author)
end
- def method_missing(m, *args, &block)
- @raw.send(m, *args, &block)
+ def merge_requests
+ @merge_requests ||= project.merge_requests.by_commit_sha(sha)
+ end
+
+ def method_missing(method, *args, &block)
+ @raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
def respond_to_missing?(method, include_private = false)
@raw.respond_to?(method, include_private) || super
end
- # Truncate sha to 8 characters
def short_id
- @raw.short_id(7)
+ @raw.short_id(MIN_SHA_LENGTH)
end
def diff_refs
@@ -236,13 +279,23 @@ class Commit
end
def status(ref = nil)
- @statuses ||= {}
-
return @statuses[ref] if @statuses.key?(ref)
- @statuses[ref] = pipelines.latest_status(ref)
+ @statuses[ref] = project.pipelines.latest_status_per_commit(id, ref)[id]
+ end
+
+ def set_status_for_ref(ref, status)
+ @statuses[ref] = status
+ end
+
+ def signature
+ return @signature if defined?(@signature)
+
+ @signature = gpg_commit.signature
end
+ delegate :has_signature?, to: :gpg_commit
+
def revert_branch_name
"revert-#{short_id}"
end
@@ -251,6 +304,28 @@ class Commit
project.repository.next_branch("cherry-pick-#{short_id}", mild: true)
end
+ def cherry_pick_description(user)
+ message_body = "(cherry picked from commit #{sha})"
+
+ if merged_merge_request?(user)
+ commits_in_merge_request = merged_merge_request(user).commits
+
+ if commits_in_merge_request.present?
+ message_body << "\n"
+
+ commits_in_merge_request.reverse.each do |commit_in_merge|
+ message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}"
+ end
+ end
+ end
+
+ message_body
+ end
+
+ def cherry_pick_message(user)
+ %Q{#{message}\n\n#{cherry_pick_description(user)}}
+ end
+
def revert_description(user)
if merged_merge_request?(user)
"This reverts merge request #{merged_merge_request(user).to_reference}"
@@ -280,10 +355,11 @@ class Commit
@merged_merge_request_hash[current_user]
end
- def has_been_reverted?(current_user, noteable = self)
+ def has_been_reverted?(current_user, notes_association = nil)
ext = all_references(current_user)
+ notes_association ||= notes_with_associations
- noteable.notes_with_associations.system.each do |note|
+ notes_association.system.each do |note|
note.all_references(current_user, extractor: ext)
end
@@ -305,40 +381,30 @@ class Commit
# uri_type('doc/README.md') # => :blob
# uri_type('doc/logo.png') # => :raw
# uri_type('doc/api') # => :tree
- # uri_type('not/found') # => :nil
+ # uri_type('not/found') # => nil
#
# Returns a symbol
def uri_type(path)
- entry = @raw.tree.path(path)
+ entry = @raw.tree_entry(path)
+ return unless entry
+
if entry[:type] == :blob
blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project)
blob.image? || blob.video? ? :raw : :blob
else
entry[:type]
end
- rescue Rugged::TreeError
- nil
end
def raw_diffs(*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
+ raw.diffs(*args)
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
+ @deltas ||= raw.deltas
end
- def diffs(diff_options = nil)
+ def diffs(diff_options = {})
Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
end
@@ -356,10 +422,14 @@ class Commit
!!(title =~ WIP_REGEX)
end
+ def merged_merge_request?(user)
+ !!merged_merge_request(user)
+ end
+
private
- def commit_reference(from_project, referable_commit_id, full: false)
- reference = project.to_reference(from_project, full: full)
+ def commit_reference(from, referable_commit_id, full: false)
+ reference = project.to_reference(from, full: full)
if reference.present?
"#{reference}#{self.class.reference_prefix}#{referable_commit_id}"
@@ -368,10 +438,6 @@ class Commit
end
end
- def find_author_by_any_email
- User.find_by_any_email(author_email.downcase)
- end
-
def repo_changes
changes = { added: [], modified: [], removed: [] }
@@ -388,10 +454,6 @@ class Commit
changes
end
- def merged_merge_request?(user)
- !!merged_merge_request(user)
- end
-
def merged_merge_request_no_cache(user)
MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit?
end
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
new file mode 100644
index 00000000000..dd93af9df64
--- /dev/null
+++ b/app/models/commit_collection.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+# A collection of Commit instances for a specific project and Git reference.
+class CommitCollection
+ include Enumerable
+
+ attr_reader :project, :ref, :commits
+
+ # project - The project the commits belong to.
+ # commits - The Commit instances to store.
+ # ref - The name of the ref (e.g. "master").
+ def initialize(project, commits, ref = nil)
+ @project = project
+ @commits = commits
+ @ref = ref
+ end
+
+ def each(&block)
+ commits.each(&block)
+ end
+
+ # Sets the pipeline status for every commit.
+ #
+ # Setting this status ahead of time removes the need for running a query for
+ # every commit we're displaying.
+ def with_pipeline_status
+ statuses = project.pipelines.latest_status_per_commit(map(&:id), ref)
+
+ each do |commit|
+ commit.set_status_for_ref(ref, statuses[commit.id])
+ end
+
+ self
+ end
+
+ def respond_to_missing?(message, inc_private = false)
+ commits.respond_to?(message, inc_private)
+ end
+
+ # rubocop:disable GitlabSecurity/PublicSend
+ def method_missing(message, *args, &block)
+ commits.public_send(message, *args, &block)
+ end
+end
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 84e2e8a5dd5..b93c111dabc 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -89,8 +89,8 @@ class CommitRange
alias_method :id, :to_s
- def to_reference(from_project = nil, full: false)
- project_reference = project.to_reference(from_project, full: full)
+ def to_reference(from = nil, full: false)
+ project_reference = project.to_reference(from, full: full)
if project_reference.present?
project_reference + self.class.reference_prefix + self.id
@@ -99,8 +99,8 @@ class CommitRange
end
end
- def reference_link_text(from_project = nil)
- project_reference = project.to_reference(from_project)
+ def reference_link_text(from = nil)
+ project_reference = project.to_reference(from)
reference = ref_from + notation + ref_to
if project_reference.present?
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 07cec63b939..9fb5b7efec6 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -14,10 +14,10 @@ class CommitStatus < ActiveRecord::Base
delegate :sha, :short_sha, to: :pipeline
validates :pipeline, presence: true, unless: :importing?
-
validates :name, presence: true, unless: :importing?
alias_attribute :author, :user
+ alias_attribute :pipeline_id, :commit_id
scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled])
@@ -38,15 +38,35 @@ class CommitStatus < ActiveRecord::Base
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
- state_machine :status do
- event :enqueue do
- transition [:created, :skipped, :manual] => :pending
+ enum failure_reason: {
+ unknown_failure: nil,
+ script_failure: 1,
+ api_failure: 2,
+ stuck_or_timeout_failure: 3,
+ runner_system_failure: 4,
+ missing_dependency_failure: 5
+ }
+
+ ##
+ # We still create some CommitStatuses outside of CreatePipelineService.
+ #
+ # These are pages deployments and external statuses.
+ #
+ before_create unless: :importing? do
+ Ci::EnsureStageService.new(project, user).execute(self) do |stage|
+ self.run_after_commit { StageUpdateWorker.perform_async(stage.id) }
end
+ end
+ state_machine :status do
event :process do
transition [:skipped, :manual] => :created
end
+ event :enqueue do
+ transition [:created, :skipped, :manual] => :pending
+ end
+
event :run do
transition pending: :running
end
@@ -79,26 +99,35 @@ class CommitStatus < ActiveRecord::Base
commit_status.finished_at = Time.now
end
+ before_transition any => :failed do |commit_status, transition|
+ failure_reason = transition.args.first
+ commit_status.failure_reason = failure_reason
+ end
+
after_transition do |commit_status, transition|
+ next unless commit_status.project
next if transition.loopback?
commit_status.run_after_commit do
- if pipeline
+ if pipeline_id
if complete? || manual?
- PipelineProcessWorker.perform_async(pipeline.id)
+ PipelineProcessWorker.perform_async(pipeline_id)
else
- PipelineUpdateWorker.perform_async(pipeline.id)
+ PipelineUpdateWorker.perform_async(pipeline_id)
end
end
- ExpireJobCacheWorker.perform_async(commit_status.id)
+ StageUpdateWorker.perform_async(stage_id)
+ ExpireJobCacheWorker.perform_async(id)
end
end
after_transition any => :failed do |commit_status|
+ next unless commit_status.project
+
commit_status.run_after_commit do
MergeRequests::AddTodoWhenBuildFailsService
- .new(pipeline.project, nil).execute(self)
+ .new(project, nil).execute(self)
end
end
end
@@ -112,7 +141,7 @@ class CommitStatus < ActiveRecord::Base
end
def group_name
- name.to_s.gsub(/\d+[\s:\/\\]+\d+\s*/, '').strip
+ name.to_s.gsub(%r{\d+[\.\s:/\\]+\d+\s*}, '').strip
end
def failed_but_allowed?
diff --git a/app/models/compare.rb b/app/models/compare.rb
index 3a8bbcb1acd..feb4b89c781 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -1,4 +1,6 @@
class Compare
+ include Gitlab::Utils::StrongMemoize
+
delegate :same, :head, :base, to: :@compare
attr_reader :project
@@ -11,9 +13,10 @@ class Compare
end
end
- def initialize(compare, project, straight: false)
+ def initialize(compare, project, base_sha: nil, straight: false)
@compare = compare
@project = project
+ @base_sha = base_sha
@straight = straight
end
@@ -22,40 +25,36 @@ class Compare
end
def start_commit
- return @start_commit if defined?(@start_commit)
+ strong_memoize(:start_commit) do
+ commit = @compare.base
- commit = @compare.base
- @start_commit = commit ? ::Commit.new(commit, project) : nil
+ ::Commit.new(commit, project) if commit
+ end
end
def head_commit
- return @head_commit if defined?(@head_commit)
+ strong_memoize(:head_commit) do
+ commit = @compare.head
- commit = @compare.head
- @head_commit = commit ? ::Commit.new(commit, project) : nil
+ ::Commit.new(commit, project) if commit
+ end
end
alias_method :commit, :head_commit
- def base_commit
- return @base_commit if defined?(@base_commit)
-
- @base_commit = if start_commit && head_commit
- project.merge_base_commit(start_commit.id, head_commit.id)
- else
- nil
- end
- end
-
def start_commit_sha
- start_commit.try(:sha)
+ start_commit&.sha
end
def base_commit_sha
- base_commit.try(:sha)
+ strong_memoize(:base_commit) do
+ next unless start_commit && head_commit
+
+ @base_sha || project.merge_base_commit(start_commit.id, head_commit.id)&.sha
+ end
end
def head_commit_sha
- commit.try(:sha)
+ commit&.sha
end
def raw_diffs(*args)
diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb
index 62bc6b809f4..d502e7e54c6 100644
--- a/app/models/concerns/access_requestable.rb
+++ b/app/models/concerns/access_requestable.rb
@@ -8,6 +8,6 @@ module AccessRequestable
extend ActiveSupport::Concern
def request_access(user)
- Members::RequestAccessService.new(self, user).execute
+ Members::RequestAccessService.new(user).execute(self)
end
end
diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb
new file mode 100644
index 00000000000..ff52ca64459
--- /dev/null
+++ b/app/models/concerns/artifact_migratable.rb
@@ -0,0 +1,44 @@
+# Adapter class to unify the interface between mounted uploaders and the
+# Ci::Artifact model
+# Meant to be prepended so the interface can stay the same
+module ArtifactMigratable
+ def artifacts_file
+ job_artifacts_archive&.file || legacy_artifacts_file
+ end
+
+ def artifacts_metadata
+ job_artifacts_metadata&.file || legacy_artifacts_metadata
+ end
+
+ def artifacts?
+ !artifacts_expired? && artifacts_file.exists?
+ end
+
+ def artifacts_metadata?
+ artifacts? && artifacts_metadata.exists?
+ end
+
+ def artifacts_file_changed?
+ job_artifacts_archive&.file_changed? || attribute_changed?(:artifacts_file)
+ end
+
+ def remove_artifacts_file!
+ if job_artifacts_archive
+ job_artifacts_archive.destroy
+ else
+ remove_legacy_artifacts_file!
+ end
+ end
+
+ def remove_artifacts_metadata!
+ if job_artifacts_metadata
+ job_artifacts_metadata.destroy
+ else
+ remove_legacy_artifacts_metadata!
+ end
+ end
+
+ def artifacts_size
+ read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i
+ end
+end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
new file mode 100644
index 00000000000..4b66725a3e6
--- /dev/null
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -0,0 +1,46 @@
+# Include atomic internal id generation scheme for a model
+#
+# This allows us to atomically generate internal ids that are
+# unique within a given scope.
+#
+# For example, let's generate internal ids for Issue per Project:
+# ```
+# class Issue < ActiveRecord::Base
+# has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) }
+# end
+# ```
+#
+# This generates unique internal ids per project for newly created issues.
+# The generated internal id is saved in the `iid` attribute of `Issue`.
+#
+# This concern uses InternalId records to facilitate atomicity.
+# In the absence of a record for the given scope, one will be created automatically.
+# In this situation, the `init` block is called to calculate the initial value.
+# In the example above, we calculate the maximum `iid` of all issues
+# within the given project.
+#
+# Note that a model may have more than one internal id associated with possibly
+# different scopes.
+module AtomicInternalId
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName
+ before_validation(on: :create) do
+ if read_attribute(column).blank?
+ scope_attrs = { scope => association(scope).reader }
+ usage = self.class.table_name.to_sym
+
+ new_iid = InternalId.generate_next(self, scope_attrs, usage, init)
+ write_attribute(column, new_iid)
+ end
+ end
+
+ validates column, presence: true, numericality: true
+ end
+ end
+
+ def to_param
+ iid.to_s
+ end
+end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 8fbfed11bdf..318df11727e 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -1,18 +1,53 @@
module Avatarable
extend ActiveSupport::Concern
+ included do
+ prepend ShadowMethods
+
+ validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
+ validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+
+ mount_uploader :avatar, AvatarUploader
+ end
+
+ module ShadowMethods
+ 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(only_path: args.fetch(:only_path, true)) || super
+ end
+ end
+
+ def avatar_type
+ unless self.avatar.image?
+ errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::IMAGE_EXT.join(', ')}"
+ end
+ end
+
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
+ use_asset_host = asset_host.present?
+
+ # Avatars for private and internal groups and projects require authentication to be viewed,
+ # which means they can only be served by Rails, on the regular GitLab host.
+ # If an asset host is configured, we need to return the fully qualified URL
+ # instead of only the avatar path, so that Rails doesn't prefix it with the asset host.
+ if use_asset_host && respond_to?(:public?) && !public?
+ use_asset_host = false
+ only_path = false
+ end
- # 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
+ url_base = ""
+ if use_asset_host
+ url_base << asset_host unless only_path
+ else
+ url_base << gitlab_config.base_url unless only_path
+ url_base << gitlab_config.relative_url_root
+ end
- [host, avatar.url].join
+ url_base + avatar.url
end
end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index a7fd0a15f0f..d8394415362 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -2,7 +2,7 @@ module Awardable
extend ActiveSupport::Concern
included do
- has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy
+ has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
if self < Participable
# By default we always load award_emoji user association
@@ -11,6 +11,21 @@ module Awardable
end
module ClassMethods
+ def awarded(user, name)
+ sql = <<~EOL
+ EXISTS (
+ SELECT TRUE
+ FROM award_emoji
+ WHERE user_id = :user_id AND
+ name = :name AND
+ awardable_type = :awardable_type AND
+ awardable_id = #{self.arel_table.name}.id
+ )
+ EOL
+
+ where(sql, user_id: user.id, name: name, awardable_type: self.name)
+ end
+
def order_upvotes_desc
order_votes_desc(AwardEmoji::UPVOTE_NAME)
end
@@ -83,6 +98,7 @@ module Awardable
def create_award_emoji(name, current_user)
return unless emoji_awardable?
+
award_emoji.create(name: normalize_name(name), user: current_user)
end
diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb
new file mode 100644
index 00000000000..8019e6adc1c
--- /dev/null
+++ b/app/models/concerns/blocks_json_serialization.rb
@@ -0,0 +1,16 @@
+# Overrides `as_json` and `to_json` to raise an exception when called in order
+# to prevent accidentally exposing attributes
+#
+# Not that that would ever happen... but just in case.
+module BlocksJsonSerialization
+ extend ActiveSupport::Concern
+
+ JsonSerializationError = Class.new(StandardError)
+
+ def to_json(*)
+ raise JsonSerializationError,
+ "JSON serialization has been disabled on #{self.class.name}"
+ end
+
+ alias_method :as_json, :to_json
+end
diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb
new file mode 100644
index 00000000000..984c4f53bf7
--- /dev/null
+++ b/app/models/concerns/bulk_member_access_load.rb
@@ -0,0 +1,46 @@
+# Returns and caches in thread max member access for a resource
+#
+module BulkMemberAccessLoad
+ extend ActiveSupport::Concern
+
+ included do
+ # Determine the maximum access level for a group of resources in bulk.
+ #
+ # Returns a Hash mapping resource ID -> maximum access level.
+ def max_member_access_for_resource_ids(resource_klass, resource_ids, memoization_index = self.id, &block)
+ raise 'Block is mandatory' unless block_given?
+
+ resource_ids = resource_ids.uniq
+ key = max_member_access_for_resource_key(resource_klass, memoization_index)
+ access = {}
+
+ if RequestStore.active?
+ RequestStore.store[key] ||= {}
+ access = RequestStore.store[key]
+ end
+
+ # Look up only the IDs we need
+ resource_ids = resource_ids - access.keys
+
+ return access if resource_ids.empty?
+
+ resource_access = yield(resource_ids)
+
+ access.merge!(resource_access)
+
+ missing_resource_ids = resource_ids - resource_access.keys
+
+ missing_resource_ids.each do |resource_id|
+ access[resource_id] = Gitlab::Access::NO_ACCESS
+ end
+
+ access
+ end
+
+ private
+
+ def max_member_access_for_resource_key(klass, memoization_index)
+ "max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}"
+ end
+ end
+end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index eb32bf3d32a..4ae5dd8c677 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -11,7 +11,7 @@ module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
- CACHE_VERSION = 1
+ CACHE_VERSION = 3
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
@@ -49,7 +49,8 @@ module CacheMarkdownField
# Always include a project key, or Banzai complains
project = self.project if self.respond_to?(:project)
- context = cached_markdown_fields[field].merge(project: project)
+ group = self.group if self.respond_to?(:group)
+ context = cached_markdown_fields[field].merge(project: project, group: group)
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
@@ -59,7 +60,7 @@ module CacheMarkdownField
# Update every column in a row if any one is invalidated, as we only store
# one version per row
- def refresh_markdown_cache!(do_update: false)
+ def refresh_markdown_cache
options = { skip_project_check: skip_project_check? }
updates = cached_markdown_fields.markdown_fields.map do |markdown_field|
@@ -71,15 +72,20 @@ module CacheMarkdownField
updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION
updates.each {|html_field, data| write_attribute(html_field, data) }
+ end
+
+ def refresh_markdown_cache!
+ updates = refresh_markdown_cache
+
+ return unless persisted? && Gitlab::Database.read_write?
- update_columns(updates) if persisted? && do_update
+ update_columns(updates)
end
def cached_html_up_to_date?(markdown_field)
html_field = cached_markdown_fields.html_field(markdown_field)
- cached = !cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil?
- return false unless cached
+ return false if cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? # rubocop:disable GitlabSecurity/PublicSend
markdown_changed = attribute_changed?(markdown_field) || false
html_changed = attribute_changed?(html_field) || false
@@ -93,14 +99,14 @@ module CacheMarkdownField
end
def attribute_invalidated?(attr)
- __send__("#{attr}_invalidated?")
+ __send__("#{attr}_invalidated?") # rubocop:disable GitlabSecurity/PublicSend
end
def cached_html_for(markdown_field)
raise ArgumentError.new("Unknown field: #{field}") unless
cached_markdown_fields.markdown_fields.include?(markdown_field)
- __send__(cached_markdown_fields.html_field(markdown_field))
+ __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end
included do
@@ -124,8 +130,8 @@ module CacheMarkdownField
end
# Using before_update here conflicts with elasticsearch-model somehow
- before_create :refresh_markdown_cache!, if: :invalidated_markdown_cache?
- before_update :refresh_markdown_cache!, if: :invalidated_markdown_cache?
+ before_create :refresh_markdown_cache, if: :invalidated_markdown_cache?
+ before_update :refresh_markdown_cache, if: :invalidated_markdown_cache?
end
class_methods do
diff --git a/app/models/concerns/created_at_filterable.rb b/app/models/concerns/created_at_filterable.rb
new file mode 100644
index 00000000000..e8a3e41203d
--- /dev/null
+++ b/app/models/concerns/created_at_filterable.rb
@@ -0,0 +1,12 @@
+module CreatedAtFilterable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :created_before, ->(date) { where(scoped_table[:created_at].lteq(date)) }
+ scope :created_after, ->(date) { where(scoped_table[:created_at].gteq(date)) }
+
+ def self.scoped_table
+ arel_table.alias(table_name)
+ end
+ end
+end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
new file mode 100644
index 00000000000..faa94204e33
--- /dev/null
+++ b/app/models/concerns/deployment_platform.rb
@@ -0,0 +1,49 @@
+module DeploymentPlatform
+ # EE would override this and utilize the extra argument
+ def deployment_platform(environment: nil)
+ @deployment_platform ||=
+ find_cluster_platform_kubernetes ||
+ find_kubernetes_service_integration ||
+ build_cluster_and_deployment_platform
+ end
+
+ private
+
+ def find_cluster_platform_kubernetes
+ clusters.find_by(enabled: true)&.platform_kubernetes
+ end
+
+ def find_kubernetes_service_integration
+ services.deployment.reorder(nil).find_by(active: true)
+ end
+
+ def build_cluster_and_deployment_platform
+ return unless kubernetes_service_template
+
+ cluster = ::Clusters::Cluster.create(cluster_attributes_from_service_template)
+ cluster.platform_kubernetes if cluster.persisted?
+ end
+
+ def kubernetes_service_template
+ @kubernetes_service_template ||= KubernetesService.active.find_by_template
+ end
+
+ def cluster_attributes_from_service_template
+ {
+ name: 'kubernetes-template',
+ projects: [self],
+ provider_type: :user,
+ platform_type: :kubernetes,
+ platform_kubernetes_attributes: platform_kubernetes_attributes_from_service_template
+ }
+ end
+
+ def platform_kubernetes_attributes_from_service_template
+ {
+ api_url: kubernetes_service_template.api_url,
+ ca_pem: kubernetes_service_template.ca_pem,
+ token: kubernetes_service_template.token,
+ namespace: kubernetes_service_template.namespace
+ }
+ end
+end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index eee1a36ac6b..8b3c55387b3 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -9,7 +9,6 @@ module DiscussionOnDiff
:original_line_code,
:diff_file,
:diff_line,
- :for_line?,
:active?,
:created_at_diff?,
@@ -28,20 +27,29 @@ module DiscussionOnDiff
true
end
+ def file_new_path
+ first_note.position.new_path
+ end
+
+ def on_merge_request_commit?
+ for_merge_request? && commit_id.present?
+ end
+
# Returns an array of at most 16 highlighted lines above a diff note
def truncated_diff_lines(highlight: true)
+ return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote)
+
lines = highlight ? highlighted_diff_lines : diff_lines
+
+ initial_line_index = [diff_line.index - NUMBER_OF_TRUNCATED_DIFF_LINES + 1, 0].max
+
prev_lines = []
- lines.each do |line|
+ lines[initial_line_index..diff_line.index].each do |line|
if line.meta?
prev_lines.clear
else
prev_lines << line
-
- break if for_line?(line)
-
- prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES
end
end
diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb
new file mode 100644
index 00000000000..6ddbb8da1a9
--- /dev/null
+++ b/app/models/concerns/each_batch.rb
@@ -0,0 +1,81 @@
+module EachBatch
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ # Iterates over the rows in a relation in batches, similar to Rails'
+ # `in_batches` but in a more efficient way.
+ #
+ # Unlike `in_batches` provided by Rails this method does not support a
+ # custom start/end range, nor does it provide support for the `load:`
+ # keyword argument.
+ #
+ # This method will yield an ActiveRecord::Relation to the supplied block, or
+ # return an Enumerator if no block is given.
+ #
+ # Example:
+ #
+ # User.each_batch do |relation|
+ # relation.update_all(updated_at: Time.now)
+ # end
+ #
+ # The supplied block is also passed an optional batch index:
+ #
+ # User.each_batch do |relation, index|
+ # puts index # => 1, 2, 3, ...
+ # end
+ #
+ # You can also specify an alternative column to use for ordering the rows:
+ #
+ # User.each_batch(column: :created_at) do |relation|
+ # ...
+ # end
+ #
+ # This will produce SQL queries along the lines of:
+ #
+ # User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
+ # (0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)
+ #
+ # of - The number of rows to retrieve per batch.
+ # column - The column to use for ordering the batches.
+ def each_batch(of: 1000, column: primary_key)
+ unless column
+ raise ArgumentError,
+ 'the column: argument must be set to a column name to use for ordering rows'
+ end
+
+ start = except(:select)
+ .select(column)
+ .reorder(column => :asc)
+ .take
+
+ return unless start
+
+ start_id = start[column]
+ arel_table = self.arel_table
+
+ 1.step do |index|
+ stop = except(:select)
+ .select(column)
+ .where(arel_table[column].gteq(start_id))
+ .reorder(column => :asc)
+ .offset(of)
+ .limit(1)
+ .take
+
+ relation = where(arel_table[column].gteq(start_id))
+
+ if stop
+ stop_id = stop[column]
+ start_id = stop_id
+ relation = relation.where(arel_table[column].lt(stop_id))
+ end
+
+ # Any ORDER BYs are useless for this relation and can lead to less
+ # efficient UPDATE queries, hence we get rid of it.
+ yield relation.except(:order), index
+
+ break unless stop
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb
index c62c7e1e936..c0a3099f676 100644
--- a/app/models/concerns/editable.rb
+++ b/app/models/concerns/editable.rb
@@ -1,7 +1,11 @@
module Editable
extend ActiveSupport::Concern
- def is_edited?
+ def edited?
last_edited_at.present? && last_edited_at != created_at
end
+
+ def last_edited_by
+ super || User.ghost
+ end
end
diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb
new file mode 100644
index 00000000000..5db64fe82c4
--- /dev/null
+++ b/app/models/concerns/feature_gate.rb
@@ -0,0 +1,7 @@
+module FeatureGate
+ def flipper_id
+ return nil if new_record?
+
+ "#{self.class.name}:#{id}"
+ end
+end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
new file mode 100644
index 00000000000..01957da0bf3
--- /dev/null
+++ b/app/models/concerns/group_descendant.rb
@@ -0,0 +1,56 @@
+module GroupDescendant
+ # Returns the hierarchy of a project or group in the from of a hash upto a
+ # given top.
+ #
+ # > project.hierarchy
+ # => { parent_group => { child_group => project } }
+ def hierarchy(hierarchy_top = nil, preloaded = nil)
+ preloaded ||= ancestors_upto(hierarchy_top)
+ expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
+ end
+
+ # Merges all hierarchies of the given groups or projects into an array of
+ # hashes. All ancestors need to be loaded into the given `descendants` to avoid
+ # queries down the line.
+ #
+ # > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent])
+ # => { parent => [{ child_group => project}, child_group2] }
+ def self.build_hierarchy(descendants, hierarchy_top = nil)
+ descendants = Array.wrap(descendants).uniq
+ return [] if descendants.empty?
+
+ unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
+ raise ArgumentError.new('element is not a hierarchy')
+ end
+
+ all_hierarchies = descendants.map do |descendant|
+ descendant.hierarchy(hierarchy_top, descendants)
+ end
+
+ Gitlab::Utils::MergeHash.merge(all_hierarchies)
+ end
+
+ private
+
+ def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded)
+ parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id
+ parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
+
+ if parent.nil? && !child.parent_id.nil?
+ raise ArgumentError.new('parent was not preloaded')
+ end
+
+ if parent.nil? && hierarchy_top.present?
+ raise ArgumentError.new('specified top is not part of the tree')
+ end
+
+ if parent && parent != hierarchy_top
+ expand_hierarchy_for_child(parent,
+ { parent => hierarchy },
+ hierarchy_top,
+ preloaded)
+ else
+ hierarchy
+ end
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index 32af5566135..7c3ed96bc28 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -8,6 +8,8 @@ module HasStatus
ACTIVE_STATUSES = %w[pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
ORDERED_STATUSES = %w[failed pending running manual canceled success skipped created].freeze
+ STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
+ failed: 4, canceled: 5, skipped: 6, manual: 7 }.freeze
class_methods do
def status_sql
@@ -79,6 +81,7 @@ module HasStatus
scope :canceled, -> { where(status: 'canceled') }
scope :skipped, -> { where(status: 'skipped') }
scope :manual, -> { where(status: 'manual') }
+ scope :alive, -> { where(status: [:created, :pending, :running]) }
scope :created_or_pending, -> { where(status: [:created, :pending]) }
scope :running_or_pending, -> { where(status: [:running, :pending]) }
scope :finished, -> { where(status: [:success, :failed, :canceled]) }
diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb
new file mode 100644
index 00000000000..8a241e4374a
--- /dev/null
+++ b/app/models/concerns/has_variable.rb
@@ -0,0 +1,27 @@
+module HasVariable
+ extend ActiveSupport::Concern
+
+ included do
+ validates :key,
+ presence: true,
+ length: { maximum: 255 },
+ format: { with: /\A[a-zA-Z0-9_]+\z/,
+ message: "can contain only letters, digits and '_'." }
+
+ scope :order_key_asc, -> { reorder(key: :asc) }
+
+ attr_encrypted :value,
+ mode: :per_attribute_iv_and_salt,
+ insecure_mode: true,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ def key=(new_key)
+ super(new_key.to_s.strip)
+ end
+
+ def to_runner_variable
+ { key: key, value: value, public: false }
+ end
+ end
+end
diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb
index eb9f3423e48..03793e8bcbb 100644
--- a/app/models/concerns/ignorable_column.rb
+++ b/app/models/concerns/ignorable_column.rb
@@ -21,8 +21,8 @@ module IgnorableColumn
@ignored_columns ||= Set.new
end
- def ignore_column(name)
- ignored_columns << name.to_s
+ def ignore_column(*names)
+ ignored_columns.merge(names.map(&:to_s))
end
end
end
diff --git a/app/models/concerns/internal_id.rb b/app/models/concerns/internal_id.rb
deleted file mode 100644
index 5382dde6765..00000000000
--- a/app/models/concerns/internal_id.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module InternalId
- extend ActiveSupport::Concern
-
- included do
- validate :set_iid, on: :create
- validates :iid, presence: true, numericality: true
- end
-
- def set_iid
- if iid.blank?
- records = project.send(self.class.name.tableize)
- records = records.with_deleted if self.paranoid?
- max_iid = records.maximum(:iid)
-
- self.iid = max_iid.to_i + 1
- end
- end
-
- def to_param
- iid.to_s
- end
-end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index d178ee4422b..5a566f3ac02 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -6,6 +6,7 @@
#
module Issuable
extend ActiveSupport::Concern
+ include Gitlab::SQL::Pattern
include CacheMarkdownField
include Participable
include Mentionable
@@ -13,9 +14,12 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
- include TimeTrackable
include Importable
include Editable
+ include AfterCommitQueue
+ include Sortable
+ include CreatedAtFilterable
+ include UpdatedAtFilterable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
@@ -30,7 +34,8 @@ module Issuable
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
- has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
+
+ has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
def authors_loaded?
# We check first if we're loaded to not load unnecessarily.
loaded? && to_a.all? { |note| note.association(:author).loaded? }
@@ -42,9 +47,9 @@ module Issuable
end
end
- has_many :label_links, as: :target, dependent: :destroy
+ has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, through: :label_links
- has_many :todos, as: :target, dependent: :destroy
+ has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :metrics
@@ -70,9 +75,8 @@ module Issuable
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
- scope :opened, -> { with_state(:opened, :reopened) }
+ scope :opened, -> { with_state(:opened) }
scope :only_opened, -> { with_state(:opened) }
- scope :only_reopened, -> { with_state(:reopened) }
scope :closed, -> { with_state(:closed) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
@@ -93,15 +97,21 @@ module Issuable
strip_attributes :title
- acts_as_paranoid
-
- after_save :record_metrics, unless: :imported?
+ after_save :ensure_metrics, unless: :imported?
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
title_changed? || description_changed?
end
+
+ def allows_multiple_assignees?
+ false
+ end
+
+ def has_multiple_assignees?
+ assignees.count > 1
+ end
end
module ClassMethods
@@ -113,7 +123,7 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def search(query)
- where(arel_table[:title].matches("%#{query}%"))
+ fuzzy_search(query, [:title])
end
# Searches for records with a matching title or description.
@@ -124,23 +134,22 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def full_search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
+ fuzzy_search(query, [:title, :description])
end
def sort(method, excluded_labels: [])
- sorted = case method.to_s
- when 'milestone_due_asc' then order_milestone_due_asc
- when 'milestone_due_desc' then order_milestone_due_desc
- when 'downvotes_desc' then order_downvotes_desc
- when 'upvotes_desc' then order_upvotes_desc
- when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels)
- when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
- else
- order_by(method)
- end
+ sorted =
+ case method.to_s
+ when 'downvotes_desc' then order_downvotes_desc
+ when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels)
+ when 'milestone' then order_milestone_due_asc
+ when 'milestone_due_asc' then order_milestone_due_asc
+ when 'milestone_due_desc' then order_milestone_due_desc
+ when 'popularity' then order_upvotes_desc
+ when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
+ when 'upvotes_desc' then order_upvotes_desc
+ else order_by(method)
+ end
# Break ties with the ID column for pagination
sorted.order(id: :desc)
@@ -202,7 +211,7 @@ module Issuable
def grouping_columns(sort)
grouping_columns = [arel_table[:id]]
- if %w(milestone_due_desc milestone_due_asc).include?(sort)
+ if %w(milestone_due_desc milestone_due_asc milestone).include?(sort)
milestone_table = Milestone.arel_table
grouping_columns << milestone_table[:id]
grouping_columns << milestone_table[:due_date]
@@ -214,6 +223,10 @@ module Issuable
def to_ability_name
model_name.singular
end
+
+ def parent_class
+ ::Project
+ end
end
def today?
@@ -225,7 +238,7 @@ module Issuable
end
def open?
- opened? || reopened?
+ opened?
end
def user_notes_count
@@ -242,23 +255,32 @@ module Issuable
participants(user).include?(user)
end
- def to_hook_data(user)
- hook_data = {
- object_kind: self.class.name.underscore,
- user: user.hook_attrs,
- project: project.hook_attrs,
- object_attributes: hook_attrs,
- labels: labels.map(&:hook_attrs),
- # DEPRECATED
- repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
- }
- if self.is_a?(Issue)
- hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any?
- else
- hook_data[:assignee] = assignee.hook_attrs if assignee
+ def to_hook_data(user, old_associations: {})
+ changes = previous_changes
+ old_labels = old_associations.fetch(:labels, [])
+ old_assignees = old_associations.fetch(:assignees, [])
+
+ if old_labels != labels
+ changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
+ end
+
+ if old_assignees != assignees
+ if self.is_a?(Issue)
+ changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
+ else
+ changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
+ end
+ end
+
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ end
end
- hook_data
+ Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
end
def labels_array
@@ -297,6 +319,7 @@ module Issuable
includes = []
includes << :author unless notes.authors_loaded?
includes << :award_emoji unless notes.award_emojis_loaded?
+
if includes.any?
notes.includes(includes)
else
@@ -318,8 +341,21 @@ module Issuable
false
end
- def record_metrics
- metrics = self.metrics || create_metrics
- metrics.record!
+ ##
+ # Override in issuable specialization
+ #
+ def first_contribution?
+ false
+ end
+
+ def ensure_metrics
+ self.metrics || create_metrics
+ end
+
+ ##
+ # Overriden in MergeRequest
+ #
+ def wipless_title_changed(old_title)
+ old_title != title
end
end
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
new file mode 100644
index 00000000000..935e9d10133
--- /dev/null
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -0,0 +1,73 @@
+module LoadedInGroupList
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def with_counts(archived:)
+ selects_including_counts = [
+ 'namespaces.*',
+ "(#{project_count_sql(archived).to_sql}) AS preloaded_project_count",
+ "(#{member_count_sql.to_sql}) AS preloaded_member_count",
+ "(#{subgroup_count_sql.to_sql}) AS preloaded_subgroup_count"
+ ]
+
+ select(selects_including_counts)
+ end
+
+ def with_selects_for_list(archived: nil)
+ with_route.with_counts(archived: archived)
+ end
+
+ private
+
+ def project_count_sql(archived = nil)
+ projects = Project.arel_table
+ namespaces = Namespace.arel_table
+
+ base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
+ .where(projects[:namespace_id].eq(namespaces[:id]))
+
+ if archived == 'only'
+ base_count.where(projects[:archived].eq(true))
+ elsif Gitlab::Utils.to_boolean(archived)
+ base_count
+ else
+ base_count.where(projects[:archived].not_eq(true))
+ end
+ end
+
+ def subgroup_count_sql
+ namespaces = Namespace.arel_table
+ children = namespaces.alias('children')
+
+ namespaces.project(Arel.star.count.as('preloaded_subgroup_count'))
+ .from(children)
+ .where(children[:parent_id].eq(namespaces[:id]))
+ end
+
+ def member_count_sql
+ members = Member.arel_table
+ namespaces = Namespace.arel_table
+
+ members.project(Arel.star.count.as('preloaded_member_count'))
+ .where(members[:source_type].eq(Namespace.name))
+ .where(members[:source_id].eq(namespaces[:id]))
+ .where(members[:requested_at].eq(nil))
+ end
+ end
+
+ def children_count
+ @children_count ||= project_count + subgroup_count
+ end
+
+ def project_count
+ @project_count ||= try(:preloaded_project_count) || projects.non_archived.count
+ end
+
+ def subgroup_count
+ @subgroup_count ||= try(:preloaded_subgroup_count) || children.count
+ end
+
+ def member_count
+ @member_count ||= try(:preloaded_member_count) || users.count
+ end
+end
diff --git a/app/models/concerns/manual_inverse_association.rb b/app/models/concerns/manual_inverse_association.rb
new file mode 100644
index 00000000000..0fca8feaf89
--- /dev/null
+++ b/app/models/concerns/manual_inverse_association.rb
@@ -0,0 +1,17 @@
+module ManualInverseAssociation
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def manual_inverse_association(association, inverse)
+ define_method(association) do |*args|
+ super(*args).tap do |value|
+ next unless value
+
+ child_association = value.association(inverse)
+ child_association.set_inverse_instance(self)
+ child_association.target = self
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index c034bf9cbc0..c013e5a708f 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -31,11 +31,11 @@ module Mentionable
#
# By default this will be the class name and the result of calling
# `to_reference` on the object.
- def gfm_reference(from_project = nil)
+ def gfm_reference(from = nil)
# "MergeRequest" > "merge_request" > "Merge request" > "merge request"
friendly_name = self.class.to_s.underscore.humanize.downcase
- "#{friendly_name} #{to_reference(from_project)}"
+ "#{friendly_name} #{to_reference(from)}"
end
# The GFM reference to this Mentionable, which shouldn't be included in its #references.
@@ -44,19 +44,17 @@ module Mentionable
end
def all_references(current_user = nil, extractor: nil)
- @extractors ||= {}
-
# Use custom extractor if it's passed in the function parameters.
if extractor
- @extractors[current_user] = extractor
+ extractors[current_user] = extractor
else
- extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user)
+ extractor = extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user)
extractor.reset_memoized_values
end
self.class.mentionable_attrs.each do |attr, options|
- text = __send__(attr)
+ text = __send__(attr) # rubocop:disable GitlabSecurity/PublicSend
options = options.merge(
cache_key: [self, attr],
author: author,
@@ -69,6 +67,10 @@ module Mentionable
extractor
end
+ def extractors
+ @extractors ||= {}
+ end
+
def mentioned_users(current_user = nil)
all_references(current_user).users
end
@@ -100,7 +102,7 @@ module Mentionable
end
self.class.mentionable_attrs.any? do |attr, _|
- __send__(attr) =~ reference_pattern
+ __send__(attr) =~ reference_pattern # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb
index 1848230ec7e..2d86a70c395 100644
--- a/app/models/concerns/mentionable/reference_regexes.rb
+++ b/app/models/concerns/mentionable/reference_regexes.rb
@@ -14,7 +14,7 @@ module Mentionable
end
EXTERNAL_PATTERN = begin
- issue_pattern = ExternalIssue.reference_pattern
+ issue_pattern = IssueTrackerService.reference_pattern
link_patterns = URI.regexp(%w(http https))
reference_pattern(link_patterns, issue_pattern)
end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 01599ce49c6..caf8afa97f9 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -70,6 +70,38 @@ module Milestoneish
due_date && due_date.past?
end
+ def group_milestone?
+ false
+ end
+
+ def project_milestone?
+ false
+ end
+
+ def legacy_group_milestone?
+ false
+ end
+
+ def dashboard_milestone?
+ false
+ end
+
+ def total_issue_time_spent
+ @total_issue_time_spent ||= issues.joins(:timelogs).sum(:time_spent)
+ end
+
+ def human_total_issue_time_spent
+ Gitlab::TimeTrackingFormatter.output(total_issue_time_spent)
+ end
+
+ def total_issue_time_estimate
+ @total_issue_time_estimate ||= issues.sum(:time_estimate)
+ end
+
+ def human_total_issue_time_estimate
+ Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate)
+ end
+
private
def count_issues_by_state(user)
@@ -79,9 +111,11 @@ module Milestoneish
end
def memoize_per_user(user, method_name)
- @memoized ||= {}
- @memoized[method_name] ||= {}
- @memoized[method_name][user&.id] ||= yield
+ memoized_users[method_name][user&.id] ||= yield
+ end
+
+ def memoized_users
+ @memoized_users ||= Hash.new { |h, k| h[k] = {} }
end
# override in a class that includes this module to get a faster query
diff --git a/app/models/concerns/nonatomic_internal_id.rb b/app/models/concerns/nonatomic_internal_id.rb
new file mode 100644
index 00000000000..9d0c9b8512f
--- /dev/null
+++ b/app/models/concerns/nonatomic_internal_id.rb
@@ -0,0 +1,22 @@
+module NonatomicInternalId
+ extend ActiveSupport::Concern
+
+ included do
+ validate :set_iid, on: :create
+ validates :iid, presence: true, numericality: true
+ end
+
+ def set_iid
+ if iid.blank?
+ parent = project || group
+ records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend
+ max_iid = records.maximum(:iid)
+
+ self.iid = max_iid.to_i + 1
+ end
+ end
+
+ def to_param
+ iid.to_s
+ end
+end
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index f734952fa6c..510b8868462 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -14,10 +14,6 @@ module NoteOnDiff
raise NotImplementedError
end
- def for_line?(line)
- raise NotImplementedError
- end
-
def original_line_code
raise NotImplementedError
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index c7bdc997eca..86f28f30032 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -24,6 +24,10 @@ module Noteable
DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
end
+ def discussions_rendered_on_frontend?
+ false
+ end
+
def discussion_notes
notes
end
@@ -38,10 +42,11 @@ module Noteable
def grouped_diff_discussions(*args)
# Doesn't use `discussion_notes`, because this may include commit diff notes
- # besides MR diff notes, that we do no want to display on the MR Changes tab.
+ # besides MR diff notes, that we do not want to display on the MR Changes tab.
notes.inc_relations_for_view.grouped_diff_discussions(*args)
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def resolvable_discussions
@resolvable_discussions ||=
if defined?(@discussions)
@@ -50,6 +55,7 @@ module Noteable
discussion_notes.resolvable.discussions(self)
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def discussions_resolvable?
resolvable_discussions.any?(&:resolvable?)
@@ -70,4 +76,8 @@ module Noteable
def discussions_can_be_resolved_by?(user)
discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) }
end
+
+ def lockable?
+ [MergeRequest, Issue].include?(self.class)
+ end
end
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index 4865c0a14b1..e48bc0be410 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -56,15 +56,17 @@ module Participable
#
# Returns an Array of User instances.
def participants(current_user = nil)
- @participants ||= Hash.new do |hash, user|
- hash[user] = raw_participants(user)
- end
-
- @participants[current_user]
+ all_participants[current_user]
end
private
+ def all_participants
+ @all_participants ||= Hash.new do |hash, user|
+ hash[user] = raw_participants(user)
+ end
+ end
+
def raw_participants(current_user = nil)
current_user ||= author
ext = Gitlab::ReferenceExtractor.new(project, current_user)
@@ -82,7 +84,7 @@ module Participable
if attr.respond_to?(:call)
source.instance_exec(current_user, ext, &attr)
else
- process << source.__send__(attr)
+ process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend
end
end
when Enumerable, ActiveRecord::Relation
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 60734bc6660..b3fec99c816 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -2,6 +2,7 @@
#
# After migrating issues_enabled merge_requests_enabled builds_enabled snippets_enabled and wiki_enabled
# fields to a new table "project_features", support for the old fields is still needed in the API.
+require 'gitlab/utils'
module ProjectFeaturesCompatibility
extend ActiveSupport::Concern
@@ -32,6 +33,6 @@ module ProjectFeaturesCompatibility
build_project_feature unless project_feature
access_level = Gitlab::Utils.to_boolean(value) ? ProjectFeature::ENABLED : ProjectFeature::DISABLED
- project_feature.send(:write_attribute, field, access_level)
+ project_feature.__send__(:write_attribute, field, access_level) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
new file mode 100644
index 00000000000..18cbbd871a1
--- /dev/null
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -0,0 +1,48 @@
+module PrometheusAdapter
+ extend ActiveSupport::Concern
+
+ included do
+ include ReactiveCaching
+
+ self.reactive_cache_key = ->(adapter) { [adapter.class.model_name.singular, adapter.id] }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_lifetime = 1.minute
+
+ def prometheus_client
+ raise NotImplementedError
+ end
+
+ def prometheus_client_wrapper
+ Gitlab::PrometheusClient.new(prometheus_client)
+ end
+
+ def can_query?
+ prometheus_client.present?
+ end
+
+ def query(query_name, *args)
+ return unless can_query?
+
+ query_class = Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query")
+
+ args.map!(&:id)
+
+ with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result))
+ end
+
+ # Cache metrics for specific environment
+ def calculate_reactive_cache(query_class_name, *args)
+ return unless prometheus_client
+
+ data = Kernel.const_get(query_class_name).new(prometheus_client_wrapper).query(*args)
+ {
+ success: true,
+ data: data,
+ last_update: Time.now.utc
+ }
+ rescue Gitlab::PrometheusClient::Error => err
+ { success: false, result: err.message }
+ end
+ end
+end
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
index a40148a4394..e62f42e8e70 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -8,22 +8,6 @@ module ProtectedBranchAccess
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
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 47e71c58557..454374121f3 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -17,7 +17,13 @@ module ProtectedRef
class_methods do
def protected_ref_access_levels(*types)
types.each do |type|
- has_many :"#{type}_access_levels", dependent: :destroy
+ # We need to set `inverse_of` to make sure the `belongs_to`-object is set
+ # when creating children using `accepts_nested_attributes_for`.
+ #
+ # If we don't `protected_branch` or `protected_tag` would be empty and
+ # `project` cannot be delegated to it, which in turn would cause validations
+ # to fail.
+ has_many :"#{type}_access_levels", inverse_of: self.model_name.singular # rubocop:disable Cop/ActiveRecordDependent
validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }
@@ -25,8 +31,8 @@ module ProtectedRef
end
end
- def protected_ref_accessible_to?(ref, user, action:)
- access_levels_for_ref(ref, action: action).any? do |access_level|
+ def protected_ref_accessible_to?(ref, user, action:, protected_refs: nil)
+ access_levels_for_ref(ref, action: action, protected_refs: protected_refs).any? do |access_level|
access_level.check_access(user)
end
end
@@ -37,8 +43,9 @@ module ProtectedRef
end
end
- def access_levels_for_ref(ref, action:)
- self.matching(ref).map(&:"#{action}_access_levels").flatten
+ def access_levels_for_ref(ref, action:, protected_refs: nil)
+ self.matching(ref, protected_refs: protected_refs)
+ .map(&:"#{action}_access_levels").flatten
end
def matching(ref_name, protected_refs: nil)
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index c4f158e569a..bfda5b1678b 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -1,18 +1,41 @@
module ProtectedRefAccess
extend ActiveSupport::Concern
+ ALLOWED_ACCESS_LEVELS = [
+ Gitlab::Access::MASTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::NO_ACCESS
+ ].freeze
+
+ HUMAN_ACCESS_LEVELS = {
+ Gitlab::Access::MASTER => "Masters".freeze,
+ Gitlab::Access::DEVELOPER => "Developers + Masters".freeze,
+ Gitlab::Access::NO_ACCESS => "No one".freeze
+ }.freeze
+
included do
scope :master, -> { where(access_level: Gitlab::Access::MASTER) }
scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
+
+ validates :access_level, presence: true, if: :role?, inclusion: {
+ in: ALLOWED_ACCESS_LEVELS
+ }
end
def humanize
- self.class.human_access_levels[self.access_level]
+ HUMAN_ACCESS_LEVELS[self.access_level]
+ end
+
+ # CE access levels are always role-based,
+ # where as EE allows groups and users too
+ def role?
+ true
end
def check_access(user)
return true if user.admin?
- project.team.max_member_access(user.id) >= access_level
+ user.can?(:push_code, project) &&
+ project.team.max_member_access(user.id) >= access_level
end
end
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
new file mode 100644
index 00000000000..b889f4202dc
--- /dev/null
+++ b/app/models/concerns/redis_cacheable.rb
@@ -0,0 +1,41 @@
+module RedisCacheable
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ CACHED_ATTRIBUTES_EXPIRY_TIME = 24.hours
+
+ class_methods do
+ def cached_attr_reader(*attributes)
+ attributes.each do |attribute|
+ define_method("#{attribute}") do
+ cached_attribute(attribute) || read_attribute(attribute)
+ end
+ end
+ end
+ end
+
+ def cached_attribute(attribute)
+ (cached_attributes || {})[attribute]
+ end
+
+ def cache_attributes(values)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
+ end
+ end
+
+ private
+
+ def cache_attribute_key
+ "cache:#{self.class.name}:#{self.id}:attributes"
+ end
+
+ def cached_attributes
+ strong_memoize(:cached_attributes) do
+ Gitlab::Redis::SharedState.with do |redis|
+ data = redis.get(cache_attribute_key)
+ JSON.parse(data, symbolize_names: true) if data
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index da803c7f481..b782e85717e 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -7,7 +7,7 @@ module Referable
# Returns the String necessary to reference this object in Markdown
#
- # from_project - Refering Project object
+ # from - Referring parent object
#
# This should be overridden by the including class.
#
@@ -17,12 +17,25 @@ module Referable
# Issue.last.to_reference(other_project) # => "cross-project#1"
#
# Returns a String
- def to_reference(_from_project = nil, full:)
+ def to_reference(_from = nil, full:)
''
end
- def reference_link_text(from_project = nil)
- to_reference(from_project)
+ def reference_link_text(from = nil)
+ to_reference(from)
+ end
+
+ included do
+ alias_method :non_referable_inspect, :inspect
+ alias_method :inspect, :referable_inspect
+ end
+
+ def referable_inspect
+ if respond_to?(:id)
+ "#<#{self.class.name} id:#{id} #{to_reference(full: true)}>"
+ else
+ "#<#{self.class.name} #{to_reference(full: true)}>"
+ end
end
module ClassMethods
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 7cb9a28a284..afacdb8cb12 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -10,8 +10,12 @@ module RelativePositioning
after_save :save_positionable_neighbours
end
+ def min_relative_position
+ self.class.in_parents(parent_ids).minimum(:relative_position)
+ end
+
def max_relative_position
- self.class.in_projects(project.id).maximum(:relative_position)
+ self.class.in_parents(parent_ids).maximum(:relative_position)
end
def prev_relative_position
@@ -19,7 +23,7 @@ module RelativePositioning
if self.relative_position
prev_pos = self.class
- .in_projects(project.id)
+ .in_parents(parent_ids)
.where('relative_position < ?', self.relative_position)
.maximum(:relative_position)
end
@@ -32,7 +36,7 @@ module RelativePositioning
if self.relative_position
next_pos = self.class
- .in_projects(project.id)
+ .in_parents(parent_ids)
.where('relative_position > ?', self.relative_position)
.minimum(:relative_position)
end
@@ -48,7 +52,7 @@ module RelativePositioning
# to its predecessor. This process will recursively move all the predecessors until we have a place
if (after.relative_position - before.relative_position) < 2
before.move_before
- @positionable_neighbours = [before]
+ @positionable_neighbours = [before] # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
self.relative_position = position_between(before.relative_position, after.relative_position)
@@ -59,9 +63,9 @@ module RelativePositioning
pos_after = before.next_relative_position
if before.shift_after?
- issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_after)
+ issue_to_move = self.class.in_parents(parent_ids).find_by!(relative_position: pos_after)
issue_to_move.move_after
- @positionable_neighbours = [issue_to_move]
+ @positionable_neighbours = [issue_to_move] # rubocop:disable Gitlab/ModuleWithInstanceVariables
pos_after = issue_to_move.relative_position
end
@@ -74,9 +78,9 @@ module RelativePositioning
pos_before = after.prev_relative_position
if after.shift_before?
- issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_before)
+ issue_to_move = self.class.in_parents(parent_ids).find_by!(relative_position: pos_before)
issue_to_move.move_before
- @positionable_neighbours = [issue_to_move]
+ @positionable_neighbours = [issue_to_move] # rubocop:disable Gitlab/ModuleWithInstanceVariables
pos_before = issue_to_move.relative_position
end
@@ -88,6 +92,10 @@ module RelativePositioning
self.relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION)
end
+ def move_to_start
+ self.relative_position = position_between(min_relative_position || START_POSITION, MIN_POSITION)
+ end
+
# Indicates if there is an issue that should be shifted to free the place
def shift_after?
next_pos = next_relative_position
@@ -128,6 +136,7 @@ module RelativePositioning
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def save_positionable_neighbours
return unless @positionable_neighbours
@@ -136,4 +145,5 @@ module RelativePositioning
status
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/models/concerns/repository_mirroring.rb b/app/models/concerns/repository_mirroring.rb
deleted file mode 100644
index fed336c29d6..00000000000
--- a/app/models/concerns/repository_mirroring.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-module RepositoryMirroring
- def set_remote_as_mirror(name)
- config = raw_repository.rugged.config
-
- # This is used to define repository as equivalent as "git clone --mirror"
- config["remote.#{name}.fetch"] = 'refs/*:refs/*'
- config["remote.#{name}.mirror"] = true
- config["remote.#{name}.prune"] = true
- end
-
- def fetch_mirror(remote, url)
- add_remote(remote, url)
- set_remote_as_mirror(remote)
- fetch_remote(remote, forced: true)
- remove_remote(remote)
- end
-end
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index dd979e7bb17..7c236369793 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -1,5 +1,6 @@
module ResolvableDiscussion
extend ActiveSupport::Concern
+ include ::Gitlab::Utils::StrongMemoize
included do
# A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized.
@@ -24,37 +25,44 @@ module ResolvableDiscussion
delegate :resolved_at,
:resolved_by,
+ :resolved_by_push?,
to: :last_resolved_note,
allow_nil: true
end
def resolvable?
- return @resolvable if @resolvable.present?
-
- @resolvable = potentially_resolvable? && notes.any?(&:resolvable?)
+ strong_memoize(:resolvable) do
+ potentially_resolvable? && notes.any?(&:resolvable?)
+ end
end
def resolved?
- return @resolved if @resolved.present?
-
- @resolved = resolvable? && notes.none?(&:to_be_resolved?)
+ strong_memoize(:resolved) do
+ resolvable? && notes.none?(&:to_be_resolved?)
+ end
end
def first_note
- @first_note ||= notes.first
+ strong_memoize(:first_note) do
+ notes.first
+ end
end
def first_note_to_resolve
return unless resolvable?
- @first_note_to_resolve ||= notes.find(&:to_be_resolved?)
+ strong_memoize(:first_note_to_resolve) do
+ notes.find(&:to_be_resolved?)
+ end
end
def last_resolved_note
return unless resolved?
- @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
+ strong_memoize(:last_resolved_note) do
+ resolved_notes.sort_by(&:resolved_at).last
+ end
end
def resolved_notes
@@ -94,10 +102,10 @@ module ResolvableDiscussion
yield(notes_relation)
# Set the notes array to the updated notes
- @notes = notes_relation.fresh.to_a
+ @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
- self.class.memoized_values.each do |var|
- instance_variable_set(:"@#{var}", nil)
+ self.class.memoized_values.each do |name|
+ clear_memoization(name)
end
end
end
diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb
index 05eb6f86704..668c5a079e3 100644
--- a/app/models/concerns/resolvable_note.rb
+++ b/app/models/concerns/resolvable_note.rb
@@ -51,22 +51,34 @@ module ResolvableNote
end
# If you update this method remember to also update `.resolve!`
- def resolve!(current_user)
- return unless resolvable?
- return if resolved?
+ def resolve_without_save(current_user, resolved_by_push: false)
+ return false unless resolvable?
+ return false if resolved?
self.resolved_at = Time.now
self.resolved_by = current_user
- save!
+ self.resolved_by_push = resolved_by_push
+
+ true
end
# If you update this method remember to also update `.unresolve!`
- def unresolve!
- return unless resolvable?
- return unless resolved?
+ def unresolve_without_save
+ return false unless resolvable?
+ return false unless resolved?
self.resolved_at = nil
self.resolved_by = nil
- save!
+
+ true
+ end
+
+ def resolve!(current_user, resolved_by_push: false)
+ resolve_without_save(current_user, resolved_by_push: resolved_by_push) &&
+ save!
+ end
+
+ def unresolve!
+ unresolve_without_save && save!
end
end
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index ec7796a9dbb..dfd7d94450b 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -4,14 +4,15 @@ module Routable
extend ActiveSupport::Concern
included do
- has_one :route, as: :source, autosave: true, dependent: :destroy
- has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy
+ has_one :route, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- validates_associated :route
validates :route, presence: true
scope :with_route, -> { includes(:route) }
+ after_validation :set_path_errors
+
before_validation do
if full_path_changed? || full_name_changed?
prepare_route
@@ -88,7 +89,7 @@ module Routable
def full_name
if route && route.name.present?
- @full_name ||= route.name
+ @full_name ||= route.name # rubocop:disable Gitlab/ModuleWithInstanceVariables
else
update_route if persisted?
@@ -103,8 +104,16 @@ module Routable
def full_path
return uncached_full_path unless RequestStore.active?
- key = "routable/full_path/#{self.class.name}/#{self.id}"
- RequestStore[key] ||= uncached_full_path
+ RequestStore[full_path_key] ||= uncached_full_path
+ end
+
+ def full_path_components
+ full_path.split('/')
+ end
+
+ def expires_full_path_cache
+ RequestStore.delete(full_path_key) if RequestStore.active?
+ @full_path = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def build_full_path
@@ -117,9 +126,14 @@ module Routable
private
+ def set_path_errors
+ route_path_errors = self.errors.delete(:"route.path")
+ self.errors[:path].concat(route_path_errors) if route_path_errors
+ end
+
def uncached_full_path
if route && route.path.present?
- @full_path ||= route.path
+ @full_path ||= route.path # rubocop:disable Gitlab/ModuleWithInstanceVariables
else
update_route if persisted?
@@ -135,6 +149,10 @@ module Routable
path_changed? || parent_changed?
end
+ def full_path_key
+ @full_path_key ||= "routable/full_path/#{self.class.name}/#{self.id}"
+ end
+
def build_full_name
if parent && name
parent.human_name + ' / ' + name
@@ -144,6 +162,8 @@ module Routable
end
def update_route
+ return if Gitlab::Database.read_only?
+
prepare_route
route.save
end
@@ -152,7 +172,7 @@ module Routable
route || build_route(source: self)
route.path = build_full_path
route.name = build_full_name
- @full_path = nil
- @full_name = nil
+ @full_path = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @full_name = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
end
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
new file mode 100644
index 00000000000..703a72c355c
--- /dev/null
+++ b/app/models/concerns/sha_attribute.rb
@@ -0,0 +1,21 @@
+module ShaAttribute
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def sha_attribute(name)
+ return if ENV['STATIC_VERIFICATION']
+ return unless table_exists?
+
+ column = columns.find { |c| c.name == name.to_s }
+
+ # In case the table doesn't exist we won't be able to find the column,
+ # thus we will only check the type if the column is present.
+ if column && column.type != :binary
+ raise ArgumentError,
+ "sha_attribute #{name.inspect} is invalid since the column type is not :binary"
+ end
+
+ attribute(name, Gitlab::Database::ShaAttribute.new)
+ end
+ end
+end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index a155a064032..cefa5c13c5f 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -6,10 +6,6 @@ module Sortable
extend ActiveSupport::Concern
included do
- # By default all models should be ordered
- # by created_at field starting from newest
- default_scope { order_id_desc }
-
scope :order_id_desc, -> { reorder(id: :desc) }
scope :order_id_asc, -> { reorder(id: :asc) }
scope :order_created_desc, -> { reorder(created_at: :desc) }
@@ -23,14 +19,15 @@ module Sortable
module ClassMethods
def order_by(method)
case method.to_s
- when 'name_asc' then order_name_asc
- when 'name_desc' then order_name_desc
- when 'updated_asc' then order_updated_asc
- when 'updated_desc' then order_updated_desc
- when 'created_asc' then order_created_asc
+ when 'created_asc' then order_created_asc
+ when 'created_date' then order_created_desc
when 'created_desc' then order_created_desc
- when 'id_desc' then order_id_desc
- when 'id_asc' then order_id_asc
+ when 'id_asc' then order_id_asc
+ when 'id_desc' then order_id_desc
+ when 'name_asc' then order_name_asc
+ when 'name_desc' then order_name_desc
+ when 'updated_asc' then order_updated_asc
+ when 'updated_desc' then order_updated_desc
else
all
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 647a6cad3d7..5e4274619c4 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -8,10 +8,11 @@ module Spammable
end
included do
- has_one :user_agent_detail, as: :subject, dependent: :destroy
+ has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
attr_accessor :spam
attr_accessor :spam_log
+ alias_method :spam?, :spam
after_validation :check_for_spam, on: [:create, :update]
@@ -28,16 +29,12 @@ module Spammable
def submittable_as_spam?
if user_agent_detail
- user_agent_detail.submittable? && current_application_settings.akismet_enabled
+ user_agent_detail.submittable? && Gitlab::CurrentSettings.current_application_settings.akismet_enabled
else
false
end
end
- def spam?
- @spam
- end
-
def check_for_spam
error_msg = if Gitlab::Recaptcha.enabled?
"Your #{spammable_entity_type} has been recognized as spam. "\
@@ -58,7 +55,7 @@ module Spammable
options.fetch(:spam_title, false)
end
- public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym) # rubocop:disable GitlabSecurity/PublicSend
end
def spam_description
@@ -66,12 +63,12 @@ module Spammable
options.fetch(:spam_description, false)
end
- public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym) # rubocop:disable GitlabSecurity/PublicSend
end
def spammable_text
result = self.class.spammable_attrs.map do |attr|
- public_send(attr.first)
+ public_send(attr.first) # rubocop:disable GitlabSecurity/PublicSend
end
result.reject(&:blank?).join("\n")
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
new file mode 100644
index 00000000000..f05e606995d
--- /dev/null
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -0,0 +1,115 @@
+module Storage
+ module LegacyNamespace
+ extend ActiveSupport::Concern
+
+ def move_dir
+ if any_project_has_container_registry_tags?
+ raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
+ end
+
+ parent_was = if parent_changed? && parent_id_was.present?
+ Namespace.find(parent_id_was) # raise NotFound early if needed
+ end
+
+ expires_full_path_cache
+
+ move_repositories
+
+ if parent_changed?
+ former_parent_full_path = parent_was&.full_path
+ parent_full_path = parent&.full_path
+ Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
+ Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
+ else
+ Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
+ Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
+ end
+
+ remove_exports!
+
+ # If repositories moved successfully we need to
+ # send update instructions to users.
+ # However we cannot allow rollback since we moved namespace dir
+ # So we basically we mute exceptions in next actions
+ begin
+ send_update_instructions
+ write_projects_repository_config
+
+ true
+ rescue
+ # Returning false does not rollback after_* transaction but gives
+ # us information about failing some of tasks
+ false
+ end
+ end
+
+ # Hooks
+
+ # Save the storage paths before the projects are destroyed to use them on after destroy
+ def prepare_for_destroy
+ old_repository_storage_paths
+ end
+
+ private
+
+ def move_repositories
+ # Move the namespace directory in all storage paths used by member projects
+ repository_storage_paths.each do |repository_storage_path|
+ # Ensure old directory exists before moving it
+ gitlab_shell.add_namespace(repository_storage_path, full_path_was)
+
+ # Ensure new directory exists before moving it (if there's a parent)
+ gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent
+
+ unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path)
+
+ Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}"
+
+ # if we cannot move namespace directory we should rollback
+ # db changes in order to prevent out of sync between db and fs
+ raise Gitlab::UpdatePathError.new('namespace directory cannot be moved')
+ end
+ end
+ end
+
+ def old_repository_storage_paths
+ @old_repository_storage_paths ||= repository_storage_paths
+ end
+
+ def repository_storage_paths
+ # We need to get the storage paths for all the projects, even the ones that are
+ # pending delete. Unscoping also get rids of the default order, which causes
+ # problems with SELECT DISTINCT.
+ Project.unscoped do
+ all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path)
+ end
+ end
+
+ def rm_dir
+ # Remove the namespace directory in all storages paths used by member projects
+ old_repository_storage_paths.each do |repository_storage_path|
+ # Move namespace directory into trash.
+ # We will remove it later async
+ new_path = "#{full_path}+#{id}+deleted"
+
+ if gitlab_shell.mv_namespace(repository_storage_path, full_path, new_path)
+ Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}")
+
+ # Remove namespace directroy async with delay so
+ # GitLab has time to remove all projects first
+ run_after_commit do
+ GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ end
+ end
+ end
+
+ remove_exports!
+ end
+
+ def remove_legacy_exports!
+ legacy_export_path = File.join(Gitlab::ImportExport.storage_path, full_path_was)
+
+ FileUtils.rm_rf(legacy_export_path)
+ end
+ end
+end
diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb
new file mode 100644
index 00000000000..ff82cb0ffa9
--- /dev/null
+++ b/app/models/concerns/storage/legacy_project_wiki.rb
@@ -0,0 +1,9 @@
+module Storage
+ module LegacyProjectWiki
+ extend ActiveSupport::Concern
+
+ def disk_path
+ project.disk_path + '.wiki'
+ end
+ end
+end
diff --git a/app/models/concerns/storage/legacy_repository.rb b/app/models/concerns/storage/legacy_repository.rb
new file mode 100644
index 00000000000..593749bf019
--- /dev/null
+++ b/app/models/concerns/storage/legacy_repository.rb
@@ -0,0 +1,7 @@
+module Storage
+ module LegacyRepository
+ extend ActiveSupport::Concern
+
+ delegate :disk_path, to: :project
+ end
+end
diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb
index f60a0f8f438..f478c8ede18 100644
--- a/app/models/concerns/subscribable.rb
+++ b/app/models/concerns/subscribable.rb
@@ -9,10 +9,12 @@ module Subscribable
extend ActiveSupport::Concern
included do
- has_many :subscriptions, dependent: :destroy, as: :subscribable
+ has_many :subscriptions, dependent: :destroy, as: :subscribable # rubocop:disable Cop/ActiveRecordDependent
end
def subscribed?(user, project = nil)
+ return false unless user
+
if subscription = subscriptions.find_by(user: user, project: project)
subscription.subscribed
else
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 25e2d8ea24e..ccd6f0e0a7d 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -9,13 +9,13 @@ require 'task_list/filter'
module Taskable
COMPLETED = 'completed'.freeze
INCOMPLETE = 'incomplete'.freeze
- ITEM_PATTERN = /
+ ITEM_PATTERN = %r{
^
\s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
\s+ # whitespace prefix has to be always presented for a list item
(\[\s\]|\[[xX]\]) # checkbox
(\s.+) # followed by whitespace and some text.
- /x
+ }x
def self.get_tasks(content)
content.to_s.scan(ITEM_PATTERN).map do |checkbox, label|
@@ -39,7 +39,7 @@ module Taskable
def task_list_items
return [] if description.blank?
- @task_list_items ||= Taskable.get_tasks(description)
+ @task_list_items ||= Taskable.get_tasks(description) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def tasks
diff --git a/app/models/concerns/throttled_touch.rb b/app/models/concerns/throttled_touch.rb
new file mode 100644
index 00000000000..ad0ff0f20d4
--- /dev/null
+++ b/app/models/concerns/throttled_touch.rb
@@ -0,0 +1,10 @@
+# ThrottledTouch can be used to throttle the number of updates triggered by
+# calling "touch" on an ActiveRecord model.
+module ThrottledTouch
+ # The amount of time to wait before "touch" can update a record again.
+ TOUCH_INTERVAL = 1.minute
+
+ def touch(*args)
+ super if (Time.zone.now - updated_at) > TOUCH_INTERVAL
+ end
+end
diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb
index 9cf83440784..5911b56c34c 100644
--- a/app/models/concerns/time_trackable.rb
+++ b/app/models/concerns/time_trackable.rb
@@ -9,7 +9,7 @@ module TimeTrackable
extend ActiveSupport::Concern
included do
- attr_reader :time_spent, :time_spent_user
+ attr_reader :time_spent, :time_spent_user, :spent_at
alias_method :time_spent?, :time_spent
@@ -18,12 +18,14 @@ module TimeTrackable
validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
validate :check_negative_time_spent
- has_many :timelogs, dependent: :destroy
+ has_many :timelogs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def spend_time(options)
@time_spent = options[:duration]
- @time_spent_user = options[:user]
+ @time_spent_user = User.find(options[:user_id])
+ @spent_at = options[:spent_at]
@original_total_time_spent = nil
return if @time_spent == 0
@@ -35,6 +37,7 @@ module TimeTrackable
end
end
alias_method :spend_time=, :spend_time
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def total_time_spent
timelogs.sum(:time_spent)
@@ -51,22 +54,30 @@ module TimeTrackable
private
def reset_spent_time
- timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user)
+ timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def add_or_subtract_spent_time
- timelogs.new(time_spent: time_spent, user: @time_spent_user)
+ timelogs.new(
+ time_spent: time_spent,
+ user: @time_spent_user,
+ spent_at: @spent_at
+ )
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def check_negative_time_spent
return if time_spent.nil? || time_spent == :reset
- # we need to cache the total time spent so multiple calls to #valid?
- # doesn't give a false error
- @original_total_time_spent ||= total_time_spent
-
- if time_spent < 0 && (time_spent.abs > @original_total_time_spent)
+ if time_spent < 0 && (time_spent.abs > original_total_time_spent)
errors.add(:time_spent, 'Time to subtract exceeds the total time spent')
end
end
+
+ # we need to cache the total time spent so multiple calls to #valid?
+ # doesn't give a false error
+ def original_total_time_spent
+ @original_total_time_spent ||= total_time_spent
+ end
end
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index 1ca7f91dc03..ec3543f7053 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -43,14 +43,17 @@ module TokenAuthenticatable
write_attribute(token_field, token) if token
end
+ # Returns a token, but only saves when the database is in read & write mode
define_method("ensure_#{token_field}!") do
- send("reset_#{token_field}!") if read_attribute(token_field).blank?
+ send("reset_#{token_field}!") if read_attribute(token_field).blank? # rubocop:disable GitlabSecurity/PublicSend
+
read_attribute(token_field)
end
+ # Resets the token, but only saves when the database is in read & write mode
define_method("reset_#{token_field}!") do
write_new_token(token_field)
- save!
+ save! if Gitlab::Database.read_write?
end
end
end
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
new file mode 100644
index 00000000000..ec0ed3b795a
--- /dev/null
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -0,0 +1,40 @@
+module TriggerableHooks
+ AVAILABLE_TRIGGERS = {
+ repository_update_hooks: :repository_update_events,
+ push_hooks: :push_events,
+ tag_push_hooks: :tag_push_events,
+ issue_hooks: :issues_events,
+ confidential_issue_hooks: :confidential_issues_events,
+ note_hooks: :note_events,
+ merge_request_hooks: :merge_requests_events,
+ job_hooks: :job_events,
+ pipeline_hooks: :pipeline_events,
+ wiki_page_hooks: :wiki_page_events
+ }.freeze
+
+ extend ActiveSupport::Concern
+
+ class_methods do
+ attr_reader :triggerable_hooks
+
+ attr_reader :triggers
+
+ def hooks_for(trigger)
+ callable_scopes = triggers.keys + [:all]
+ return none unless callable_scopes.include?(trigger)
+
+ public_send(trigger) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ private
+
+ def triggerable_hooks(hooks)
+ triggers = AVAILABLE_TRIGGERS.slice(*hooks)
+ @triggers = triggers
+
+ triggers.each do |trigger, event|
+ scope trigger, -> { where(event => true) }
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/updated_at_filterable.rb b/app/models/concerns/updated_at_filterable.rb
new file mode 100644
index 00000000000..edb423b7828
--- /dev/null
+++ b/app/models/concerns/updated_at_filterable.rb
@@ -0,0 +1,12 @@
+module UpdatedAtFilterable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :updated_before, ->(date) { where(scoped_table[:updated_at].lteq(date)) }
+ scope :updated_after, ->(date) { where(scoped_table[:updated_at].gteq(date)) }
+
+ def self.scoped_table
+ arel_table.alias(table_name)
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
index f42f516f99a..0bee62f954f 100644
--- a/app/models/conversational_development_index/metric.rb
+++ b/app/models/conversational_development_index/metric.rb
@@ -13,9 +13,7 @@ module ConversationalDevelopmentIndex
end
def percentage_score(feature)
- return 100 if leader_score(feature).zero?
-
- 100 * instance_score(feature) / leader_score(feature)
+ self["percentage_#{feature}"]
end
end
end
diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb
index d2e626c22e8..b34d1382d43 100644
--- a/app/models/cycle_analytics.rb
+++ b/app/models/cycle_analytics.rb
@@ -6,6 +6,12 @@ class CycleAnalytics
@options = options
end
+ def all_medians_per_stage
+ STAGES.each_with_object({}) do |stage_name, medians_per_stage|
+ medians_per_stage[stage_name] = self[stage_name].median
+ end
+ end
+
def summary
@summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(@project,
from: @options[:from],
diff --git a/app/models/cycle_analytics/summary.rb b/app/models/cycle_analytics/summary.rb
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/models/cycle_analytics/summary.rb
+++ /dev/null
diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb
index 646c1e5ce1a..86eb4ec76fc 100644
--- a/app/models/dashboard_milestone.rb
+++ b/app/models/dashboard_milestone.rb
@@ -2,4 +2,8 @@ class DashboardMilestone < GlobalMilestone
def issues_finder_params
{ authorized_only: true }
end
+
+ def dashboard_milestone?
+ true
+ end
end
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index 053f2a11aa0..c2e0a5fa126 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -1,10 +1,16 @@
class DeployKey < Key
- has_many :deploy_keys_projects, dependent: :destroy
+ include IgnorableColumn
+
+ has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :deploy_keys_projects
scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) }
scope :are_public, -> { where(public: true) }
+ ignore_column :can_push
+
+ accepts_nested_attributes_for :deploy_keys_projects
+
def private?
!public?
end
@@ -22,16 +28,18 @@ class DeployKey < Key
end
def has_access_to?(project)
- projects.include?(project)
+ deploy_keys_project_for(project).present?
end
def can_push_to?(project)
- can_push? && has_access_to?(project)
+ !!deploy_keys_project_for(project)&.can_push?
end
- private
+ def deploy_keys_project_for(project)
+ deploy_keys_projects.find_by(project: project)
+ end
- # we don't want to notify the user for deploy keys
- def notify_user
+ def projects_with_write_access
+ Project.preload(:route).where(id: deploy_keys_projects.with_write_access.select(:project_id))
end
end
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index ae8486bd9ac..6eef12c4373 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -1,8 +1,14 @@
class DeployKeysProject < ActiveRecord::Base
belongs_to :project
- belongs_to :deploy_key
+ belongs_to :deploy_key, inverse_of: :deploy_keys_projects
- validates :deploy_key_id, presence: true
+ scope :without_project_deleted, -> { joins(:project).where(projects: { pending_delete: false }) }
+ scope :in_project, ->(project) { where(project: project) }
+ scope :with_write_access, -> { where(can_push: true) }
+
+ accepts_nested_attributes_for :deploy_key
+
+ validates :deploy_key, presence: true
validates :deploy_key_id, uniqueness: { scope: [:project_id], message: "already exists in project" }
validates :project_id, presence: true
@@ -12,7 +18,7 @@ class DeployKeysProject < ActiveRecord::Base
def destroy_orphaned_deploy_key
return unless self.deploy_key.destroyed_when_orphaned? && self.deploy_key.orphaned?
-
+
self.deploy_key.destroy
end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 056c49e7162..e18ea8bfea4 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -1,8 +1,8 @@
class Deployment < ActiveRecord::Base
- include InternalId
+ include NonatomicInternalId
- belongs_to :project, required: true, validate: true
- belongs_to :environment, required: true, validate: true
+ belongs_to :project, required: true
+ belongs_to :environment, required: true
belongs_to :user
belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -45,14 +45,7 @@ class Deployment < ActiveRecord::Base
def includes_commit?(commit)
return false unless commit
- # Before 8.10, deployments didn't have keep-around refs. Any deployment
- # created before then could have a `sha` referring to a commit that no
- # longer exists in the repository, so just ignore those.
- begin
- project.repository.is_ancestor?(commit.id, sha)
- rescue Rugged::OdbError
- false
- end
+ project.repository.ancestor?(commit.id, sha)
end
def update_merge_request_metrics!
@@ -105,28 +98,29 @@ class Deployment < ActiveRecord::Base
end
def has_metrics?
- project.monitoring_service.present?
+ prometheus_adapter&.can_query?
end
def metrics
return {} unless has_metrics?
- project.monitoring_service.deployment_metrics(self)
- end
-
- def has_additional_metrics?
- project.prometheus_service.present?
+ metrics = prometheus_adapter.query(:deployment, self)
+ metrics&.merge(deployment_time: created_at.to_i) || {}
end
def additional_metrics
- return {} unless project.prometheus_service.present?
+ return {} unless has_metrics?
- metrics = project.prometheus_service.additional_deployment_metrics(self)
+ metrics = prometheus_adapter.query(:additional_metrics_deployment, self)
metrics&.merge(deployment_time: created_at.to_i) || {}
end
private
+ def prometheus_adapter
+ environment.prometheus_adapter
+ end
+
def ref_path
File.join(environment.ref_path, 'deployments', iid.to_s)
end
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index 07c4846e2ac..bd6af622bfb 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -11,6 +11,8 @@ class DiffDiscussion < Discussion
delegate :position,
:original_position,
:change_position,
+ :on_text?,
+ :on_image?,
to: :first_note
@@ -20,9 +22,15 @@ class DiffDiscussion < Discussion
def merge_request_version_params
return unless for_merge_request?
- return {} if active?
- noteable.version_params_for(position.diff_refs)
+ version_params = get_params
+
+ return version_params unless on_merge_request_commit? && commit_id
+
+ version_params ||= {}
+ version_params.tap do |params|
+ params[:commit_id] = commit_id
+ end
end
def reply_attributes
@@ -31,4 +39,12 @@ class DiffDiscussion < Discussion
position: position.to_json
)
end
+
+ private
+
+ def get_params
+ return {} if active?
+
+ noteable.version_params_for(position.diff_refs)
+ end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 20ef1378500..15122cbc693 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -6,20 +6,22 @@ class DiffNote < Note
NOTEABLE_TYPES = %w(MergeRequest Commit).freeze
- 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
+ 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
- validates :diff_line, presence: true
- validates :line_code, presence: true, line_code: true
+ validates :diff_line, presence: true, if: :on_text?
+ validates :line_code, presence: true, line_code: true, if: :on_text?
validates :noteable_type, inclusion: { in: NOTEABLE_TYPES }
validate :positions_complete
validate :verify_supported
+ validate :diff_refs_match_commit, if: :for_commit?
- before_validation :set_original_position, :update_position, on: :create
- before_validation :set_line_code
+ before_validation :set_original_position, on: :create
+ before_validation :update_position, on: :create, if: :on_text?
+ before_validation :set_line_code, if: :on_text?
after_save :keep_around_commits
def discussion_class(*)
@@ -43,6 +45,14 @@ class DiffNote < Note
end
end
+ def on_text?
+ position.position_type == "text"
+ end
+
+ def on_image?
+ position.position_type == "image"
+ end
+
def diff_file
@diff_file ||= self.original_position.diff_file(self.project.repository)
end
@@ -51,11 +61,9 @@ class DiffNote < Note
@diff_line ||= diff_file&.line_for_position(self.original_position)
end
- def for_line?(line)
- diff_file.position(line) == self.original_position
- end
-
def original_line_code
+ return unless on_text?
+
self.diff_file.line_code(self.diff_line)
end
@@ -124,6 +132,12 @@ class DiffNote < Note
errors.add(:position, "is invalid")
end
+ def diff_refs_match_commit
+ return if self.original_position.diff_refs == self.commit.diff_refs
+
+ errors.add(:commit_id, 'does not match the diff refs')
+ end
+
def keep_around_commits
project.repository.keep_around(self.original_position.base_sha)
project.repository.keep_around(self.original_position.start_sha)
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index d1cec7613af..92482a1a875 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -11,6 +11,7 @@ class Discussion
:author,
:noteable,
+ :commit_id,
:for_commit?,
:for_merge_request?,
@@ -66,6 +67,10 @@ class Discussion
@context_noteable = context_noteable
end
+ def on_image?
+ false
+ end
+
def ==(other)
other.class == self.class &&
other.context_noteable == self.context_noteable &&
@@ -81,6 +86,10 @@ class Discussion
last_note.author
end
+ def updated?
+ last_updated_at != created_at
+ end
+
def id
first_note.discussion_id(context_noteable)
end
diff --git a/app/models/email.rb b/app/models/email.rb
index 826d4f16edb..d6516761f0a 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -1,5 +1,6 @@
class Email < ActiveRecord::Base
include Sortable
+ include Gitlab::SQL::Pattern
belongs_to :user
@@ -7,6 +8,15 @@ class Email < ActiveRecord::Base
validates :email, presence: true, uniqueness: true, email: true
validate :unique_email, if: ->(email) { email.email_changed? }
+ scope :confirmed, -> { where.not(confirmed_at: nil) }
+
+ after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') }
+
+ devise :confirmable
+ self.reconfirmable = false # currently email can't be changed, no need to reconfirm
+
+ delegate :username, to: :user
+
def email=(value)
write_attribute(:email, value.downcase.strip)
end
@@ -14,4 +24,9 @@ class Email < ActiveRecord::Base
def unique_email
self.errors.add(:email, 'has already been taken') if User.exists?(email: self.email)
end
+
+ # once email is confirmed, update the gpg signatures
+ def update_invalid_gpg_signatures
+ user.update_invalid_gpg_signatures if confirmed?
+ end
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 66c96d0f586..9517723d9d9 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -4,9 +4,10 @@ class Environment < ActiveRecord::Base
NUMBERS = '0'..'9'
SUFFIX_CHARS = LETTERS.to_a + NUMBERS.to_a
- belongs_to :project, required: true, validate: true
+ belongs_to :project, required: true
+
+ has_many :deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :deployments, dependent: :destroy
has_one :last_deployment, -> { order('deployments.id DESC') }, class_name: 'Deployment'
before_validation :nullify_external_url
@@ -29,7 +30,6 @@ class Environment < ActiveRecord::Base
message: Gitlab::Regex.environment_slug_regex_message }
validates :external_url,
- uniqueness: { scope: :project_id },
length: { maximum: 255 },
allow_nil: true,
addressable_url: true
@@ -65,10 +65,9 @@ class Environment < ActiveRecord::Base
end
def predefined_variables
- [
- { key: 'CI_ENVIRONMENT_NAME', value: name, public: true },
- { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }
- ]
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_ENVIRONMENT_NAME', value: name)
+ .append(key: 'CI_ENVIRONMENT_SLUG', value: slug)
end
def recently_updated_on_branch?(ref)
@@ -82,12 +81,7 @@ class Environment < ActiveRecord::Base
def set_environment_type
names = name.split('/')
- self.environment_type =
- if names.many?
- names.first
- else
- nil
- end
+ self.environment_type = names.many? ? names.first : nil
end
def includes_commit?(commit)
@@ -101,11 +95,11 @@ class Environment < ActiveRecord::Base
end
def update_merge_request_metrics?
- (environment_type || name) == "production"
+ folder_name == "production"
end
- def first_deployment_for(commit)
- ref = project.repository.ref_name_for_sha(ref_path, commit.sha)
+ def first_deployment_for(commit_sha)
+ ref = project.repository.ref_name_for_sha(ref_path, commit_sha)
return nil unless ref
@@ -114,13 +108,13 @@ class Environment < ActiveRecord::Base
end
def ref_path
- "refs/environments/#{Shellwords.shellescape(name)}"
+ "refs/#{Repository::REF_ENVIRONMENTS}/#{slug}"
end
def formatted_external_url
return nil unless external_url
- external_url.gsub(/\A.*?:\/\//, '')
+ external_url.gsub(%r{\A.*?://}, '')
end
def stop_action?
@@ -143,29 +137,31 @@ class Environment < ActiveRecord::Base
end
def has_terminals?
- project.deployment_service.present? && available? && last_deployment.present?
+ project.deployment_platform.present? && available? && last_deployment.present?
end
def terminals
- project.deployment_service.terminals(self) if has_terminals?
+ project.deployment_platform.terminals(self) if has_terminals?
end
def has_metrics?
- project.monitoring_service.present? && available? && last_deployment.present?
+ prometheus_adapter&.can_query? && available? && last_deployment.present?
end
def metrics
- project.monitoring_service.environment_metrics(self) if has_metrics?
+ prometheus_adapter.query(:environment, self) if has_metrics?
end
- def has_additional_metrics?
- project.prometheus_service.present? && available? && last_deployment.present?
+ def additional_metrics
+ prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics?
end
- def additional_metrics
- if has_additional_metrics?
- project.prometheus_service.additional_environment_metrics(self)
- end
+ def prometheus_adapter
+ @prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).prometheus_adapter
+ end
+
+ def slug
+ super.presence || generate_slug
end
# An environment name is not necessarily suitable for use in URLs, DNS
@@ -218,12 +214,19 @@ class Environment < ActiveRecord::Base
end
def etag_cache_key
- Gitlab::Routing.url_helpers.namespace_project_environments_path(
- project.namespace,
+ Gitlab::Routing.url_helpers.project_environments_path(
project,
format: :json)
end
+ def folder_name
+ self.environment_type || self.name
+ end
+
+ def deployment_platform
+ project.deployment_platform(environment: self)
+ end
+
private
# Slugifying a name may remove the uniqueness guarantee afforded by it being
diff --git a/app/models/epic.rb b/app/models/epic.rb
new file mode 100644
index 00000000000..286b855de3f
--- /dev/null
+++ b/app/models/epic.rb
@@ -0,0 +1,11 @@
+# Placeholder class for model that is implemented in EE
+# It reserves '&' as a reference prefix, but the table does not exists in CE
+class Epic < ActiveRecord::Base
+ def self.reference_prefix
+ '&'
+ end
+
+ def self.reference_prefix_escaped
+ '&amp;'
+ end
+end
diff --git a/app/models/event.rb b/app/models/event.rb
index 29bc141c5cd..3805f6cf857 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -1,6 +1,7 @@
class Event < ActiveRecord::Base
include Sortable
- default_scope { reorder(nil).where.not(author_id: nil) }
+ include IgnorableColumn
+ default_scope { reorder(nil) }
CREATED = 1
UPDATED = 2
@@ -47,27 +48,78 @@ class Event < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :project
- belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
- # For Hash only
- serialize :data # rubocop:disable Cop/ActiverecordSerialize
+ belongs_to :target, -> {
+ # If the association for "target" defines an "author" association we want to
+ # eager-load this so Banzai & friends don't end up performing N+1 queries to
+ # get the authors of notes, issues, etc. (likewise for "noteable").
+ incs = %i(author noteable).select do |a|
+ reflections['events'].active_record.reflect_on_association(a)
+ end
+
+ incs.reduce(self) { |obj, a| obj.includes(a) }
+ }, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
+ has_one :push_event_payload
# Callbacks
after_create :reset_project_activity
after_create :set_last_repository_updated_at, if: :push?
+ after_create :track_user_interacted_projects
# Scopes
scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) }
- scope :in_projects, ->(projects) do
- where(project_id: projects.pluck(:id)).recent
+ scope :in_projects, -> (projects) do
+ sub_query = projects
+ .except(:order)
+ .select(1)
+ .where('projects.id = events.project_id')
+
+ where('EXISTS (?)', sub_query).recent
+ end
+
+ scope :with_associations, -> do
+ # We're using preload for "push_event_payload" as otherwise the association
+ # is not always available (depending on the query being built).
+ includes(:author, :project, project: :namespace)
+ .preload(:target, :push_event_payload)
end
- scope :with_associations, -> { includes(:author, :project, project: :namespace).preload(:target) }
scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) }
+ # Authors are required as they're used to display who pushed data.
+ #
+ # We're just validating the presence of the ID here as foreign key constraints
+ # should ensure the ID points to a valid user.
+ validates :author_id, presence: true
+
+ self.inheritance_column = 'action'
+
class << self
+ def model_name
+ ActiveModel::Name.new(self, nil, 'event')
+ end
+
+ def find_sti_class(action)
+ if action.to_i == PUSHED
+ PushEvent
+ else
+ Event
+ end
+ end
+
+ def subclass_from_attributes(attrs)
+ # Without this Rails will keep calling this method on the returned class,
+ # resulting in an infinite loop.
+ return unless self == Event
+
+ action = attrs.with_indifferent_access[inheritance_column].to_i
+
+ PushEvent if action == PUSHED
+ end
+
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
@@ -107,7 +159,7 @@ class Event < ActiveRecord::Base
def project_name
if project
- project.name_with_namespace
+ project.full_name
else
"(deleted project)"
end
@@ -122,7 +174,7 @@ class Event < ActiveRecord::Base
end
def push?
- action == PUSHED && valid_push?
+ false
end
def merged?
@@ -203,13 +255,7 @@ class Event < ActiveRecord::Base
def action_name
if push?
- if new_ref?
- "pushed new"
- elsif rm_ref?
- "deleted"
- else
- "pushed to"
- end
+ push_action_name
elsif closed?
"closed"
elsif merged?
@@ -225,87 +271,12 @@ class Event < ActiveRecord::Base
elsif commented?
"commented on"
elsif created_project?
- if project.external_import?
- "imported"
- else
- "created"
- end
+ created_project_action_name
else
"opened"
end
end
- def valid_push?
- data[:ref] && ref_name.present?
- rescue
- false
- end
-
- def tag?
- Gitlab::Git.tag_ref?(data[:ref])
- end
-
- def branch?
- Gitlab::Git.branch_ref?(data[:ref])
- end
-
- def new_ref?
- Gitlab::Git.blank_ref?(commit_from)
- end
-
- def rm_ref?
- Gitlab::Git.blank_ref?(commit_to)
- end
-
- def md_ref?
- !(rm_ref? || new_ref?)
- end
-
- def commit_from
- data[:before]
- end
-
- def commit_to
- data[:after]
- end
-
- def ref_name
- if tag?
- tag_name
- else
- branch_name
- end
- end
-
- def branch_name
- @branch_name ||= Gitlab::Git.ref_name(data[:ref])
- end
-
- def tag_name
- @tag_name ||= Gitlab::Git.ref_name(data[:ref])
- end
-
- # Max 20 commits from push DESC
- def commits
- @commits ||= (data[:commits] || []).reverse
- end
-
- def commits_count
- data[:total_commits_count] || commits.count || 0
- end
-
- def ref_type
- tag? ? "tag" : "branch"
- end
-
- def push_with_commits?
- !commits.empty? && commit_from && commit_to
- end
-
- def last_push_to_non_root?
- branch? && project.default_branch != branch_name
- end
-
def target_iid
target.respond_to?(:iid) ? target.iid : target_id
end
@@ -359,7 +330,7 @@ class Event < ActiveRecord::Base
def body?
if push?
- push_with_commits? || rm_ref?
+ push_with_commits?
elsif note?
true
else
@@ -385,8 +356,32 @@ class Event < ActiveRecord::Base
user ? author_id == user.id : false
end
+ def to_partial_path
+ # We are intentionally using `Event` rather than `self.class` so that
+ # subclasses also use the `Event` implementation.
+ Event._to_partial_path
+ end
+
private
+ def push_action_name
+ if new_ref?
+ "pushed new"
+ elsif rm_ref?
+ "deleted"
+ else
+ "pushed to"
+ end
+ end
+
+ def created_project_action_name
+ if project.external_import?
+ "imported"
+ else
+ "created"
+ end
+ end
+
def recent_update?
project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago
end
@@ -395,4 +390,11 @@ class Event < ActiveRecord::Base
Project.unscoped.where(id: project_id)
.update_all(last_repository_updated_at: created_at)
end
+
+ def track_user_interacted_projects
+ # Note the call to .available? is due to earlier migrations
+ # that would otherwise conflict with the call to .track
+ # (because the table does not exist yet).
+ UserInteractedProject.track(self) if UserInteractedProject.available?
+ end
end
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
new file mode 100644
index 00000000000..8b8244314af
--- /dev/null
+++ b/app/models/event_collection.rb
@@ -0,0 +1,98 @@
+# A collection of events to display in an event list.
+#
+# An EventCollection is meant to be used for displaying events to a user (e.g.
+# in a controller), it's not suitable for building queries that are used for
+# building other queries.
+class EventCollection
+ # To prevent users from putting too much pressure on the database by cycling
+ # through thousands of events we put a limit on the number of pages.
+ MAX_PAGE = 10
+
+ # projects - An ActiveRecord::Relation object that returns the projects for
+ # which to retrieve events.
+ # filter - An EventFilter instance to use for filtering events.
+ def initialize(projects, limit: 20, offset: 0, filter: nil)
+ @projects = projects
+ @limit = limit
+ @offset = offset
+ @filter = filter
+ end
+
+ # Returns an Array containing the events.
+ def to_a
+ return [] if current_page > MAX_PAGE
+
+ relation = if Gitlab::Database.join_lateral_supported?
+ relation_with_join_lateral
+ else
+ relation_without_join_lateral
+ end
+
+ relation.with_associations.to_a
+ end
+
+ private
+
+ # Returns the events relation to use when JOIN LATERAL is not supported.
+ #
+ # This relation simply gets all the events for all authorized projects, then
+ # limits that set.
+ def relation_without_join_lateral
+ events = filtered_events.in_projects(projects)
+
+ paginate_events(events)
+ end
+
+ # Returns the events relation to use when JOIN LATERAL is supported.
+ #
+ # This relation is built using JOIN LATERAL, producing faster queries than a
+ # regular LIMIT + OFFSET approach.
+ def relation_with_join_lateral
+ projects_for_lateral = projects.select(:id).to_sql
+
+ lateral = filtered_events
+ .limit(limit_for_join_lateral)
+ .where('events.project_id = projects_for_lateral.id')
+ .to_sql
+
+ # The outer query does not need to re-apply the filters since the JOIN
+ # LATERAL body already takes care of this.
+ outer = base_relation
+ .from("(#{projects_for_lateral}) projects_for_lateral")
+ .joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true")
+
+ paginate_events(outer)
+ end
+
+ def filtered_events
+ @filter ? @filter.apply_filter(base_relation) : base_relation
+ end
+
+ def paginate_events(events)
+ events.limit(@limit).offset(@offset)
+ end
+
+ def base_relation
+ # We want to have absolute control over the event queries being built, thus
+ # we're explicitly opting out of any default scopes that may be set.
+ Event.unscoped.recent
+ end
+
+ def limit_for_join_lateral
+ # Applying the OFFSET on the inside of a JOIN LATERAL leads to incorrect
+ # results. To work around this we need to increase the inner limit for every
+ # page.
+ #
+ # This means that on page 1 we use LIMIT 20, and an outer OFFSET of 0. On
+ # page 2 we use LIMIT 40 and an outer OFFSET of 20.
+ @limit + @offset
+ end
+
+ def current_page
+ (@offset / @limit) + 1
+ end
+
+ def projects
+ @projects.except(:order)
+ end
+end
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index e63f89a9f85..282fd7edcb7 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -38,18 +38,17 @@ class ExternalIssue
@project.id
end
- # Pattern used to extract `JIRA-123` issue references from text
- def self.reference_pattern
- @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
+ def to_reference(_from = nil, full: nil)
+ reference_link_text
end
- def to_reference(_from_project = nil, full: nil)
- id
- end
-
- def reference_link_text(from_project = nil)
+ def reference_link_text(from = nil)
return "##{id}" if id =~ /^\d+$/
id
end
+
+ def notes
+ Note.none
+ end
end
diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb
new file mode 100644
index 00000000000..7f1728e8c77
--- /dev/null
+++ b/app/models/fork_network.rb
@@ -0,0 +1,19 @@
+class ForkNetwork < ActiveRecord::Base
+ belongs_to :root_project, class_name: 'Project'
+ has_many :fork_network_members
+ has_many :projects, through: :fork_network_members
+
+ after_create :add_root_as_member, if: :root_project
+
+ def add_root_as_member
+ projects << root_project
+ end
+
+ def find_forks_in(other_projects)
+ projects.where(id: other_projects)
+ end
+
+ def merge_requests
+ MergeRequest.where(target_project: projects)
+ end
+end
diff --git a/app/models/fork_network_member.rb b/app/models/fork_network_member.rb
new file mode 100644
index 00000000000..eb9417dc34f
--- /dev/null
+++ b/app/models/fork_network_member.rb
@@ -0,0 +1,17 @@
+class ForkNetworkMember < ActiveRecord::Base
+ belongs_to :fork_network
+ belongs_to :project
+ belongs_to :forked_from_project, class_name: 'Project'
+
+ validates :fork_network, :project, presence: true
+
+ after_destroy :cleanup_fork_network
+
+ private
+
+ def cleanup_fork_network
+ # Explicitly using `#count` makes sure we have the correct number if the
+ # relation was loaded in the fork_network.
+ fork_network.destroy if fork_network.fork_network_members.count == 0
+ end
+end
diff --git a/app/models/forked_project_link.rb b/app/models/forked_project_link.rb
index 36cf7ad6a28..8d35864eff6 100644
--- a/app/models/forked_project_link.rb
+++ b/app/models/forked_project_link.rb
@@ -1,4 +1,4 @@
class ForkedProjectLink < ActiveRecord::Base
- belongs_to :forked_to_project, class_name: 'Project'
- belongs_to :forked_from_project, class_name: 'Project'
+ belongs_to :forked_to_project, -> { where.not(pending_delete: true) }, class_name: 'Project'
+ belongs_to :forked_from_project, -> { where.not(pending_delete: true) }, class_name: 'Project'
end
diff --git a/app/models/global_label.rb b/app/models/global_label.rb
index 698a7bbd327..2a1b7564962 100644
--- a/app/models/global_label.rb
+++ b/app/models/global_label.rb
@@ -2,7 +2,7 @@ class GlobalLabel
attr_accessor :title, :labels
alias_attribute :name, :title
- delegate :color, :description, to: :@first_label
+ delegate :color, :text_color, :description, to: :@first_label
def for_display
@first_label
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index 538615130a7..dc2f6817190 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -2,6 +2,7 @@ class GlobalMilestone
include Milestoneish
EPOCH = DateTime.parse('1970-01-01')
+ STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
attr_accessor :title, :milestones
alias_attribute :name, :title
@@ -11,7 +12,10 @@ class GlobalMilestone
end
def self.build_collection(projects, params)
- child_milestones = MilestonesFinder.new.execute(projects, params)
+ params =
+ { project_ids: projects.map(&:id), state: params[:state] }
+
+ child_milestones = MilestonesFinder.new(params).execute
milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped|
milestones_relation = Milestone.where(id: grouped.map(&:id))
@@ -28,13 +32,42 @@ class GlobalMilestone
new(title, child_milestones)
end
- def self.states_count(projects)
- relation = MilestonesFinder.new.execute(projects, state: 'all')
- milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
+ def self.states_count(projects, group = nil)
+ legacy_group_milestones_count = legacy_group_milestone_states_count(projects)
+ group_milestones_count = group_milestones_states_count(group)
+
+ legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count|
+ legacy_group_milestones_count + group_milestones_count
+ end
+ end
+
+ def self.group_milestones_states_count(group)
+ return STATE_COUNT_HASH unless group
+
+ params = { group_ids: [group.id], state: 'all' }
+
+ relation = MilestonesFinder.new(params).execute
+ grouped_by_state = relation.reorder(nil).group(:state).count
+
+ {
+ opened: grouped_by_state['active'] || 0,
+ closed: grouped_by_state['closed'] || 0,
+ all: grouped_by_state.values.sum
+ }
+ end
+
+ # Counts the legacy group milestones which must be grouped by title
+ def self.legacy_group_milestone_states_count(projects)
+ return STATE_COUNT_HASH unless projects
+
+ params = { project_ids: projects.map(&:id), state: 'all' }
+
+ relation = MilestonesFinder.new(params).execute
+ project_milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
- opened = count_by_state(milestones_by_state_and_title, 'active')
- closed = count_by_state(milestones_by_state_and_title, 'closed')
- all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
+ opened = count_by_state(project_milestones_by_state_and_title, 'active')
+ closed = count_by_state(project_milestones_by_state_and_title, 'closed')
+ all = project_milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
{
opened: opened,
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
new file mode 100644
index 00000000000..44eda741679
--- /dev/null
+++ b/app/models/gpg_key.rb
@@ -0,0 +1,127 @@
+class GpgKey < ActiveRecord::Base
+ KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze
+ KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze
+
+ include ShaAttribute
+
+ sha_attribute :primary_keyid
+ sha_attribute :fingerprint
+
+ belongs_to :user
+ has_many :gpg_signatures
+ has_many :subkeys, class_name: 'GpgKeySubkey'
+
+ scope :with_subkeys, -> { includes(:subkeys) }
+
+ validates :user, presence: true
+
+ validates :key,
+ presence: true,
+ uniqueness: true,
+ format: {
+ with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/m,
+ message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}' and ends with '#{KEY_SUFFIX}'"
+ }
+
+ validates :fingerprint,
+ presence: true,
+ uniqueness: true,
+ # only validate when the `key` is valid, as we don't want the user to show
+ # the error about the fingerprint
+ unless: -> { errors.has_key?(:key) }
+
+ validates :primary_keyid,
+ presence: true,
+ uniqueness: true,
+ # only validate when the `key` is valid, as we don't want the user to show
+ # the error about the fingerprint
+ unless: -> { errors.has_key?(:key) }
+
+ before_validation :extract_fingerprint, :extract_primary_keyid
+ after_commit :update_invalid_gpg_signatures, on: :create
+ after_create :generate_subkeys
+
+ def primary_keyid
+ super&.upcase
+ end
+ alias_method :keyid, :primary_keyid
+
+ def fingerprint
+ super&.upcase
+ end
+
+ def key=(value)
+ super(value&.strip)
+ end
+
+ def keyids
+ [keyid].concat(subkeys.map(&:keyid))
+ end
+
+ def user_infos
+ @user_infos ||= Gitlab::Gpg.user_infos_from_key(key)
+ end
+
+ def verified_user_infos
+ user_infos.select do |user_info|
+ user.verified_email?(user_info[:email])
+ end
+ end
+
+ def emails_with_verified_status
+ user_infos.map do |user_info|
+ [
+ user_info[:email],
+ user.verified_email?(user_info[:email])
+ ]
+ end.to_h
+ end
+
+ def verified?
+ emails_with_verified_status.values.any?
+ end
+
+ def verified_and_belongs_to_email?(email)
+ emails_with_verified_status.fetch(email.downcase, false)
+ end
+
+ def update_invalid_gpg_signatures
+ InvalidGpgSignatureUpdateWorker.perform_async(self.id)
+ end
+
+ def revoke
+ GpgSignature
+ .with_key_and_subkeys(self)
+ .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key])
+ .update_all(
+ gpg_key_id: nil,
+ gpg_key_subkey_id: nil,
+ verification_status: GpgSignature.verification_statuses[:unknown_key],
+ updated_at: Time.zone.now
+ )
+
+ destroy
+ end
+
+ private
+
+ def extract_fingerprint
+ # we can assume that the result only contains one item as the validation
+ # only allows one key
+ self.fingerprint = Gitlab::Gpg.fingerprints_from_key(key).first
+ end
+
+ def extract_primary_keyid
+ # we can assume that the result only contains one item as the validation
+ # only allows one key
+ self.primary_keyid = Gitlab::Gpg.primary_keyids_from_key(key).first
+ end
+
+ def generate_subkeys
+ gpg_subkeys = Gitlab::Gpg.subkeys_from_key(key)
+
+ gpg_subkeys[primary_keyid]&.each do |subkey_data|
+ subkeys.create!(keyid: subkey_data[:keyid], fingerprint: subkey_data[:fingerprint])
+ end
+ end
+end
diff --git a/app/models/gpg_key_subkey.rb b/app/models/gpg_key_subkey.rb
new file mode 100644
index 00000000000..b57922aba30
--- /dev/null
+++ b/app/models/gpg_key_subkey.rb
@@ -0,0 +1,22 @@
+class GpgKeySubkey < ActiveRecord::Base
+ include ShaAttribute
+
+ sha_attribute :keyid
+ sha_attribute :fingerprint
+
+ belongs_to :gpg_key
+
+ validates :gpg_key_id, presence: true
+ validates :fingerprint, :keyid, presence: true, uniqueness: true
+
+ delegate :key, :user, :user_infos, :verified?, :verified_user_infos,
+ :verified_and_belongs_to_email?, to: :gpg_key
+
+ def keyid
+ super&.upcase
+ end
+
+ def fingerprint
+ super&.upcase
+ end
+end
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
new file mode 100644
index 00000000000..bf88d75246f
--- /dev/null
+++ b/app/models/gpg_signature.rb
@@ -0,0 +1,67 @@
+class GpgSignature < ActiveRecord::Base
+ include ShaAttribute
+
+ sha_attribute :commit_sha
+ sha_attribute :gpg_key_primary_keyid
+
+ enum verification_status: {
+ unverified: 0,
+ verified: 1,
+ same_user_different_email: 2,
+ other_user: 3,
+ unverified_key: 4,
+ unknown_key: 5
+ }
+
+ belongs_to :project
+ belongs_to :gpg_key
+ belongs_to :gpg_key_subkey
+
+ validates :commit_sha, presence: true
+ validates :project_id, presence: true
+ validates :gpg_key_primary_keyid, presence: true
+
+ def self.with_key_and_subkeys(gpg_key)
+ subkey_ids = gpg_key.subkeys.pluck(:id)
+
+ where(
+ arel_table[:gpg_key_id].eq(gpg_key.id).or(
+ arel_table[:gpg_key_subkey_id].in(subkey_ids)
+ )
+ )
+ end
+
+ def gpg_key=(model)
+ case model
+ when GpgKey
+ super
+ when GpgKeySubkey
+ self.gpg_key_subkey = model
+ when NilClass
+ super
+ self.gpg_key_subkey = nil
+ end
+ end
+
+ def gpg_key
+ if gpg_key_id
+ super
+ elsif gpg_key_subkey_id
+ gpg_key_subkey
+ end
+ end
+
+ def gpg_key_primary_keyid
+ super&.upcase
+ end
+
+ def commit
+ project.commit(commit_sha)
+ end
+
+ def gpg_commit
+ return unless commit
+
+ Gitlab::Gpg::Commit.new(commit)
+ end
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index 0b93460d473..d99af79b5fe 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -2,13 +2,15 @@ require 'carrierwave/orm/activerecord'
class Group < Namespace
include Gitlab::ConfigHelper
- include Gitlab::VisibilityLevel
+ include AfterCommitQueue
include AccessRequestable
include Avatarable
include Referable
include SelectForProjectAuthorization
+ include LoadedInGroupList
+ include GroupDescendant
- has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source
+ has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
has_many :users, through: :group_members
has_many :owners,
@@ -16,46 +18,41 @@ class Group < Namespace
through: :group_members,
source: :user
- has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember'
+ has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
- has_many :project_group_links, dependent: :destroy
+ has_many :milestones
+ has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :shared_projects, through: :project_group_links, source: :project
- has_many :notification_settings, dependent: :destroy, as: :source
+ has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, class_name: 'GroupLabel'
+ has_many :variables, class_name: 'Ci::GroupVariable'
+ has_many :custom_attributes, class_name: 'GroupCustomAttribute'
- validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
- validate :visibility_level_allowed_by_projects
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+ has_many :boards
+ has_many :badges, class_name: 'GroupBadge'
- validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
+ accepts_nested_attributes_for :variables, allow_destroy: true
+
+ validate :visibility_level_allowed_by_projects
+ validate :visibility_level_allowed_by_sub_groups
+ validate :visibility_level_allowed_by_parent
+ validates :variables, variable_duplicates: true
- mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy
+ validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
+ after_update :path_changed_hook, if: :path_changed?
class << self
def supports_nested_groups?
Gitlab::Database.postgresql?
end
- # Searches for groups matching the given query.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search(query)
- table = Namespace.arel_table
- pattern = "%#{query}%"
-
- where(table[:name].matches(pattern).or(table[:path].matches(pattern)))
- end
-
def sort(method)
if method == 'storage_size_desc'
# storage_size is a virtual column so we need to
@@ -89,7 +86,7 @@ class Group < Namespace
end
end
- def to_reference(_from_project = nil, full: nil)
+ def to_reference(_from = nil, full: nil)
"#{self.class.reference_prefix}#{full_path}"
end
@@ -101,25 +98,24 @@ class Group < Namespace
full_name
end
- def visibility_level_field
- :visibility_level
- end
+ def visibility_level_allowed_by_parent?(level = self.visibility_level)
+ return true unless parent_id && parent_id.nonzero?
- def visibility_level_allowed_by_projects
- allowed_by_projects = self.projects.where('visibility_level > ?', self.visibility_level).none?
+ level <= parent.visibility_level
+ end
- unless allowed_by_projects
- level_name = Gitlab::VisibilityLevel.level_name(visibility_level).downcase
- self.errors.add(:visibility_level, "#{level_name} is not allowed since there are projects with higher visibility.")
- end
+ def visibility_level_allowed_by_projects?(level = self.visibility_level)
+ !projects.where('visibility_level > ?', level).exists?
+ end
- allowed_by_projects
+ def visibility_level_allowed_by_sub_groups?(level = self.visibility_level)
+ !children.where('visibility_level > ?', level).exists?
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)
+ def visibility_level_allowed?(level = self.visibility_level)
+ visibility_level_allowed_by_parent?(level) &&
+ visibility_level_allowed_by_projects?(level) &&
+ visibility_level_allowed_by_sub_groups?(level)
end
def lfs_enabled?
@@ -169,11 +165,21 @@ class Group < Namespace
add_user(user, :owner, current_user: current_user)
end
+ def member?(user, min_access_level = Gitlab::Access::GUEST)
+ return false unless user
+
+ max_member_access_for_user(user) >= min_access_level
+ end
+
def has_owner?(user)
+ return false unless user
+
members_with_parents.owners.where(user_id: user).any?
end
def has_master?(user)
+ return false unless user
+
members_with_parents.masters.where(user_id: user).any?
end
@@ -183,12 +189,6 @@ class Group < Namespace
owners.include?(user) && owners.size == 1
end
- def avatar_type
- unless self.avatar.image?
- self.errors.add :avatar, "only images allowed"
- end
- end
-
def post_create_hook
Gitlab::AppLogger.info("Group \"#{name}\" was created")
@@ -205,21 +205,45 @@ class Group < Namespace
SystemHooksService.new
end
- def refresh_members_authorized_projects
+ def refresh_members_authorized_projects(blocking: true)
UserProjectAccessChangedService.new(user_ids_for_project_authorizations)
- .execute
+ .execute(blocking: blocking)
end
def user_ids_for_project_authorizations
- users_with_parents.pluck(:id)
+ members_with_parents.pluck(:user_id)
end
def members_with_parents
- GroupMember.non_request.where(source_id: ancestors.pluck(:id).push(id))
+ # Avoids an unnecessary SELECT when the group has no parents
+ source_ids =
+ if parent_id
+ self_and_ancestors.reorder(nil).select(:id)
+ else
+ id
+ end
+
+ GroupMember
+ .active_without_invites_and_requests
+ .where(source_id: source_ids)
+ end
+
+ def members_with_descendants
+ GroupMember
+ .active_without_invites_and_requests
+ .where(source_id: self_and_descendants.reorder(nil).select(:id))
end
def users_with_parents
- User.where(id: members_with_parents.select(:user_id))
+ User
+ .where(id: members_with_parents.select(:user_id))
+ .reorder(nil)
+ end
+
+ def users_with_descendants
+ User
+ .where(id: members_with_descendants.select(:user_id))
+ .reorder(nil)
end
def max_member_access_for_user(user)
@@ -242,11 +266,53 @@ class Group < Namespace
}
end
- protected
+ def secret_variables_for(ref, project)
+ list_of_ids = [self] + ancestors
+ variables = Ci::GroupVariable.where(group: list_of_ids)
+ variables = variables.unprotected unless project.protected_for?(ref)
+ variables = variables.group_by(&:group_id)
+ list_of_ids.reverse.map { |group| variables[group.id] }.compact.flatten
+ end
+
+ def group_member(user)
+ if group_members.loaded?
+ group_members.find { |gm| gm.user_id == user.id }
+ else
+ group_members.find_by(user_id: user)
+ end
+ end
+
+ def hashed_storage?(_feature)
+ false
+ end
+
+ private
def update_two_factor_requirement
return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
users.find_each(&:update_two_factor_requirement)
end
+
+ def path_changed_hook
+ system_hook_service.execute_hooks_for(self, :rename)
+ end
+
+ def visibility_level_allowed_by_parent
+ return if visibility_level_allowed_by_parent?
+
+ errors.add(:visibility_level, "#{visibility} is not allowed since the parent group has a #{parent.visibility} visibility.")
+ end
+
+ def visibility_level_allowed_by_projects
+ return if visibility_level_allowed_by_projects?
+
+ errors.add(:visibility_level, "#{visibility} is not allowed since this group contains projects with higher visibility.")
+ end
+
+ def visibility_level_allowed_by_sub_groups
+ return if visibility_level_allowed_by_sub_groups?
+
+ errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.")
+ end
end
diff --git a/app/models/group_custom_attribute.rb b/app/models/group_custom_attribute.rb
new file mode 100644
index 00000000000..8157d602d67
--- /dev/null
+++ b/app/models/group_custom_attribute.rb
@@ -0,0 +1,6 @@
+class GroupCustomAttribute < ActiveRecord::Base
+ belongs_to :group
+
+ validates :group, :key, :value, presence: true
+ validates :key, uniqueness: { scope: [:group_id] }
+end
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index 86d38e5468b..98135ee3c8b 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -16,4 +16,8 @@ class GroupMilestone < GlobalMilestone
def issues_finder_params
{ group_id: group.id }
end
+
+ def legacy_group_milestone?
+ true
+ end
end
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index ee6165fd32d..b6dd39b860b 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -1,11 +1,18 @@
class ProjectHook < WebHook
- belongs_to :project
+ include TriggerableHooks
+
+ triggerable_hooks [
+ :push_hooks,
+ :tag_push_hooks,
+ :issue_hooks,
+ :confidential_issue_hooks,
+ :note_hooks,
+ :merge_request_hooks,
+ :job_hooks,
+ :pipeline_hooks,
+ :wiki_page_hooks
+ ]
- scope :issue_hooks, -> { where(issues_events: true) }
- 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 :job_hooks, -> { where(job_events: true) }
- scope :pipeline_hooks, -> { where(pipeline_events: true) }
- scope :wiki_page_hooks, -> { where(wiki_page_events: true) }
+ belongs_to :project
+ validates :project, presence: true
end
diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb
index 40e43c27f91..aef11514945 100644
--- a/app/models/hooks/service_hook.rb
+++ b/app/models/hooks/service_hook.rb
@@ -1,5 +1,6 @@
class ServiceHook < WebHook
belongs_to :service
+ validates :service, presence: true
def execute(data)
WebHookService.new(self, data, 'service_hook').execute
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index 1584235ab00..0528266e5b3 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -1,6 +1,14 @@
class SystemHook < WebHook
- scope :repository_update_hooks, -> { where(repository_update_events: true) }
+ include TriggerableHooks
+
+ triggerable_hooks [
+ :repository_update_hooks,
+ :push_hooks,
+ :tag_push_hooks,
+ :merge_request_hooks
+ ]
default_value_for :push_events, false
default_value_for :repository_update_events, true
+ default_value_for :merge_requests_events, false
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 7503f3739c3..27729deeac9 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -1,23 +1,10 @@
class WebHook < ActiveRecord::Base
include Sortable
- default_value_for :push_events, true
- default_value_for :issues_events, false
- default_value_for :confidential_issues_events, false
- default_value_for :note_events, false
- default_value_for :merge_requests_events, false
- default_value_for :tag_push_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) }
+ has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :url, presence: true, url: true
+ validates :token, format: { without: /\n/ }
def execute(data, hook_name)
WebHookService.new(self, data, hook_name).execute
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index d73cfcf630d..e72c125fb69 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -1,9 +1,9 @@
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
+ 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
diff --git a/app/models/identity.rb b/app/models/identity.rb
index 920a25932b4..1011b9f1109 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,15 +1,46 @@
class Identity < ActiveRecord::Base
include Sortable
include CaseSensitivity
+
belongs_to :user
validates :provider, presence: true
- validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider }
+ validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider, case_sensitive: false }
validates :user_id, uniqueness: { scope: :provider }
- scope :with_extern_uid, ->(provider, extern_uid) { where(extern_uid: extern_uid, provider: provider) }
+ before_save :ensure_normalized_extern_uid, if: :extern_uid_changed?
+ after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider?
+
+ scope :with_provider, ->(provider) { where(provider: provider) }
+ scope :with_extern_uid, ->(provider, extern_uid) do
+ iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider)
+ end
def ldap?
- provider.starts_with?('ldap')
+ Gitlab::Auth::OAuth::Provider.ldap_provider?(provider)
+ end
+
+ def self.normalize_uid(provider, uid)
+ if Gitlab::Auth::OAuth::Provider.ldap_provider?(provider)
+ Gitlab::Auth::LDAP::Person.normalize_dn(uid)
+ else
+ uid.to_s
+ end
+ end
+
+ private
+
+ def ensure_normalized_extern_uid
+ return if extern_uid.nil?
+
+ self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid)
+ end
+
+ def user_synced_attributes_metadata_from_provider?
+ user.user_synced_attributes_metadata&.provider == provider
+ end
+
+ def clear_user_synced_attributes
+ user.user_synced_attributes_metadata&.destroy
end
end
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
new file mode 100644
index 00000000000..b30b707e5fe
--- /dev/null
+++ b/app/models/instance_configuration.rb
@@ -0,0 +1,71 @@
+require 'resolv'
+
+class InstanceConfiguration
+ SSH_ALGORITHMS = %w(DSA ECDSA ED25519 RSA).freeze
+ SSH_ALGORITHMS_PATH = '/etc/ssh/'.freeze
+ CACHE_KEY = 'instance_configuration'.freeze
+ EXPIRATION_TIME = 24.hours
+
+ def settings
+ @configuration ||= Rails.cache.fetch(CACHE_KEY, expires_in: EXPIRATION_TIME) do
+ { ssh_algorithms_hashes: ssh_algorithms_hashes,
+ host: host,
+ gitlab_pages: gitlab_pages,
+ gitlab_ci: gitlab_ci }.deep_symbolize_keys
+ end
+ end
+
+ private
+
+ def ssh_algorithms_hashes
+ SSH_ALGORITHMS.map { |algo| ssh_algorithm_hashes(algo) }.compact
+ end
+
+ def host
+ Settings.gitlab.host
+ end
+
+ def gitlab_pages
+ Settings.pages.to_h.merge(ip_address: resolv_dns(Settings.pages.host))
+ end
+
+ def resolv_dns(dns)
+ Resolv.getaddress(dns)
+ rescue Resolv::ResolvError
+ end
+
+ def gitlab_ci
+ Settings.gitlab_ci
+ .to_h
+ .merge(artifacts_max_size: { value: Settings.artifacts.max_size&.megabytes,
+ default: 100.megabytes })
+ end
+
+ def ssh_algorithm_file(algorithm)
+ File.join(SSH_ALGORITHMS_PATH, "ssh_host_#{algorithm.downcase}_key.pub")
+ end
+
+ def ssh_algorithm_hashes(algorithm)
+ content = ssh_algorithm_file_content(algorithm)
+ return unless content.present?
+
+ { name: algorithm,
+ md5: ssh_algorithm_md5(content),
+ sha256: ssh_algorithm_sha256(content) }
+ end
+
+ def ssh_algorithm_file_content(algorithm)
+ file = ssh_algorithm_file(algorithm)
+ return unless File.exist?(file)
+
+ File.read(file)
+ end
+
+ def ssh_algorithm_md5(ssh_file_content)
+ OpenSSL::Digest::MD5.hexdigest(ssh_file_content).scan(/../).join(':')
+ end
+
+ def ssh_algorithm_sha256(ssh_file_content)
+ OpenSSL::Digest::SHA256.hexdigest(ssh_file_content)
+ end
+end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
new file mode 100644
index 00000000000..cbec735c2dd
--- /dev/null
+++ b/app/models/internal_id.rb
@@ -0,0 +1,125 @@
+# An InternalId is a strictly monotone sequence of integers
+# generated for a given scope and usage.
+#
+# For example, issues use their project to scope internal ids:
+# In that sense, scope is "project" and usage is "issues".
+# Generated internal ids for an issue are unique per project.
+#
+# See InternalId#usage enum for available usages.
+#
+# In order to leverage InternalId for other usages, the idea is to
+# * Add `usage` value to enum
+# * (Optionally) add columns to `internal_ids` if needed for scope.
+class InternalId < ActiveRecord::Base
+ belongs_to :project
+
+ enum usage: { issues: 0 }
+
+ validates :usage, presence: true
+
+ REQUIRED_SCHEMA_VERSION = 20180305095250
+
+ # Increments #last_value and saves the record
+ #
+ # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
+ # As such, the increment is atomic and safe to be called concurrently.
+ def increment_and_save!
+ lock!
+ self.last_value = (last_value || 0) + 1
+ save!
+ last_value
+ end
+
+ class << self
+ def generate_next(subject, scope, usage, init)
+ # Shortcut if `internal_ids` table is not available (yet)
+ # This can be the case in other (unrelated) migration specs
+ return (init.call(subject) || 0) + 1 unless available?
+
+ InternalIdGenerator.new(subject, scope, usage, init).generate
+ end
+
+ def available?
+ @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
+ end
+
+ # Flushes cached information about schema
+ def reset_column_information
+ @available_flag = nil
+ super
+ end
+ end
+
+ class InternalIdGenerator
+ # Generate next internal id for a given scope and usage.
+ #
+ # For currently supported usages, see #usage enum.
+ #
+ # The method implements a locking scheme that has the following properties:
+ # 1) Generated sequence of internal ids is unique per (scope and usage)
+ # 2) The method is thread-safe and may be used in concurrent threads/processes.
+ # 3) The generated sequence is gapless.
+ # 4) In the absence of a record in the internal_ids table, one will be created
+ # and last_value will be calculated on the fly.
+ #
+ # subject: The instance we're generating an internal id for. Gets passed to init if called.
+ # scope: Attributes that define the scope for id generation.
+ # usage: Symbol to define the usage of the internal id, see InternalId.usages
+ # init: Block that gets called to initialize InternalId record if not present
+ # Make sure to not throw exceptions in the absence of records (if this is expected).
+ attr_reader :subject, :scope, :init, :scope_attrs, :usage
+
+ def initialize(subject, scope, usage, init)
+ @subject = subject
+ @scope = scope
+ @init = init
+ @usage = usage
+
+ raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
+
+ unless InternalId.usages.has_key?(usage.to_s)
+ raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages"
+ end
+ end
+
+ # Generates next internal id and returns it
+ def generate
+ subject.transaction do
+ # Create a record in internal_ids if one does not yet exist
+ # and increment its last value
+ #
+ # Note this will acquire a ROW SHARE lock on the InternalId record
+ (lookup || create_record).increment_and_save!
+ end
+ end
+
+ private
+
+ # Retrieve InternalId record for (project, usage) combination, if it exists
+ def lookup
+ InternalId.find_by(**scope, usage: usage_value)
+ end
+
+ def usage_value
+ @usage_value ||= InternalId.usages[usage.to_s]
+ end
+
+ # Create InternalId record for (scope, usage) combination, if it doesn't exist
+ #
+ # We blindly insert without synchronization. If another process
+ # was faster in doing this, we'll realize once we hit the unique key constraint
+ # violation. We can safely roll-back the nested transaction and perform
+ # a lookup instead to retrieve the record.
+ def create_record
+ subject.transaction(requires_new: true) do
+ InternalId.create!(
+ **scope,
+ usage: usage_value,
+ last_value: init.call(subject) || 0
+ )
+ end
+ rescue ActiveRecord::RecordNotUnique
+ lookup
+ end
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 3a9a6dba601..7bfc45c1f43 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -1,17 +1,18 @@
require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base
- include InternalId
+ include AtomicInternalId
include Issuable
include Noteable
include Referable
- include Sortable
include Spammable
include FasterCacheKeys
include RelativePositioning
+ include TimeTrackable
+ include ThrottledTouch
include IgnorableColumn
- ignore_column :position
+ ignore_column :assignee_id, :branch_name, :deleted_at
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -23,15 +24,21 @@ class Issue < ActiveRecord::Base
belongs_to :project
belongs_to :moved_to, class_name: 'Issue'
- has_many :events, as: :target, dependent: :destroy
+ has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) }
- has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :merge_requests_closing_issues,
+ class_name: 'MergeRequestsClosingIssues',
+ dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
validates :project, presence: true
+ alias_attribute :parent_ids, :project_id
+
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
@@ -45,10 +52,10 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
- scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
-
scope :preload_associations, -> { preload(:labels, project: :namespace) }
+ scope :public_only, -> { where(confidential: false) }
+
after_save :expire_etag_cache
attr_spammable :title, spam_title: true
@@ -58,15 +65,14 @@ class Issue < ActiveRecord::Base
state_machine :state, initial: :opened do
event :close do
- transition [:reopened, :opened] => :closed
+ transition [:opened] => :closed
end
event :reopen do
- transition closed: :reopened
+ transition closed: :opened
end
state :opened
- state :reopened
state :closed
before_transition any => :closed do |issue|
@@ -74,18 +80,8 @@ class Issue < ActiveRecord::Base
end
end
- def hook_attrs
- assignee_ids = self.assignee_ids
-
- attrs = {
- total_time_spent: total_time_spent,
- human_total_time_spent: human_total_time_spent,
- human_time_estimate: human_time_estimate,
- assignee_ids: assignee_ids,
- assignee_id: assignee_ids.first # This key is deprecated
- }
-
- attributes.merge!(attrs)
+ class << self
+ alias_method :in_parents, :in_projects
end
def self.reference_prefix
@@ -116,7 +112,8 @@ class Issue < ActiveRecord::Base
def self.sort(method, excluded_labels: [])
case method.to_s
- when 'due_date_asc' then order_due_date_asc
+ when 'due_date' then order_due_date_asc
+ when 'due_date_asc' then order_due_date_asc
when 'due_date_desc' then order_due_date_desc
else
super
@@ -130,6 +127,10 @@ class Issue < ActiveRecord::Base
"id DESC")
end
+ def hook_attrs
+ Gitlab::HookData::IssueBuilder.new(self).build
+ end
+
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
@@ -160,7 +161,18 @@ class Issue < ActiveRecord::Base
object.all_references(current_user, extractor: ext)
end
- ext.merge_requests.sort_by(&:iid)
+ merge_requests = ext.merge_requests.sort_by(&:iid)
+
+ cross_project_filter = -> (merge_requests) do
+ merge_requests.select { |mr| mr.target_project == project }
+ end
+
+ Ability.merge_requests_readable_by_user(
+ merge_requests, current_user,
+ filters: {
+ read_cross_project: cross_project_filter
+ }
+ )
end
# All branches containing the current issue's ID, except for
@@ -254,7 +266,12 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user]
+ if options.key?(:sidebar_endpoints) && project
+ url_helper = Gitlab::Routing.url_helpers
+
+ json.merge!(issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'),
+ toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self))
+ end
if options.key?(:labels)
json[:labels] = labels.as_json(
@@ -266,8 +283,21 @@ class Issue < ActiveRecord::Base
end
end
+ def discussions_rendered_on_frontend?
+ true
+ end
+
+ def update_project_counter_caches
+ Projects::OpenIssuesCountService.new(project).refresh_cache
+ end
+
private
+ def ensure_metrics
+ super
+ metrics.record!
+ end
+
# Returns `true` if the given User can read the current Issue.
#
# This method duplicates the same check of issue_policy.rb
@@ -295,11 +325,7 @@ class Issue < ActiveRecord::Base
end
def expire_etag_cache
- key = Gitlab::Routing.url_helpers.realtime_changes_namespace_project_issue_path(
- project.namespace,
- project,
- self
- )
+ key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
Gitlab::EtagCaching::Store.new.touch(key)
end
end
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index 06d760b6a89..326b9eb7ad5 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -1,6 +1,4 @@
class IssueAssignee < ActiveRecord::Base
- extend Gitlab::CurrentSettings
-
belongs_to :issue
belongs_to :assignee, class_name: "User", foreign_key: :user_id
end
diff --git a/app/models/key.rb b/app/models/key.rb
index cb8f10f6d55..ae5769c0627 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,10 +1,9 @@
require 'digest/md5'
class Key < ActiveRecord::Base
+ include AfterCommitQueue
include Sortable
- LAST_USED_AT_REFRESH_TIME = 1.day.to_i
-
belongs_to :user
before_validation :generate_fingerprint
@@ -12,27 +11,31 @@ class Key < ActiveRecord::Base
validates :title,
presence: true,
length: { maximum: 255 }
+
validates :key,
presence: true,
length: { maximum: 5000 },
format: { with: /\A(ssh|ecdsa)-.*\Z/ }
- validates :key,
- format: { without: /\n|\r/, message: 'should be a single line' }
+
validates :fingerprint,
uniqueness: true,
presence: { message: 'cannot be generated' }
+ validate :key_meets_restrictions
+
delegate :name, :email, to: :user, prefix: true
after_commit :add_to_shell, on: :create
- after_commit :notify_user, on: :create
after_create :post_create_hook
+ after_create :refresh_user_cache
after_commit :remove_from_shell, on: :destroy
after_destroy :post_destroy_hook
+ after_destroy :refresh_user_cache
def key=(value)
- value.strip! unless value.blank?
- write_attribute(:key, value)
+ write_attribute(:key, value.present? ? Gitlab::SSHPublicKey.sanitize(value) : nil)
+
+ @public_key = nil
end
def publishable_key
@@ -51,10 +54,7 @@ class Key < ActiveRecord::Base
end
def update_last_used_at
- lease = Gitlab::ExclusiveLease.new("key_update_last_used_at:#{id}", timeout: LAST_USED_AT_REFRESH_TIME)
- return unless lease.try_obtain
-
- UseKeyWorker.perform_async(id)
+ Keys::LastUsedService.new(self).execute
end
def add_to_shell
@@ -77,21 +77,47 @@ class Key < ActiveRecord::Base
)
end
+ def refresh_user_cache
+ return unless user
+
+ Users::KeysCountService.new(user).refresh_cache
+ end
+
def post_destroy_hook
SystemHooksService.new.execute_hooks_for(self, :destroy)
end
+ def public_key
+ @public_key ||= Gitlab::SSHPublicKey.new(key)
+ end
+
private
def generate_fingerprint
self.fingerprint = nil
- return unless self.key.present?
+ return unless public_key.valid?
- self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint
+ self.fingerprint = public_key.fingerprint
end
- def notify_user
- NotificationService.new.new_key(self)
+ def key_meets_restrictions
+ restriction = Gitlab::CurrentSettings.key_restriction_for(public_key.type)
+
+ if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE
+ errors.add(:key, forbidden_key_type_message)
+ elsif public_key.bits < restriction
+ errors.add(:key, "must be at least #{restriction} bits")
+ end
+ end
+
+ def forbidden_key_type_message
+ allowed_types =
+ Gitlab::CurrentSettings
+ .allowed_key_types
+ .map(&:upcase)
+ .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+
+ "type is forbidden. Must be #{allowed_types}"
end
end
diff --git a/app/models/label.rb b/app/models/label.rb
index ed6a8411da9..de7f1d56c64 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -15,9 +15,9 @@ class Label < ActiveRecord::Base
default_value_for :color, DEFAULT_COLOR
- has_many :lists, dependent: :destroy
+ has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :priorities, class_name: 'LabelPriority'
- has_many :label_links, dependent: :destroy
+ has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
@@ -34,7 +34,9 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) }
- scope :on_project_boards, ->(project_id) { joins(lists: :board).merge(List.movable).where(boards: { project_id: project_id }) }
+ scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
+ scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
+ scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
def self.prioritized(project)
joins(:priorities)
@@ -126,7 +128,13 @@ class Label < ActiveRecord::Base
end
def priority(project)
- priorities.find_by(project: project).try(:priority)
+ priority = if priorities.loaded?
+ priorities.first { |p| p.project == project }
+ else
+ priorities.find_by(project: project)
+ end
+
+ priority.try(:priority)
end
def template?
@@ -159,12 +167,12 @@ class Label < ActiveRecord::Base
#
# Returns a String
#
- def to_reference(from_project = nil, target_project: nil, format: :id, full: false)
+ def to_reference(from = nil, target_project: nil, format: :id, full: false)
format_reference = label_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
- if from_project
- "#{from_project.to_reference(target_project, full: full)}#{reference}"
+ if from
+ "#{from.to_reference(target_project, full: full)}#{reference}"
else
reference
end
@@ -172,6 +180,7 @@ class Label < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
+ json[:type] = self.try(:type)
json[:priority] = priority(options[:project]) if options.key?(:project)
end
end
diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb
index 3c1d34db5fa..80fc6304fd4 100644
--- a/app/models/legacy_diff_discussion.rb
+++ b/app/models/legacy_diff_discussion.rb
@@ -17,6 +17,14 @@ class LegacyDiffDiscussion < Discussion
true
end
+ def on_image?
+ false
+ end
+
+ def on_text?
+ true
+ end
+
def active?(*args)
return @active if @active.present?
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index 2d5909ab25e..d90cafd14b4 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 # rubocop:disable Cop/ActiverecordSerialize
+ serialize :st_diff # rubocop:disable Cop/ActiveRecordSerialize
validates :line_code, presence: true, line_code: true
@@ -38,11 +38,7 @@ class LegacyDiffNote < Note
end
def diff_line
- @diff_line ||= diff_file.line_for_line_code(self.line_code) if diff_file
- end
-
- def for_line?(line)
- line.discussable? && diff_file.line_code(line) == self.line_code
+ @diff_line ||= diff_file&.line_for_line_code(self.line_code)
end
def original_line_code
diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb
new file mode 100644
index 00000000000..50bb6ca382d
--- /dev/null
+++ b/app/models/lfs_file_lock.rb
@@ -0,0 +1,12 @@
+class LfsFileLock < ActiveRecord::Base
+ belongs_to :project
+ belongs_to :user
+
+ validates :project_id, :user_id, :path, presence: true
+
+ def can_be_unlocked_by?(current_user, forced = false)
+ return true if current_user.id == user_id
+
+ forced && current_user.can?(:admin_project, project)
+ end
+end
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 7712d5783e0..b444812a4cf 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -1,21 +1,13 @@
class LfsObject < ActiveRecord::Base
- has_many :lfs_objects_projects, dependent: :destroy
+ has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :lfs_objects_projects
validates :oid, presence: true, uniqueness: true
mount_uploader :file, LfsObjectUploader
- def storage_project(project)
- if project && project.forked?
- storage_project(project.forked_from_project)
- else
- project
- end
- end
-
def project_allowed_access?(project)
- projects.exists?(storage_project(project).id)
+ projects.exists?(project.lfs_storage_project.id)
end
def self.destroy_unreferenced
@@ -23,4 +15,8 @@ class LfsObject < ActiveRecord::Base
.where(lfs_objects_projects: { id: nil })
.destroy_all
end
+
+ def self.calculate_oid(path)
+ Digest::SHA256.file(path).hexdigest
+ end
end
diff --git a/app/models/member.rb b/app/models/member.rb
index dc9247bc9a0..e1a32148538 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,8 +1,10 @@
class Member < ActiveRecord::Base
+ include AfterCommitQueue
include Sortable
include Importable
include Expirable
include Gitlab::Access
+ include Presentable
attr_accessor :raw_invite_token
@@ -41,9 +43,20 @@ class Member < ActiveRecord::Base
is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil))
user_is_active = User.arel_table[:state].eq(:active)
- includes(:user).references(:users)
- .where(is_external_invite.or(user_is_active))
+ user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_active)
+
+ left_join_users
+ .where(user_ok)
.where(requested_at: nil)
+ .reorder(nil)
+ end
+
+ # Like active, but without invites. For when a User is required.
+ scope :active_without_invites_and_requests, -> do
+ left_join_users
+ .where(users: { state: 'active' })
+ .non_request
+ .reorder(nil)
end
scope :invite, -> { where.not(invite_token: nil) }
@@ -72,6 +85,7 @@ class Member < ActiveRecord::Base
after_create :create_notification_setting, unless: [:pending?, :importing?]
after_create :post_create_hook, unless: [:pending?, :importing?]
after_update :post_update_hook, unless: [:pending?, :importing?]
+ after_destroy :destroy_notification_setting
after_destroy :post_destroy_hook, unless: :pending?
after_commit :refresh_member_authorized_projects
@@ -115,20 +129,11 @@ class Member < ActiveRecord::Base
find_by(invite_token: invite_token)
end
- def add_user(source, user, access_level, current_user: nil, expires_at: nil)
- user = retrieve_user(user)
+ def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil, ldap: false)
+ # `user` can be either a User object, User ID or an email to be invited
+ member = retrieve_member(source, user, existing_members)
access_level = retrieve_access_level(access_level)
- # `user` can be either a User object or an email to be invited
- member =
- if user.is_a?(User)
- source.members.find_by(user_id: user.id) ||
- source.requesters.find_by(user_id: user.id) ||
- source.members.build(user_id: user.id)
- else
- source.members.build(invite_email: user)
- end
-
return member unless can_update_member?(current_user, member)
member.attributes = {
@@ -139,11 +144,13 @@ class Member < ActiveRecord::Base
if member.request?
::Members::ApproveAccessRequestService.new(
- source,
current_user,
- id: member.id,
access_level: access_level
- ).execute
+ ).execute(
+ member,
+ skip_authorization: ldap,
+ skip_log_audit_event: ldap
+ )
else
member.save
end
@@ -154,17 +161,15 @@ class Member < ActiveRecord::Base
def add_users(source, users, access_level, current_user: nil, expires_at: nil)
return [] unless users.present?
- # Collect all user ids into separate array
- # so we can use single sql query to get user objects
- user_ids = users.select { |user| user =~ /\A\d+\Z/ }
- users = users - user_ids + User.where(id: user_ids)
+ emails, users, existing_members = parse_users_list(source, users)
self.transaction do
- users.map do |user|
+ (emails + users).map! do |user|
add_user(
source,
user,
access_level,
+ existing_members: existing_members,
current_user: current_user,
expires_at: expires_at
)
@@ -178,6 +183,31 @@ class Member < ActiveRecord::Base
private
+ def parse_users_list(source, list)
+ emails, user_ids, users = [], [], []
+ existing_members = {}
+
+ list.each do |item|
+ case item
+ when User
+ users << item
+ when Integer
+ user_ids << item
+ when /\A\d+\Z/
+ user_ids << item.to_i
+ when Devise.email_regexp
+ emails << item
+ end
+ end
+
+ if user_ids.present?
+ users.concat(User.where(id: user_ids))
+ existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id)
+ end
+
+ [emails, users, existing_members]
+ end
+
# This method is used to find users that have been entered into the "Add members" field.
# These can be the User objects directly, their IDs, their emails, or new emails to be invited.
def retrieve_user(user)
@@ -186,6 +216,20 @@ class Member < ActiveRecord::Base
User.find_by(id: user) || User.find_by(email: user) || user
end
+ def retrieve_member(source, user, existing_members)
+ user = retrieve_user(user)
+
+ if user.is_a?(User)
+ if existing_members
+ existing_members[user.id] || source.members.build(user_id: user.id)
+ else
+ source.members_and_requesters.find_or_initialize_by(user_id: user.id)
+ end
+ else
+ source.members.build(invite_email: user)
+ end
+ end
+
def retrieve_access_level(access_level)
access_levels.fetch(access_level) { access_level.to_i }
end
@@ -272,8 +316,19 @@ class Member < ActiveRecord::Base
user.notification_settings.find_or_create_for(source)
end
+ def destroy_notification_setting
+ notification_setting&.destroy
+ end
+
def notification_setting
- @notification_setting ||= user.notification_settings_for(source)
+ @notification_setting ||= user&.notification_settings_for(source)
+ end
+
+ def notifiable?(type, opts = {})
+ # always notify when there isn't a user yet
+ return true if user.blank?
+
+ NotificationRecipientService.notifiable?(user, type, notifiable_options.merge(opts))
end
private
@@ -332,4 +387,8 @@ class Member < ActiveRecord::Base
def notification_service
NotificationService.new
end
+
+ def notifiable_options
+ {}
+ end
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 47040f95533..661e668dbf9 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -30,6 +30,10 @@ class GroupMember < Member
'Group'
end
+ def notifiable_options
+ { group: group }
+ end
+
private
def send_invite
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index c0e17f4bfc8..1c7ed4a96df 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -13,8 +13,6 @@ class ProjectMember < Member
scope :in_project, ->(project) { where(source_id: project.id) }
- before_destroy :delete_member_todos
-
class << self
# Add users to projects with passed access option
#
@@ -87,12 +85,12 @@ class ProjectMember < Member
project.owner == user
end
- private
-
- def delete_member_todos
- user.todos.where(project_id: source_id).destroy_all if user
+ def notifiable_options
+ { project: project }
end
+ private
+
def send_invite
notification_service.invite_project_member(self, @raw_invite_token)
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index c099d731082..7e6d89ec9c7 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,37 +1,62 @@
class MergeRequest < ActiveRecord::Base
- include InternalId
+ include NonatomicInternalId
include Issuable
include Noteable
include Referable
- include Sortable
include IgnorableColumn
+ include TimeTrackable
+ include ManualInverseAssociation
+ include EachBatch
+ include ThrottledTouch
+ include Gitlab::Utils::StrongMemoize
- ignore_column :position
+ ignore_column :locked_at,
+ :ref_fetched,
+ :deleted_at
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
- has_many :merge_request_diffs, dependent: :destroy
+ has_many :merge_request_diffs
+
has_one :merge_request_diff,
- -> { order('merge_request_diffs.id DESC') }
+ -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request
+
+ belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff'
+ manual_inverse_association :latest_merge_request_diff, :merge_request
+
+ # This is the same as latest_merge_request_diff unless:
+ # 1. There are arguments - in which case we might be trying to force-reload.
+ # 2. This association is already loaded.
+ # 3. The latest diff does not exist.
+ #
+ # The second one in particular is important - MergeRequestDiff#merge_request
+ # is the inverse of MergeRequest#merge_request_diff, which means it may not be
+ # the latest diff, because we could have loaded any diff from this particular
+ # MR. If we haven't already loaded a diff, then it's fine to load the latest.
+ def merge_request_diff(*args)
+ fallback = latest_merge_request_diff if args.empty? && !association(:merge_request_diff).loaded?
+
+ fallback || super
+ end
belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline"
- has_many :events, as: :target, dependent: :destroy
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
+ has_many :merge_requests_closing_issues,
+ class_name: 'MergeRequestsClosingIssues',
+ dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
belongs_to :assignee, class_name: "User"
- serialize :merge_params, Hash # rubocop:disable Cop/ActiverecordSerialize
+ serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize
after_create :ensure_merge_request_diff, unless: :importing?
+ after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
- delegate :commits, :real_size, :commits_sha, :commits_count,
- to: :merge_request_diff, prefix: nil
-
# When this attribute is true some MR validation is ignored
# It allows us to close or modify broken merge requests
attr_accessor :allow_broken
@@ -42,37 +67,34 @@ class MergeRequest < ActiveRecord::Base
state_machine :state, initial: :opened do
event :close do
- transition [:reopened, :opened] => :closed
+ transition [:opened] => :closed
end
event :mark_as_merged do
- transition [:reopened, :opened, :locked] => :merged
+ transition [:opened, :locked] => :merged
end
event :reopen do
- transition closed: :reopened
+ transition closed: :opened
end
event :lock_mr do
- transition [:reopened, :opened] => :locked
+ transition [:opened] => :locked
end
event :unlock_mr do
- transition locked: :reopened
+ transition locked: :opened
end
- after_transition any => :locked do |merge_request, transition|
- merge_request.locked_at = Time.now
- merge_request.save
- end
+ before_transition any => :opened do |merge_request|
+ merge_request.merge_jid = nil
- after_transition locked: (any - :locked) do |merge_request, transition|
- merge_request.locked_at = nil
- merge_request.save
+ merge_request.run_after_commit do
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ end
end
state :opened
- state :reopened
state :closed
state :merged
state :locked
@@ -118,7 +140,9 @@ class MergeRequest < ActiveRecord::Base
scope :merged, -> { with_state(:merged) }
scope :closed_and_merged, -> { with_states(:closed, :merged) }
scope :from_source_branches, ->(branches) { where(source_branch: branches) }
-
+ scope :by_commit_sha, ->(sha) do
+ where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil)
+ end
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
scope :assigned, -> { where("assignee_id IS NOT NULL") }
@@ -133,6 +157,22 @@ class MergeRequest < ActiveRecord::Base
'!'
end
+ def rebase_in_progress?
+ strong_memoize(:rebase_in_progress) do
+ # The source project can be deleted
+ next false unless source_project
+
+ source_project.repository.rebase_in_progress?(id)
+ end
+ end
+
+ # Use this method whenever you need to make sure the head_pipeline is synced with the
+ # branch head commit, for example checking if a merge request can be merged.
+ # For more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/40004
+ def actual_head_pipeline
+ head_pipeline&.sha == diff_head_sha ? head_pipeline : nil
+ end
+
# Pattern used to extract `!123` merge request references from text
#
# This pattern supports cross-project references.
@@ -172,7 +212,23 @@ class MergeRequest < ActiveRecord::Base
target = unscoped.where(target_project_id: relation).select(:id)
union = Gitlab::SQL::Union.new([source, target])
- where("merge_requests.id IN (#{union.to_sql})")
+ where("merge_requests.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
+ end
+
+ # This is used after project import, to reset the IDs to the correct
+ # values. It is not intended to be called without having already scoped the
+ # relation.
+ def self.set_latest_merge_request_diff_ids!
+ update = '
+ latest_merge_request_diff_id = (
+ SELECT MAX(id)
+ FROM merge_request_diffs
+ WHERE merge_requests.id = merge_request_diffs.merge_request_id
+ )'.squish
+
+ self.each_batch do |batch|
+ batch.update_all(update)
+ end
end
WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze
@@ -189,6 +245,16 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}"
end
+ # Verifies if title has changed not taking into account WIP prefix
+ # for merge requests.
+ def wipless_title_changed(old_title)
+ self.class.wipless_title(old_title) != self.wipless_title
+ end
+
+ def hook_attrs
+ Gitlab::HookData::MergeRequestBuilder.new(self).build
+ end
+
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
@@ -197,11 +263,19 @@ class MergeRequest < ActiveRecord::Base
}
end
- # This method is needed for compatibility with issues to not mess view and other code
+ # These method are needed for compatibility with issues to not mess view and other code
def assignees
Array(assignee)
end
+ def assignee_ids
+ Array(assignee_id)
+ end
+
+ def assignee_ids=(ids)
+ write_attribute(:assignee_id, ids.last)
+ end
+
def assignee_or_author?(user)
author_id == user.id || assignee_id == user.id
end
@@ -213,6 +287,44 @@ class MergeRequest < ActiveRecord::Base
"#{project.to_reference(from, full: full)}#{reference}"
end
+ def commits
+ if persisted?
+ merge_request_diff.commits
+ elsif compare_commits
+ compare_commits.reverse
+ else
+ []
+ end
+ end
+
+ def commits_count
+ if persisted?
+ merge_request_diff.commits_count
+ elsif compare_commits
+ compare_commits.size
+ else
+ 0
+ end
+ end
+
+ def commit_shas
+ if persisted?
+ merge_request_diff.commit_shas
+ elsif compare_commits
+ compare_commits.to_a.reverse.map(&:sha)
+ else
+ Array(diff_head_sha)
+ end
+ end
+
+ # Calls `MergeWorker` to proceed with the merge process and
+ # updates `merge_jid` with the MergeWorker#jid.
+ # This helps tracking enqueued and ongoing merge jobs.
+ def merge_async(user_id, params)
+ jid = MergeWorker.perform_async(id, user_id, params)
+ update_column(:merge_jid, jid)
+ end
+
def first_commit
merge_request_diff ? merge_request_diff.first_commit : compare_commits.first
end
@@ -235,9 +347,7 @@ class MergeRequest < ActiveRecord::Base
def diff_size
# Calling `merge_request_diff.diffs.real_size` will also perform
# highlighting, which we don't need here.
- return real_size if merge_request_diff
-
- diffs.real_size
+ merge_request_diff&.real_size || diffs.real_size
end
def diff_base_commit
@@ -265,15 +375,27 @@ class MergeRequest < ActiveRecord::Base
end
def diff_start_sha
- diff_start_commit.try(:sha)
+ if persisted?
+ merge_request_diff.start_commit_sha
+ else
+ target_branch_head.try(:sha)
+ end
end
def diff_base_sha
- diff_base_commit.try(:sha)
+ if persisted?
+ merge_request_diff.base_commit_sha
+ else
+ branch_merge_base_commit.try(:sha)
+ end
end
def diff_head_sha
- diff_head_commit.try(:sha)
+ if persisted?
+ merge_request_diff.head_commit_sha
+ else
+ source_branch_head.try(:sha)
+ end
end
# When importing a pull request from GitHub, the old and new branches may no
@@ -282,16 +404,32 @@ class MergeRequest < ActiveRecord::Base
# We use these attributes to force these to the intended values.
attr_writer :target_branch_sha, :source_branch_sha
- def source_branch_head
- return unless source_project
+ def source_branch_ref
+ return @source_branch_sha if @source_branch_sha
+ return unless source_branch
+
+ Gitlab::Git::BRANCH_REF_PREFIX + source_branch
+ end
+
+ def target_branch_ref
+ return @target_branch_sha if @target_branch_sha
+ return unless target_branch
- source_branch_ref = @source_branch_sha || source_branch
- source_project.repository.commit(source_branch_ref) if source_branch_ref
+ Gitlab::Git::BRANCH_REF_PREFIX + target_branch
+ end
+
+ def source_branch_head
+ strong_memoize(:source_branch_head) do
+ if source_project && source_branch_ref
+ source_project.repository.commit(source_branch_ref)
+ end
+ end
end
def target_branch_head
- target_branch_ref = @target_branch_sha || target_branch
- target_project.repository.commit(target_branch_ref) if target_branch_ref
+ strong_memoize(:target_branch_head) do
+ target_project.repository.commit(target_branch_ref)
+ end
end
def branch_merge_base_commit
@@ -332,7 +470,7 @@ class MergeRequest < ActiveRecord::Base
errors.add :branch_conflict, "You can not use same project/branch for source and target"
end
- if opened? || reopened?
+ if opened?
similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.try(:id)).opened
similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id
if similar_mrs.any?
@@ -357,6 +495,14 @@ class MergeRequest < ActiveRecord::Base
'Source project is not a fork of the target project'
end
+ def merge_ongoing?
+ # While the MergeRequest is locked, it should present itself as 'merge ongoing'.
+ # The unlocking process is handled by StuckMergeJobsWorker scheduled in Cron.
+ return true if locked?
+
+ !!merge_jid && !merged? && Gitlab::SidekiqStatus.running?(merge_jid)
+ end
+
def closed_without_fork?
closed? && source_project_missing?
end
@@ -365,7 +511,7 @@ class MergeRequest < ActiveRecord::Base
return false unless for_fork?
return true unless source_project
- !source_project.forked_from?(target_project)
+ !source_project.in_fork_network_of?(target_project)
end
def reopenable?
@@ -377,8 +523,13 @@ class MergeRequest < ActiveRecord::Base
end
def create_merge_request_diff
- merge_request_diffs.create
- reload_merge_request_diff
+ fetch_ref!
+
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37435
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ merge_request_diffs.create
+ reload_merge_request_diff
+ end
end
def reload_merge_request_diff
@@ -387,7 +538,7 @@ class MergeRequest < ActiveRecord::Base
def merge_request_diff_for(diff_refs_or_sha)
@merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha|
- diffs = merge_request_diffs.viewable.select_without_diff
+ diffs = merge_request_diffs.viewable
h[diff_refs_or_sha] =
if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs)
diffs.find_by_diff_refs(diff_refs_or_sha)
@@ -410,8 +561,16 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def clear_memoized_shas
+ @target_branch_sha = @source_branch_sha = nil
+
+ clear_memoization(:source_branch_head)
+ clear_memoization(:target_branch_head)
+ end
+
def reload_diff_if_branch_changed
- if source_branch_changed? || target_branch_changed?
+ if (source_branch_changed? || target_branch_changed?) &&
+ (source_branch_head && target_branch_head)
reload_diff
end
end
@@ -420,8 +579,10 @@ class MergeRequest < ActiveRecord::Base
return unless open?
old_diff_refs = self.diff_refs
- create_merge_request_diff
- MergeRequests::MergeRequestDiffCacheService.new.execute(self)
+ new_diff = create_merge_request_diff
+
+ MergeRequests::MergeRequestDiffCacheService.new.execute(self, new_diff)
+
new_diff_refs = self.diff_refs
update_diff_discussion_positions(
@@ -432,7 +593,7 @@ class MergeRequest < ActiveRecord::Base
end
def check_if_can_be_merged
- return unless unchecked?
+ return unless unchecked? && Gitlab::Database.read_write?
can_be_merged =
!broken? && project.repository.can_be_merged?(diff_head_sha, target_branch)
@@ -469,19 +630,27 @@ class MergeRequest < ActiveRecord::Base
check_if_can_be_merged
- can_be_merged?
+ can_be_merged? && !should_be_rebased?
end
- def mergeable_state?(skip_ci_check: false)
+ def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
return false unless open?
return false if work_in_progress?
return false if broken?
return false unless skip_ci_check || mergeable_ci_state?
- return false unless mergeable_discussions_state?
+ return false unless skip_discussions_check || mergeable_discussions_state?
true
end
+ def ff_merge_possible?
+ project.repository.ancestor?(target_branch_sha, diff_head_sha)
+ end
+
+ def should_be_rebased?
+ project.ff_merge_must_be_possible? && !ff_merge_possible?
+ end
+
def can_cancel_merge_when_pipeline_succeeds?(current_user)
can_be_merged_by?(current_user) || self.author == current_user
end
@@ -490,7 +659,7 @@ class MergeRequest < ActiveRecord::Base
!ProtectedBranch.protected?(source_project, source_branch) &&
!source_project.root_ref?(source_branch) &&
Ability.allowed?(current_user, :push_code, source_project) &&
- diff_head_commit == source_branch_head
+ diff_head_sha == source_branch_head.try(:sha)
end
def should_remove_source_branch?
@@ -508,16 +677,23 @@ class MergeRequest < ActiveRecord::Base
def related_notes
# Fetch comments only from last 100 commits
commits_for_notes_limit = 100
- commit_ids = commits.last(commits_for_notes_limit).map(&:id)
-
- Note.where(
- "(project_id = :target_project_id AND noteable_type = 'MergeRequest' AND noteable_id = :mr_id) OR" +
- "((project_id = :source_project_id OR project_id = :target_project_id) AND noteable_type = 'Commit' AND commit_id IN (:commit_ids))",
- mr_id: id,
- commit_ids: commit_ids,
- target_project_id: target_project_id,
- source_project_id: source_project_id
- )
+ commit_ids = commit_shas.take(commits_for_notes_limit)
+
+ commit_notes = Note
+ .except(:order)
+ .where(project_id: [source_project_id, target_project_id])
+ .for_commit_id(commit_ids)
+
+ # We're using a UNION ALL here since this results in better performance
+ # compared to using OR statements. We're using UNION ALL since the queries
+ # used won't produce any duplicates (e.g. a note for a commit can't also be
+ # a note for an MR).
+ union = Gitlab::SQL::Union
+ .new([notes, commit_notes], remove_duplicates: false)
+ .to_sql
+
+ Note.from("(#{union}) #{Note.table_name}")
+ .includes(:noteable)
end
alias_method :discussion_notes, :related_notes
@@ -528,24 +704,6 @@ class MergeRequest < ActiveRecord::Base
!discussions_to_be_resolved?
end
- def hook_attrs
- attrs = {
- source: source_project.try(:hook_attrs),
- target: target_project.hook_attrs,
- last_commit: nil,
- work_in_progress: work_in_progress?,
- total_time_spent: total_time_spent,
- human_total_time_spent: human_total_time_spent,
- human_time_estimate: human_time_estimate
- }
-
- if diff_head_commit
- attrs[:last_commit] = diff_head_commit.hook_attrs
- end
-
- attributes.merge!(attrs)
- end
-
def for_fork?
target_project != source_project
end
@@ -560,12 +718,14 @@ class MergeRequest < ActiveRecord::Base
# running `ReferenceExtractor` on each of them separately.
# This optimization does not apply to issues from external sources.
def cache_merge_request_closes_issues!(current_user)
- return if project.has_external_issue_tracker?
+ return unless project.issues_enabled?
transaction do
self.merge_requests_closing_issues.delete_all
closes_issues(current_user).each do |issue|
+ next if issue.is_a?(ExternalIssue)
+
self.merge_requests_closing_issues.create!(issue: issue)
end
end
@@ -595,7 +755,7 @@ class MergeRequest < ActiveRecord::Base
def target_project_path
if target_project
- target_project.path_with_namespace
+ target_project.full_path
else
"(removed)"
end
@@ -603,7 +763,7 @@ class MergeRequest < ActiveRecord::Base
def source_project_path
if source_project
- source_project.path_with_namespace
+ source_project.full_path
else
"(removed)"
end
@@ -628,13 +788,13 @@ class MergeRequest < ActiveRecord::Base
def source_branch_exists?
return false unless self.source_project
- self.source_project.repository.branch_names.include?(self.source_branch)
+ self.source_project.repository.branch_exists?(self.source_branch)
end
def target_branch_exists?
return false unless self.target_project
- self.target_project.repository.branch_names.include?(self.target_branch)
+ self.target_project.repository.branch_exists?(self.target_branch)
end
def merge_commit_message(include_description: false)
@@ -652,7 +812,7 @@ class MergeRequest < ActiveRecord::Base
end
message << "#{description}" if include_description && description.present?
- message << "See merge request #{to_reference}"
+ message << "See merge request #{to_reference(full: true)}"
message.join("\n\n")
end
@@ -690,17 +850,10 @@ class MergeRequest < ActiveRecord::Base
end
end
- def locked_long_ago?
- return false unless locked?
-
- locked_at.nil? || locked_at < (Time.now - 1.day)
- end
-
def has_ci?
- has_ci_integration = source_project.try(:ci_service)
- uses_gitlab_ci = all_pipelines.any?
+ return false if has_no_commits?
- (has_ci_integration || uses_gitlab_ci) && commits.any?
+ !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_service)
end
def branch_missing?
@@ -713,7 +866,7 @@ class MergeRequest < ActiveRecord::Base
def can_be_merged_by?(user)
access = ::Gitlab::UserAccess.new(user, project: project)
- access.can_push_to_branch?(target_branch) || access.can_merge_to_branch?(target_branch)
+ access.can_update_branch?(target_branch)
end
def can_be_merged_via_command_line_by?(user)
@@ -723,8 +876,9 @@ class MergeRequest < ActiveRecord::Base
def mergeable_ci_state?
return true unless project.only_allow_merge_if_pipeline_succeeds?
+ return true unless head_pipeline
- !head_pipeline || head_pipeline.success? || head_pipeline.skipped?
+ actual_head_pipeline&.success? || actual_head_pipeline&.skipped?
end
def environments_for(current_user)
@@ -757,39 +911,20 @@ class MergeRequest < ActiveRecord::Base
def state_icon_name
if merged?
- "check"
+ "git-merge"
elsif closed?
- "times"
+ "close"
else
- "circle-o"
+ "issue-open-m"
end
end
- def fetch_ref
- target_project.repository.fetch_ref(
- source_project.repository.path_to_repo,
- "refs/heads/#{source_branch}",
- ref_path
- )
- update_column(:ref_fetched, true)
+ def fetch_ref!
+ target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path)
end
def ref_path
- "refs/merge-requests/#{iid}/head"
- end
-
- def ref_fetched?
- super ||
- begin
- computed_value = project.repository.ref_exists?(ref_path)
- update_column(:ref_fetched, true) if computed_value
-
- computed_value
- end
- end
-
- def ensure_ref_fetched
- fetch_ref unless ref_fetched?
+ "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head"
end
def in_locked_state
@@ -797,7 +932,7 @@ class MergeRequest < ActiveRecord::Base
lock_mr
yield
ensure
- unlock_mr if locked?
+ unlock_mr
end
end
@@ -819,7 +954,8 @@ class MergeRequest < ActiveRecord::Base
def compute_diverged_commits_count
return 0 unless source_branch_sha && target_branch_sha
- Gitlab::Git::Commit.between(target_project.repository.raw_repository, source_branch_sha, target_branch_sha).size
+ target_project.repository
+ .count_commits_between(source_branch_sha, target_branch_sha)
end
private :compute_diverged_commits_count
@@ -831,19 +967,30 @@ class MergeRequest < ActiveRecord::Base
return Ci::Pipeline.none unless source_project
@all_pipelines ||= source_project.pipelines
- .where(sha: all_commits_sha, ref: source_branch)
+ .where(sha: all_commit_shas, ref: source_branch)
.order(id: :desc)
end
+ def all_commits
+ # MySQL doesn't support LIMIT in a subquery.
+ diffs_relation = if Gitlab::Database.postgresql?
+ merge_request_diffs.recent
+ else
+ merge_request_diffs
+ end
+
+ MergeRequestDiffCommit
+ .where(merge_request_diff: diffs_relation)
+ .limit(10_000)
+ end
+
# Note that this could also return SHA from now dangling commits
#
- def all_commits_sha
- if persisted?
- merge_request_diffs.flat_map(&:commits_sha).uniq
- elsif compare_commits
- compare_commits.to_a.reverse.map(&:id)
- else
- [diff_head_sha]
+ def all_commit_shas
+ @all_commit_shas ||= begin
+ return commit_shas unless persisted?
+
+ all_commits.pluck(:sha).uniq
end
end
@@ -852,7 +999,22 @@ class MergeRequest < ActiveRecord::Base
end
def can_be_reverted?(current_user)
- merge_commit && !merge_commit.has_been_reverted?(current_user, self)
+ return false unless merge_commit
+
+ merged_at = metrics&.merged_at
+ notes_association = notes_with_associations
+
+ if merged_at
+ # It is not guaranteed that Note#created_at will be strictly later than
+ # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
+ # comparison, as will a HA environment if clocks are not *precisely*
+ # synchronized. Add a minute's leeway to compensate for both possibilities
+ cutoff = merged_at - 1.minute
+
+ notes_association = notes_association.where('created_at >= ?', cutoff)
+ end
+
+ !merge_commit.has_been_reverted?(current_user, notes_association)
end
def can_be_cherry_picked?
@@ -885,6 +1047,12 @@ class MergeRequest < ActiveRecord::Base
active_diff_discussions.each do |discussion|
service.execute(discussion)
end
+
+ if project.resolve_outdated_diff_discussions?
+ MergeRequests::ResolvedDiscussionNotificationService
+ .new(project, current_user)
+ .execute(self)
+ end
end
def keep_around_commit
@@ -905,9 +1073,37 @@ class MergeRequest < ActiveRecord::Base
return true if autocomplete_precheck
return false unless mergeable?(skip_ci_check: true)
- return false if head_pipeline && !(head_pipeline.success? || head_pipeline.active?)
+ return false if actual_head_pipeline && !(actual_head_pipeline.success? || actual_head_pipeline.active?)
return false if last_diff_sha != diff_head_sha
true
end
+
+ def update_project_counter_caches
+ Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache
+ end
+
+ def first_contribution?
+ return false if project.team.max_member_access(author_id) > Gitlab::Access::GUEST
+
+ project.merge_requests.merged.where(author_id: author_id).empty?
+ end
+
+ def allow_maintainer_to_push
+ maintainer_push_possible? && super
+ end
+
+ alias_method :allow_maintainer_to_push?, :allow_maintainer_to_push
+
+ def maintainer_push_possible?
+ source_project.present? && for_fork? &&
+ target_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE &&
+ source_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE &&
+ !ProtectedBranch.protected?(source_project, source_branch)
+ end
+
+ def can_allow_maintainer_to_push?(user)
+ maintainer_push_possible? &&
+ Ability.allowed?(user, :push_code, source_project)
+ end
end
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index cdc408738be..9e660eccd86 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,12 +1,6 @@
class MergeRequest::Metrics < ActiveRecord::Base
belongs_to :merge_request
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
-
- def record!
- if merge_request.merged? && self.merged_at.blank?
- self.merged_at = Time.now
- end
-
- self.save
- end
+ belongs_to :latest_closed_by, class_name: 'User'
+ belongs_to :merged_by, class_name: 'User'
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index f1ee4d3f7a9..c1c27ccf3e5 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,19 +1,20 @@
class MergeRequestDiff < ActiveRecord::Base
include Sortable
include Importable
- include Gitlab::EncodingHelper
+ include ManualInverseAssociation
+ include IgnorableColumn
- # Prevent store of diff if commits amount more then 500
+ # Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
- # Valid types of serialized diffs allowed by Gitlab::Git::Diff
- VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta].freeze
+ ignore_column :st_commits,
+ :st_diffs
belongs_to :merge_request
- has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ manual_inverse_association :merge_request, :merge_request_diff
- serialize :st_commits # rubocop:disable Cop/ActiverecordSerialize
- serialize :st_diffs # rubocop:disable Cop/ActiverecordSerialize
+ has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
state_machine :state, initial: :empty do
state :collected
@@ -27,6 +28,11 @@ class MergeRequestDiff < ActiveRecord::Base
end
scope :viewable, -> { without_state(:empty) }
+ scope :by_commit_sha, ->(sha) do
+ joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
+ end
+
+ scope :recent, -> { order(id: :desc).limit(100) }
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
@@ -36,37 +42,31 @@ class MergeRequestDiff < ActiveRecord::Base
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
end
- def self.select_without_diff
- select(column_names - ['st_diffs'])
- end
-
- def st_commits
- super || []
- end
-
# Collect information about commits and diff from repository
# and save it to the database as serialized data
def save_git_content
- ensure_commits_sha
+ MergeRequest
+ .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id)
+ .update_all(latest_merge_request_diff_id: self.id)
+
+ ensure_commit_shas
save_commits
- reload_commits
save_diffs
+ save
keep_around_commits
end
- def ensure_commits_sha
- merge_request.fetch_ref
+ def ensure_commit_shas
self.start_commit_sha ||= merge_request.target_branch_sha
self.head_commit_sha ||= merge_request.source_branch_sha
self.base_commit_sha ||= find_base_sha
- save
end
# Override head_commit_sha to keep compatibility with merge request diff
# created before version 8.4 that does not store head_commit_sha in separate db field.
def head_commit_sha
if persisted? && super.nil?
- last_commit.try(:sha)
+ last_commit_sha
else
super
end
@@ -85,11 +85,7 @@ class MergeRequestDiff < ActiveRecord::Base
def raw_diffs(options = {})
if options[:ignore_whitespace_change]
- @diffs_no_whitespace ||=
- Gitlab::Git::Compare.new(
- repository.raw_repository,
- safe_start_commit_sha,
- head_commit_sha).diffs(options)
+ @diffs_no_whitespace ||= compare.diffs(options)
else
@raw_diffs ||= {}
@raw_diffs[options] ||= load_diffs(options)
@@ -97,16 +93,11 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commits
- @commits ||= load_commits(st_commits)
- end
-
- def reload_commits
- @commits = nil
- commits
+ @commits ||= load_commits
end
- def last_commit
- commits.first
+ def last_commit_sha
+ commit_shas.first
end
def first_commit
@@ -116,23 +107,23 @@ class MergeRequestDiff < ActiveRecord::Base
def base_commit
return unless base_commit_sha
- project.commit(base_commit_sha)
+ project.commit_by(oid: base_commit_sha)
end
def start_commit
return unless start_commit_sha
- project.commit(start_commit_sha)
+ project.commit_by(oid: start_commit_sha)
end
def head_commit
return unless head_commit_sha
- project.commit(head_commit_sha)
+ project.commit_by(oid: head_commit_sha)
end
- def commits_sha
- st_commits.map { |commit| commit[:id] }
+ def commit_shas
+ merge_request_diff_commits.map(&:sha)
end
def diff_refs=(new_diff_refs)
@@ -196,7 +187,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def latest?
- self == merge_request.merge_request_diff
+ self.id == merge_request.latest_merge_request_diff_id
end
def compare_with(sha)
@@ -207,68 +198,35 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commits_count
- st_commits.count
- end
-
- def utf8_st_diffs
- return [] if st_diffs.blank?
-
- st_diffs.map do |diff|
- diff.each do |k, v|
- diff[k] = encode_utf8(v) if v.respond_to?(:encoding)
- end
- end
+ super || merge_request_diff_commits.size
end
private
- # Old GitLab implementations may have generated diffs as ["--broken-diff"].
- # Avoid an error 500 by ignoring bad elements. See:
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/20776
- def valid_raw_diff?(raw)
- return false unless raw.respond_to?(:each)
-
- raw.any? { |element| VALID_CLASSES.include?(element.class) }
- end
-
- def dump_commits(commits)
- commits.map(&:to_hash)
- end
-
- def load_commits(array)
- array.map { |hash| Commit.new(Gitlab::Git::Commit.new(hash), merge_request.source_project) }
- end
-
- # Load all commits related to current merge request diff from repo
- # and save it as array of hashes in st_commits db field
- def save_commits
- new_attributes = {}
-
- commits = compare.commits
-
- if commits.present?
- commits = Commit.decorate(commits, merge_request.source_project).reverse
- new_attributes[:st_commits] = dump_commits(commits)
- end
-
- update_columns_serialized(new_attributes)
- end
-
def create_merge_request_diff_files(diffs)
rows = diffs.map.with_index do |diff, index|
- diff.to_hash.merge(
+ diff_hash = diff.to_hash.merge(
+ binary: false,
merge_request_diff_id: self.id,
relative_order: index
)
+
+ # Compatibility with old diffs created with Psych.
+ diff_hash.tap do |hash|
+ diff_text = hash[:diff]
+
+ if diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?
+ hash[:binary] = true
+ hash[:diff] = [diff_text].pack('m0')
+ end
+ end
end
Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
end
def load_diffs(options)
- return Gitlab::Git::DiffCollection.new([]) unless diffs_from_database
-
- raw = diffs_from_database
+ raw = merge_request_diff_files.map(&:to_hash)
if paths = options[:paths]
raw = raw.select do |diff|
@@ -279,27 +237,17 @@ class MergeRequestDiff < ActiveRecord::Base
Gitlab::Git::DiffCollection.new(raw, options)
end
- def diffs_from_database
- return @diffs_from_database if defined?(@diffs_from_database)
+ def load_commits
+ commits = merge_request_diff_commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
- @diffs_from_database =
- if st_diffs.present?
- if valid_raw_diff?(st_diffs)
- st_diffs
- end
- elsif merge_request_diff_files.present?
- merge_request_diff_files
- .as_json(only: Gitlab::Git::Diff::SERIALIZE_KEYS)
- .map(&:with_indifferent_access)
- end
+ CommitCollection
+ .new(merge_request.source_project, commits, merge_request.source_branch)
end
- # Load diffs between branches related to current merge request diff from repo
- # and save it as array of hashes in st_diffs db field
def save_diffs
new_attributes = {}
- if commits.size.zero?
+ if compare.commits.size.zero?
new_attributes[:state] = :empty
else
diff_collection = compare.diffs(Commit.max_diff_options)
@@ -319,7 +267,16 @@ class MergeRequestDiff < ActiveRecord::Base
new_attributes[:state] = :overflow if diff_collection.overflow?
end
- update_columns_serialized(new_attributes)
+ assign_attributes(new_attributes)
+ end
+
+ def save_commits
+ MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
+
+ # merge_request_diff_commits.reload is preferred way to reload associated
+ # objects but it returns cached result for some reason in this case
+ commits = merge_request_diff_commits(true)
+ self.commits_count = commits.size
end
def repository
@@ -332,31 +289,8 @@ class MergeRequestDiff < ActiveRecord::Base
project.merge_base_commit(head_commit_sha, start_commit_sha).try(:sha)
end
- #
- # #save or #update_attributes providing changes on serialized attributes do a lot of
- # serialization and deserialization calls resulting in bad performance.
- # Using #update_columns solves the problem with just one YAML.dump per serialized attribute that we provide.
- # As a tradeoff we need to reload the current instance to properly manage time objects on those serialized
- # attributes. So to keep the same behaviour as the attribute assignment we reload the instance.
- # The difference is in the usage of
- # #write_attribute= (#update_attributes) and #raw_write_attribute= (#update_columns)
- #
- # Ex:
- #
- # new_attributes[:st_commits].first.slice(:committed_date)
- # => {:committed_date=>2014-02-27 11:01:38 +0200}
- # YAML.load(YAML.dump(new_attributes[:st_commits].first.slice(:committed_date)))
- # => {:committed_date=>2014-02-27 10:01:38 +0100}
- #
- def update_columns_serialized(new_attributes)
- return unless new_attributes.any?
-
- update_columns(new_attributes.merge(updated_at: current_time_from_proper_timezone))
- reload
- end
-
def keep_around_commits
- [repository, merge_request.source_project.repository].each do |repo|
+ [repository, merge_request.source_project.repository].uniq.each do |repo|
repo.keep_around(start_commit_sha)
repo.keep_around(head_commit_sha)
repo.keep_around(base_commit_sha)
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
new file mode 100644
index 00000000000..b75387e236e
--- /dev/null
+++ b/app/models/merge_request_diff_commit.rb
@@ -0,0 +1,40 @@
+class MergeRequestDiffCommit < ActiveRecord::Base
+ include ShaAttribute
+
+ belongs_to :merge_request_diff
+
+ sha_attribute :sha
+ alias_attribute :id, :sha
+
+ def self.create_bulk(merge_request_diff_id, commits)
+ sha_attribute = Gitlab::Database::ShaAttribute.new
+
+ rows = commits.map.with_index do |commit, index|
+ # See #parent_ids.
+ commit_hash = commit.to_hash.except(:parent_ids)
+ sha = commit_hash.delete(:id)
+
+ commit_hash.merge(
+ merge_request_diff_id: merge_request_diff_id,
+ relative_order: index,
+ sha: sha_attribute.type_cast_for_database(sha),
+ authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
+ committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date])
+ )
+ end
+
+ Gitlab::Database.bulk_insert(self.table_name, rows)
+ end
+
+ def to_hash
+ Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash|
+ hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ # We don't save these, because they would need a table or a serialised
+ # field. They aren't used anywhere, so just pretend the commit has no parents.
+ def parent_ids
+ []
+ end
+end
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index 598ebd4d829..1199ff5af22 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -8,4 +8,14 @@ class MergeRequestDiffFile < ActiveRecord::Base
encode_utf8(diff) if diff.respond_to?(:encoding)
end
+
+ def diff
+ binary? ? super.unpack('m0').first : super
+ end
+
+ def to_hash
+ keys = Gitlab::Git::Diff::SERIALIZE_KEYS - [:diff]
+
+ as_json(only: keys).merge(diff: diff).with_indifferent_access
+ end
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index d2e2749f70d..e7d397f40f5 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -8,27 +8,43 @@ class Milestone < ActiveRecord::Base
Started = MilestoneStruct.new('Started', '#started', -3)
include CacheMarkdownField
- include InternalId
+ include NonatomicInternalId
include Sortable
include Referable
include StripAttribute
include Milestoneish
+ include Gitlab::SQL::Pattern
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
belongs_to :project
+ belongs_to :group
+
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
- has_many :events, as: :target, dependent: :destroy
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
- scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :for_projects, -> { where(group: nil).includes(:project) }
+
+ scope :for_projects_and_groups, -> (project_ids, group_ids) do
+ conditions = []
+ conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any?
+ conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any?
- validates :title, presence: true, uniqueness: { scope: :project_id }
- validates :project, presence: true
+ where(conditions.reduce(:or))
+ end
+
+ validates :group, presence: true, unless: :project
+ validates :project, presence: true, unless: :group
+
+ validate :uniqueness_of_title, if: :title_changed?
+ validate :milestone_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
strip_attributes :title
@@ -58,10 +74,22 @@ class Milestone < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- t = arel_table
- pattern = "%#{query}%"
+ fuzzy_search(query, [:title, :description])
+ end
- where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
+ def filter_by_state(milestones, state)
+ case state
+ when 'closed' then milestones.closed
+ when 'all' then milestones
+ else milestones.active
+ end
+ end
+
+ def predefined?(milestone)
+ milestone == Any ||
+ milestone == None ||
+ milestone == Upcoming ||
+ milestone == Started
end
end
@@ -126,7 +154,9 @@ class Milestone < ActiveRecord::Base
end
##
- # Returns the String necessary to reference this Milestone in Markdown
+ # Returns the String necessary to reference this Milestone in Markdown. Group
+ # milestones only support name references, and do not support cross-project
+ # references.
#
# format - Symbol format to use (default: :iid, optional: :name)
#
@@ -137,14 +167,18 @@ class Milestone < ActiveRecord::Base
# Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-ce%1"
# Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1"
#
- def to_reference(from_project = nil, format: :iid, full: false)
+ def to_reference(from = nil, format: :name, full: false)
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
- "#{project.to_reference(from_project, full: full)}#{reference}"
+ if project
+ "#{project.to_reference(from, full: full)}#{reference}"
+ else
+ reference
+ end
end
- def reference_link_text(from_project = nil)
+ def reference_link_text(from = nil)
self.title
end
@@ -152,6 +186,10 @@ class Milestone < ActiveRecord::Base
id
end
+ def for_display
+ self
+ end
+
def can_be_closed?
active? && issues.opened.count.zero?
end
@@ -164,11 +202,52 @@ class Milestone < ActiveRecord::Base
write_attribute(:title, sanitize_title(value)) if value.present?
end
+ def safe_title
+ title.to_slug.normalize.to_s
+ end
+
+ def parent
+ group || project
+ end
+
+ def group_milestone?
+ group_id.present?
+ end
+
+ def project_milestone?
+ project_id.present?
+ end
+
private
+ # Milestone titles must be unique across project milestones and group milestones
+ def uniqueness_of_title
+ if project
+ relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
+ elsif group
+ project_ids = group.projects.map(&:id)
+ relation = Milestone.for_projects_and_groups(project_ids, [group.id])
+ end
+
+ title_exists = relation.find_by_title(title)
+ errors.add(:title, "already being used for another group or project milestone.") if title_exists
+ end
+
+ # Milestone should be either a project milestone or a group milestone
+ def milestone_type_check
+ if group_id && project_id
+ field = project_id_changed? ? :project_id : :group_id
+ errors.add(field, "milestone should belong either to a project or a group.")
+ end
+ end
+
def milestone_format_reference(format = :iid)
raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
+ if group_milestone? && format == :iid
+ raise ArgumentError, 'Cannot refer to a group milestone by an internal id!'
+ end
+
if format == :name && !name.include?('"')
%("#{name}")
else
@@ -182,7 +261,7 @@ class Milestone < ActiveRecord::Base
def start_date_should_be_less_than_due_date
if due_date <= start_date
- errors.add(:start_date, "Can't be greater than due date")
+ errors.add(:due_date, "must be greater than start date")
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 583d4fb5244..e350b675639 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -1,12 +1,15 @@
class Namespace < ActiveRecord::Base
- acts_as_paranoid
-
include CacheMarkdownField
include Sortable
include Gitlab::ShellAdapter
- include Gitlab::CurrentSettings
+ include Gitlab::VisibilityLevel
include Routable
include AfterCommitQueue
+ include Storage::LegacyNamespace
+ include Gitlab::SQL::Pattern
+ include IgnorableColumn
+
+ ignore_column :deleted_at
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -15,18 +18,20 @@ class Namespace < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description
- has_many :projects, dependent: :destroy
+ has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
+
+ # This should _not_ be `inverse_of: :namespace`, because that would also set
+ # `user.namespace` when this user creates a group with themselves as `owner`.
belongs_to :owner, class_name: "User"
belongs_to :parent, class_name: "Namespace"
has_many :children, class_name: "Namespace", foreign_key: :parent_id
- has_one :chat_team, dependent: :destroy
+ has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
presence: true,
- uniqueness: { scope: :parent_id },
length: { maximum: 255 },
namespace_name: true
@@ -34,16 +39,21 @@ class Namespace < ActiveRecord::Base
validates :path,
presence: true,
length: { maximum: 255 },
- dynamic_path: true
+ namespace_path: true
validate :nesting_level_allowed
delegate :name, to: :owner, allow_nil: true, prefix: true
- after_update :move_dir, if: :path_changed?
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
- # Save the storage paths before the projects are destroyed to use them on after destroy
+ before_create :sync_share_with_group_lock_with_parent
+ before_update :sync_share_with_group_lock_with_parent, if: :parent_changed?
+ after_update :force_share_with_group_lock_on_descendants, if: -> { share_with_group_lock_changed? && share_with_group_lock? }
+
+ # Legacy Storage specific hooks
+
+ after_update :move_dir, if: :path_or_parent_changed?
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
@@ -79,10 +89,7 @@ class Namespace < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation
def search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:name].matches(pattern).or(t[:path].matches(pattern)))
+ fuzzy_search(query, [:name, :path])
end
def clean_path(path)
@@ -105,6 +112,10 @@ class Namespace < ActiveRecord::Base
end
end
+ def visibility_level_field
+ :visibility_level
+ end
+
def to_param
full_path
end
@@ -113,43 +124,6 @@ class Namespace < ActiveRecord::Base
owner_name
end
- def move_dir
- if any_project_has_container_registry_tags?
- raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
- end
-
- # Move the namespace directory in all storages paths used by member projects
- repository_storage_paths.each do |repository_storage_path|
- # Ensure old directory exists before moving it
- gitlab_shell.add_namespace(repository_storage_path, full_path_was)
-
- unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path)
- Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}"
-
- # if we cannot move namespace directory we should rollback
- # db changes in order to prevent out of sync between db and fs
- raise Gitlab::UpdatePathError.new('namespace directory cannot be moved')
- end
- end
-
- Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
- Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
-
- remove_exports!
-
- # If repositories moved successfully we need to
- # send update instructions to users.
- # However we cannot allow rollback since we moved namespace dir
- # So we basically we mute exceptions in next actions
- begin
- send_update_instructions
- rescue
- # Returning false does not rollback after_* transaction but gives
- # us information about failing some of tasks
- false
- end
- end
-
def any_project_has_container_registry_tags?
all_projects.any?(&:has_container_registry_tags?)
end
@@ -165,7 +139,19 @@ class Namespace < ActiveRecord::Base
end
def find_fork_of(project)
- projects.joins(:forked_project_link).find_by('forked_project_links.forked_from_project_id = ?', project.id)
+ return nil unless project.fork_network
+
+ if RequestStore.active?
+ forks_in_namespace = RequestStore.fetch("namespaces:#{id}:forked_projects") do
+ Hash.new do |found_forks, project|
+ found_forks[project] = project.fork_network.find_forks_in(projects).first
+ end
+ end
+
+ forks_in_namespace[project]
+ else
+ project.fork_network.find_forks_in(projects).first
+ end
end
def lfs_enabled?
@@ -186,6 +172,21 @@ class Namespace < ActiveRecord::Base
.base_and_ancestors
end
+ # returns all ancestors upto but excluding the the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil)
+ Gitlab::GroupHierarchy.new(self.class.where(id: id))
+ .ancestors(upto: top)
+ end
+
+ def self_and_ancestors
+ return self.class.where(id: id) unless parent_id
+
+ Gitlab::GroupHierarchy
+ .new(self.class.where(id: id))
+ .base_and_ancestors
+ end
+
# Returns all the descendants of the current namespace.
def descendants
Gitlab::GroupHierarchy
@@ -193,6 +194,12 @@ class Namespace < ActiveRecord::Base
.base_and_descendants
end
+ def self_and_descendants
+ Gitlab::GroupHierarchy
+ .new(self.class.where(id: id))
+ .base_and_descendants
+ end
+
def user_ids_for_project_authorizations
[owner_id]
end
@@ -201,14 +208,6 @@ class Namespace < ActiveRecord::Base
parent_id_changed?
end
- def prepare_for_destroy
- old_repository_storage_paths
- end
-
- def old_repository_storage_paths
- @old_repository_storage_paths ||= repository_storage_paths
- end
-
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
@@ -219,37 +218,44 @@ class Namespace < ActiveRecord::Base
parent.present?
end
- private
+ def subgroup?
+ has_parent?
+ end
- def repository_storage_paths
- # We need to get the storage paths for all the projects, even the ones that are
- # pending delete. Unscoping also get rids of the default order, which causes
- # problems with SELECT DISTINCT.
- Project.unscoped do
- all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path)
- end
+ # Overridden on EE module
+ def multiple_issue_boards_available?
+ false
end
- def rm_dir
- # Remove the namespace directory in all storages paths used by member projects
- old_repository_storage_paths.each do |repository_storage_path|
- # Move namespace directory into trash.
- # We will remove it later async
- new_path = "#{full_path}+#{id}+deleted"
-
- if gitlab_shell.mv_namespace(repository_storage_path, full_path, new_path)
- message = "Namespace directory \"#{full_path}\" moved to \"#{new_path}\""
- Gitlab::AppLogger.info message
-
- # Remove namespace directroy async with delay so
- # GitLab has time to remove all projects first
- run_after_commit do
- GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
- end
- end
+ def full_path_was
+ if parent_id_was.nil?
+ path_was
+ else
+ previous_parent = Group.find_by(id: parent_id_was)
+ previous_parent.full_path + '/' + path_was
end
+ end
- remove_exports!
+ # Exports belonging to projects with legacy storage are placed in a common
+ # subdirectory of the namespace, so a simple `rm -rf` is sufficient to remove
+ # them.
+ #
+ # Exports of projects using hashed storage are placed in a location defined
+ # only by the project ID, so each must be removed individually.
+ def remove_exports!
+ remove_legacy_exports!
+
+ all_projects.with_storage_feature(:repository).find_each(&:remove_exports)
+ end
+
+ def features
+ []
+ end
+
+ private
+
+ def path_or_parent_changed?
+ path_changed? || parent_changed?
end
def refresh_access_of_projects_invited_groups
@@ -259,25 +265,33 @@ class Namespace < ActiveRecord::Base
.find_each(&:refresh_members_authorized_projects)
end
- def remove_exports!
- Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
+ def nesting_level_allowed
+ if ancestors.count > Group::NUMBER_OF_ANCESTORS_ALLOWED
+ errors.add(:parent_id, "has too deep level of nesting")
+ end
end
- def export_path
- File.join(Gitlab::ImportExport.storage_path, full_path_was)
+ def sync_share_with_group_lock_with_parent
+ if parent&.share_with_group_lock?
+ self.share_with_group_lock = true
+ end
end
- def full_path_was
- if parent
- parent.full_path + '/' + path_was
- else
- path_was
- end
+ def force_share_with_group_lock_on_descendants
+ return unless Group.supports_nested_groups?
+
+ # We can't use `descendants.update_all` since Rails will throw away the WITH
+ # RECURSIVE statement. We also can't use WHERE EXISTS since we can't use
+ # different table aliases, hence we're just using WHERE IN. Since we have a
+ # maximum of 20 nested groups this should be fine.
+ Namespace.where(id: descendants.select(:id))
+ .update_all(share_with_group_lock: true)
end
- def nesting_level_allowed
- if ancestors.count > Group::NUMBER_OF_ANCESTORS_ALLOWED
- errors.add(:parent_id, "has too deep level of nesting")
+ def write_projects_repository_config
+ all_projects.find_each do |project|
+ project.expires_full_path_cache # we need to clear cache to validate renames correctly
+ project.write_repository_config
end
end
end
diff --git a/app/models/network/commit.rb b/app/models/network/commit.rb
index 8417f200e36..22d48c9e661 100644
--- a/app/models/network/commit.rb
+++ b/app/models/network/commit.rb
@@ -12,7 +12,7 @@ module Network
end
def method_missing(m, *args, &block)
- @commit.send(m, *args, &block)
+ @commit.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
def space
@@ -24,12 +24,7 @@ module Network
end
def parents(map)
- @commit.parents.map do |p|
- if map.include?(p.id)
- map[p.id]
- end
- end
- .compact
+ map.values_at(*@commit.parent_ids).compact
end
end
end
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index 59737bb6085..1e0d1f9edcb 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -113,7 +113,7 @@ module Network
opts[:ref] = @commit.id if @filter_ref
- @repo.find_commits(opts)
+ Gitlab::Git::Commit.find_all(@repo.raw_repository, opts)
end
def commits_sort_by_ref
@@ -152,14 +152,14 @@ module Network
end
def find_free_parent_space(range, space_base, space_step, space_default)
- if is_overlap?(range, space_default)
+ if overlap?(range, space_default)
find_free_space(range, space_step, space_base, space_default)
else
space_default
end
end
- def is_overlap?(range, overlap_space)
+ def overlap?(range, overlap_space)
range.each do |i|
if i != range.first &&
i != range.last &&
@@ -206,7 +206,7 @@ module Network
# Visit branching chains
leaves.each do |l|
- parents = l.parents(@map).select{|p| p.space.zero?}
+ parents = l.parents(@map).select {|p| p.space.zero?}
parents.each do |p|
place_chain(p, l.time)
end
@@ -221,6 +221,7 @@ module Network
space_base = parents.first.space
end
end
+
space_base
end
diff --git a/app/models/note.rb b/app/models/note.rb
index ca6999427c0..787a80f0196 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -3,7 +3,6 @@
# A note of this type is never resolvable.
class Note < ActiveRecord::Base
extend ActiveModel::Naming
- include Gitlab::CurrentSettings
include Participable
include Mentionable
include Awardable
@@ -14,6 +13,18 @@ class Note < ActiveRecord::Base
include ResolvableNote
include IgnorableColumn
include Editable
+ include Gitlab::SQL::Pattern
+ include ThrottledTouch
+
+ module SpecialRole
+ FIRST_TIME_CONTRIBUTOR = :first_time_contributor
+
+ class << self
+ def values
+ constants.map {|const| self.const_get(const)}
+ end
+ end
+ end
ignore_column :original_discussion_id
@@ -32,22 +43,25 @@ class Note < ActiveRecord::Base
# Banzai::ObjectRenderer
attr_accessor :user_visible_reference_count
- # Attribute used to store the attributes that have ben changed by quick actions.
+ # Attribute used to store the attributes that have been changed by quick actions.
attr_accessor :commands_changes
+ # A special role that may be displayed on issuable's discussions
+ attr_accessor :special_role
+
default_value_for :system, false
attr_mentionable :note, pipeline: :note
participant :author
belongs_to :project
- belongs_to :noteable, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :noteable, polymorphic: 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'
- has_many :todos, dependent: :destroy
- has_many :events, as: :target, dependent: :destroy
+ has_many :todos
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
delegate :gfm_reference, :local_reference, to: :noteable
@@ -56,7 +70,7 @@ class Note < ActiveRecord::Base
delegate :title, to: :noteable, allow_nil: true
validates :note, presence: true
- validates :project, presence: true, unless: :for_personal_snippet?
+ validates :project, presence: true, if: :for_project_noteable?
# Attachments are deprecated and are handled by Markdown uploader
validates :attachment, file_size: { maximum: :max_attachment_size }
@@ -67,42 +81,45 @@ class Note < ActiveRecord::Base
validates :author, presence: true
validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
- validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note|
+ validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note|
unless note.noteable.try(:project) == note.project
errors.add(:project, 'does not match noteable project')
end
end
+ # @deprecated attachments are handler by the MarkdownUploader
mount_uploader :attachment, AttachmentUploader
# Scopes
scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
- scope :system, ->{ where(system: true) }
- scope :user, ->{ where(system: false) }
- scope :common, ->{ where(noteable_type: ["", nil]) }
- scope :fresh, ->{ order(created_at: :asc, id: :asc) }
- scope :updated_after, ->(time){ where('updated_at > ?', time) }
- scope :inc_author_project, ->{ includes(:project, :author) }
- scope :inc_author, ->{ includes(:author) }
+ scope :system, -> { where(system: true) }
+ scope :user, -> { where(system: false) }
+ scope :common, -> { where(noteable_type: ["", nil]) }
+ scope :fresh, -> { order(created_at: :asc, id: :asc) }
+ scope :updated_after, ->(time) { where('updated_at > ?', time) }
+ scope :inc_author_project, -> { includes(:project, :author) }
+ scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
includes(:project, :author, :updated_by, :resolved_by, :award_emoji, :system_note_metadata)
end
- scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) }
- scope :new_diff_notes, ->{ where(type: 'DiffNote') }
- scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) }
+ scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
+ scope :new_diff_notes, -> { where(type: 'DiffNote') }
+ scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
scope :with_associations, -> do
# FYI noteable cannot be loaded for LegacyDiffNote for commits
includes(:author, :noteable, :updated_by,
project: [:project_members, { group: [:group_members] }])
end
+ scope :with_metadata, -> { includes(:system_note_metadata) }
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id, on: :create
- after_save :keep_around_commit, unless: :for_personal_snippet?
+ after_save :keep_around_commit, if: :for_project_noteable?
after_save :expire_etag_cache
+ after_save :touch_noteable
after_destroy :expire_etag_cache
class << self
@@ -116,19 +133,28 @@ class Note < ActiveRecord::Base
def find_discussion(discussion_id)
notes = where(discussion_id: discussion_id).fresh.to_a
+
return if notes.empty?
Discussion.build(notes)
end
+ # Group diff discussions by line code or file path.
+ # It is not needed to group by line code when comment is
+ # on an image.
def grouped_diff_discussions(diff_refs = nil)
groups = {}
diff_notes.fresh.discussions.each do |discussion|
- line_code = discussion.line_code_in_diffs(diff_refs)
-
- if line_code
- discussions = groups[line_code] ||= []
+ group_key =
+ if discussion.on_image?
+ discussion.file_new_path
+ else
+ discussion.line_code_in_diffs(diff_refs)
+ end
+
+ if group_key
+ discussions = groups[group_key] ||= []
discussions << discussion
end
end
@@ -141,10 +167,24 @@ class Note < ActiveRecord::Base
.group(:noteable_id)
.where(noteable_type: type, noteable_id: ids)
end
+
+ def has_special_role?(role, note)
+ note.special_role == role
+ end
+
+ def search(query)
+ fuzzy_search(query, [:note])
+ end
end
def cross_reference?
- system? && SystemNoteService.cross_reference?(note)
+ return unless system?
+
+ if force_cross_reference_regex_check?
+ matches_cross_reference_regex?
+ else
+ SystemNoteService.cross_reference?(note)
+ end
end
def diff_note?
@@ -156,7 +196,7 @@ class Note < ActiveRecord::Base
end
def max_attachment_size
- current_application_settings.max_attachment_size.megabytes.to_i
+ Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
end
def hook_attrs
@@ -183,20 +223,26 @@ class Note < ActiveRecord::Base
noteable.is_a?(PersonalSnippet)
end
+ def for_project_noteable?
+ !for_personal_snippet?
+ end
+
def skip_project_check?
- for_personal_snippet?
+ !for_project_noteable?
+ end
+
+ def commit
+ @commit ||= project.commit(commit_id) if commit_id.present?
end
# override to return commits, which are not active record
def noteable
- if for_commit?
- project.commit(commit_id)
- else
- super
- end
- # Temp fix to prevent app crash
- # if note commit id doesn't exist
+ return commit if for_commit?
+
+ super
rescue
+ # Temp fix to prevent app crash
+ # if note commit id doesn't exist
nil
end
@@ -206,6 +252,22 @@ class Note < ActiveRecord::Base
super(noteable_type.to_s.classify.constantize.base_class.to_s)
end
+ def special_role=(role)
+ raise "Role is undefined, #{role} not found in #{SpecialRole.values}" unless SpecialRole.values.include?(role)
+
+ @special_role = role
+ end
+
+ def has_special_role?(role)
+ self.class.has_special_role?(role, self)
+ end
+
+ def specialize_for_first_contribution!(noteable)
+ return unless noteable.author_id == self.author_id
+
+ self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
+ end
+
def editable?
!system?
end
@@ -246,6 +308,11 @@ class Note < ActiveRecord::Base
self.noteable.supports_discussions? && !part_of_discussion?
end
+ def can_create_todo?
+ # Skip system notes, and notes on project snippet
+ !system? && !for_snippet?
+ end
+
def discussion_class(noteable = nil)
# When commit notes are rendered on an MR's Discussion page, they are
# displayed in one discussion instead of individually.
@@ -299,6 +366,66 @@ class Note < ActiveRecord::Base
end
end
+ def references
+ refs = [noteable]
+
+ if part_of_discussion?
+ refs += discussion.notes.take_while { |n| n.id < id }
+ end
+
+ refs
+ end
+
+ def expire_etag_cache
+ return unless noteable&.discussions_rendered_on_frontend?
+
+ key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
+ project,
+ target_type: noteable_type.underscore,
+ target_id: noteable_id
+ )
+ Gitlab::EtagCaching::Store.new.touch(key)
+ end
+
+ def touch(*args)
+ # We're not using an explicit transaction here because this would in all
+ # cases result in all future queries going to the primary, even if no writes
+ # are performed.
+ #
+ # We touch the noteable first so its SELECT query can run before our writes,
+ # ensuring it runs on a secondary (if no prior write took place).
+ touch_noteable
+ super
+ end
+
+ # By default Rails will issue an "SELECT *" for the relation, which is
+ # overkill for just updating the timestamps. To work around this we manually
+ # touch the data so we can SELECT only the columns we need.
+ def touch_noteable
+ # Commits are not stored in the DB so we can't touch them.
+ return if for_commit?
+
+ assoc = association(:noteable)
+
+ noteable_object =
+ if assoc.loaded?
+ noteable
+ else
+ # If the object is not loaded (e.g. when notes are loaded async) we
+ # _only_ want the data we actually need.
+ assoc.scope.select(:id, :updated_at).take
+ end
+
+ noteable_object&.touch
+
+ # We return the noteable object so we can re-use it in EE for ElasticSearch.
+ noteable_object
+ end
+
+ def banzai_render_context(field)
+ super.merge(noteable: noteable)
+ end
+
private
def keep_around_commit
@@ -327,15 +454,9 @@ class Note < ActiveRecord::Base
self.discussion_id ||= discussion_class.discussion_id(self)
end
- def expire_etag_cache
- return unless for_issue?
+ def force_cross_reference_regex_check?
+ return unless system?
- key = Gitlab::Routing.url_helpers.namespace_project_noteable_notes_path(
- noteable.project.namespace,
- noteable.project,
- target_type: noteable_type.underscore,
- target_id: noteable.id
- )
- Gitlab::EtagCaching::Store.new.touch(key)
+ SystemNoteMetadata::TYPES_WITH_CROSS_REFERENCES.include?(system_note_metadata&.action)
end
end
diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb
new file mode 100644
index 00000000000..c3965565022
--- /dev/null
+++ b/app/models/notification_reason.rb
@@ -0,0 +1,19 @@
+# Holds reasons for a notification to have been sent as well as a priority list to select which reason to use
+# above the rest
+class NotificationReason
+ OWN_ACTIVITY = 'own_activity'.freeze
+ ASSIGNED = 'assigned'.freeze
+ MENTIONED = 'mentioned'.freeze
+
+ # Priority list for selecting which reason to return in the notification
+ REASON_PRIORITY = [
+ OWN_ACTIVITY,
+ ASSIGNED,
+ MENTIONED
+ ].freeze
+
+ # returns the priority of a reason as an integer
+ def self.priority(reason)
+ REASON_PRIORITY.index(reason) || REASON_PRIORITY.length + 1
+ end
+end
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
new file mode 100644
index 00000000000..e95655e19f8
--- /dev/null
+++ b/app/models/notification_recipient.rb
@@ -0,0 +1,142 @@
+class NotificationRecipient
+ attr_reader :user, :type, :reason
+ def initialize(user, type, **opts)
+ unless NotificationSetting.levels.key?(type) || type == :subscription
+ raise ArgumentError, "invalid type: #{type.inspect}"
+ end
+
+ @custom_action = opts[:custom_action]
+ @acting_user = opts[:acting_user]
+ @target = opts[:target]
+ @project = opts[:project] || default_project
+ @group = opts[:group] || @project&.group
+ @user = user
+ @type = type
+ @reason = opts[:reason]
+ @skip_read_ability = opts[:skip_read_ability]
+ end
+
+ def notification_setting
+ @notification_setting ||= find_notification_setting
+ end
+
+ def notification_level
+ @notification_level ||= notification_setting&.level&.to_sym
+ end
+
+ def notifiable?
+ return false unless has_access?
+ return false if own_activity?
+
+ # even users with :disabled notifications receive manual subscriptions
+ return !unsubscribed? if @type == :subscription
+
+ return false unless suitable_notification_level?
+
+ # check this last because it's expensive
+ # nobody should receive notifications if they've specifically unsubscribed
+ # except if they were mentioned.
+ return false if @type != :mention && unsubscribed?
+
+ true
+ end
+
+ def suitable_notification_level?
+ case notification_level
+ when :disabled, nil
+ false
+ when :custom
+ custom_enabled? || %i[participating mention].include?(@type)
+ when :watch, :participating
+ !excluded_watcher_action?
+ when :mention
+ @type == :mention
+ else
+ false
+ end
+ end
+
+ def custom_enabled?
+ @custom_action && notification_setting&.event_enabled?(@custom_action)
+ end
+
+ def unsubscribed?
+ return false unless @target
+ return false unless @target.respond_to?(:subscriptions)
+
+ subscription = @target.subscriptions.find_by_user_id(@user.id)
+ subscription && !subscription.subscribed
+ end
+
+ def own_activity?
+ return false unless @acting_user
+
+ if user == @acting_user
+ # if activity was generated by the same user, change reason to :own_activity
+ @reason = NotificationReason::OWN_ACTIVITY
+ # If the user wants to be notified, we must return `false`
+ !@acting_user.notified_of_own_activity?
+ else
+ false
+ end
+ end
+
+ def has_access?
+ DeclarativePolicy.subject_scope do
+ return false unless user.can?(:receive_notifications)
+ return true if @skip_read_ability
+
+ return false if @target && !user.can?(:read_cross_project)
+ return false if @project && !user.can?(:read_project, @project)
+
+ return true unless read_ability
+ return true unless DeclarativePolicy.has_policy?(@target)
+
+ user.can?(read_ability, @target)
+ end
+ end
+
+ def excluded_watcher_action?
+ return false unless @custom_action
+ return false if notification_level == :custom
+
+ NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action)
+ end
+
+ private
+
+ def read_ability
+ return nil if @skip_read_ability
+ return @read_ability if instance_variable_defined?(:@read_ability)
+
+ @read_ability =
+ case @target
+ when Issuable
+ :"read_#{@target.to_ability_name}"
+ when Ci::Pipeline
+ :read_build # We have build trace in pipeline emails
+ when ActiveRecord::Base
+ :"read_#{@target.class.model_name.name.underscore}"
+ else
+ nil
+ end
+ end
+
+ def default_project
+ return nil if @target.nil?
+ return @target if @target.is_a?(Project)
+ return @target.project if @target.respond_to?(:project)
+ end
+
+ def find_notification_setting
+ project_setting = @project && user.notification_settings_for(@project)
+
+ return project_setting unless project_setting.nil? || project_setting.global?
+
+ group_setting = @group && user.notification_settings_for(@group)
+
+ return group_setting unless group_setting.nil? || group_setting.global?
+
+ user.global_notification_setting
+ end
+end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index b0df7aeb323..245f8dddcf9 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -1,4 +1,8 @@
class NotificationSetting < ActiveRecord::Base
+ include IgnorableColumn
+
+ ignore_column :events
+
enum level: { global: 3, watch: 2, mention: 4, participating: 1, disabled: 0, custom: 5 }
default_value_for :level, NotificationSetting.levels[:global]
@@ -19,7 +23,7 @@ class NotificationSetting < ActiveRecord::Base
# pending delete).
#
scope :for_projects, -> do
- includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil })
+ includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil, pending_delete: true })
end
EMAIL_EVENTS = [
@@ -41,9 +45,6 @@ class NotificationSetting < ActiveRecord::Base
:success_pipeline
].freeze
- store :events, coder: JSON
- before_save :convert_events
-
def self.find_or_create_for(source)
setting = find_or_initialize_by(source: source)
@@ -54,42 +55,17 @@ class NotificationSetting < ActiveRecord::Base
setting
end
- # 1. Check if this event has a value stored in its database column.
- # 2. If it does, return that value.
- # 3. If it doesn't (the value is nil), return the value from the serialized
- # JSON hash in `events`.
- (EMAIL_EVENTS - [:failed_pipeline]).each do |event|
- define_method(event) do
- bool = super()
-
- bool.nil? ? !!events[event] : bool
- end
-
- alias_method :"#{event}?", event
- end
-
# Allow people to receive failed pipeline notifications if they already have
# custom notifications enabled, as these are more like mentions than the other
# custom settings.
def failed_pipeline
bool = super
- bool = events[:failed_pipeline] if bool.nil?
bool.nil? || bool
end
alias_method :failed_pipeline?, :failed_pipeline
def event_enabled?(event)
- respond_to?(event) && public_send(event)
- end
-
- def convert_events
- return if events_before_type_cast.nil?
-
- EMAIL_EVENTS.each do |event|
- write_attribute(event, public_send(event))
- end
-
- write_attribute(:events, nil)
+ respond_to?(event) && !!public_send(event) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index b85f5dbaf2e..e8595b13d6d 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -1,4 +1,14 @@
class OauthAccessToken < Doorkeeper::AccessToken
belongs_to :resource_owner, class_name: 'User'
belongs_to :application, class_name: 'Doorkeeper::Application'
+
+ alias_attribute :user, :resource_owner
+
+ def scopes=(value)
+ if value.is_a?(Array)
+ super(Doorkeeper::OAuth::Scopes.from_array(value).to_s)
+ else
+ super
+ end
+ end
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 5d798247863..2e478a24778 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,10 +1,16 @@
class PagesDomain < ActiveRecord::Base
+ VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze
+ VERIFICATION_THRESHOLD = 3.days.freeze
+
belongs_to :project
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
+ validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' }, if: ->(domain) { domain.project&.pages_https_only? }
+ validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? }
+ validates :key, presence: { message: 'must be present if HTTPS-only is enabled' }, if: ->(domain) { domain.project&.pages_https_only? }
+ validates :key, certificate_key: true, if: ->(domain) { domain.key.present? }
+ validates :verification_code, presence: true, allow_blank: false
validate :validate_pages_domain
validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? }
@@ -16,9 +22,35 @@ class PagesDomain < ActiveRecord::Base
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
- after_create :update
- after_save :update
- after_destroy :update
+ after_initialize :set_verification_code
+ after_create :update_daemon
+ after_update :update_daemon, if: :pages_config_changed?
+ after_destroy :update_daemon
+
+ scope :enabled, -> { where('enabled_until >= ?', Time.now ) }
+ scope :needs_verification, -> do
+ verified_at = arel_table[:verified_at]
+ enabled_until = arel_table[:enabled_until]
+ threshold = Time.now + VERIFICATION_THRESHOLD
+
+ where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
+ end
+
+ def verified?
+ !!verified_at
+ end
+
+ def unverified?
+ !verified?
+ end
+
+ def enabled?
+ !Gitlab::CurrentSettings.pages_domain_verification_enabled? || enabled_until.present?
+ end
+
+ def https?
+ certificate.present?
+ end
def to_param
domain
@@ -27,7 +59,7 @@ class PagesDomain < ActiveRecord::Base
def url
return unless domain
- if certificate
+ if certificate.present?
"https://#{domain}"
else
"http://#{domain}"
@@ -65,12 +97,18 @@ class PagesDomain < ActiveRecord::Base
def expired?
return false unless x509
+
current = Time.new
current < x509.not_before || x509.not_after < current
end
+ def expiration
+ x509&.not_after
+ end
+
def subject
return unless x509
+
x509.subject.to_s
end
@@ -78,12 +116,49 @@ class PagesDomain < ActiveRecord::Base
@certificate_text ||= x509.try(:to_text)
end
+ # Verification codes may be TXT records for domain or verification_domain, to
+ # support the use of CNAME records on domain.
+ def verification_domain
+ return unless domain.present?
+
+ "_#{VERIFICATION_KEY}.#{domain}"
+ end
+
+ def keyed_verification_code
+ return unless verification_code.present?
+
+ "#{VERIFICATION_KEY}=#{verification_code}"
+ end
+
private
- def update
+ def set_verification_code
+ return if self.verification_code.present?
+
+ self.verification_code = SecureRandom.hex(16)
+ end
+
+ def update_daemon
::Projects::UpdatePagesConfigurationService.new(project).execute
end
+ def pages_config_changed?
+ project_id_changed? ||
+ domain_changed? ||
+ certificate_changed? ||
+ key_changed? ||
+ became_enabled? ||
+ became_disabled?
+ end
+
+ def became_enabled?
+ enabled_until.present? && !enabled_until_was.present?
+ end
+
+ def became_disabled?
+ !enabled_until.present? && enabled_until_was.present?
+ end
+
def validate_matching_key
unless has_matching_key?
self.errors.add(:key, "doesn't match the certificate")
@@ -98,6 +173,7 @@ class PagesDomain < ActiveRecord::Base
def validate_pages_domain
return unless domain
+
if domain.downcase.ends_with?(Settings.pages.host.downcase)
self.errors.add(:domain, "*.#{Settings.pages.host} is restricted")
end
@@ -105,6 +181,7 @@ class PagesDomain < ActiveRecord::Base
def x509
return unless certificate
+
@x509 ||= OpenSSL::X509::Certificate.new(certificate)
rescue OpenSSL::X509::CertificateError
nil
@@ -112,6 +189,7 @@ class PagesDomain < ActiveRecord::Base
def pkey
return unless key
+
@pkey ||= OpenSSL::PKey::RSA.new(key)
rescue OpenSSL::PKey::PKeyError, OpenSSL::Cipher::CipherError
nil
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 6e13f9b2089..063dc521324 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -3,7 +3,9 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable
add_authentication_token_field :token
- serialize :scopes, Array # rubocop:disable Cop/ActiverecordSerialize
+ REDIS_EXPIRY_TIME = 3.minutes
+
+ serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user
@@ -17,6 +19,8 @@ class PersonalAccessToken < ActiveRecord::Base
validates :scopes, presence: true
validate :validate_scopes
+ after_initialize :set_default_scopes, if: :persisted?
+
def revoke!
update!(revoked: true)
end
@@ -25,11 +29,34 @@ class PersonalAccessToken < ActiveRecord::Base
!revoked? && !expired?
end
+ def self.redis_getdel(user_id)
+ Gitlab::Redis::SharedState.with do |redis|
+ token = redis.get(redis_shared_state_key(user_id))
+ redis.del(redis_shared_state_key(user_id))
+ token
+ end
+ end
+
+ def self.redis_store!(user_id, token)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(redis_shared_state_key(user_id), token, ex: REDIS_EXPIRY_TIME)
+ token
+ end
+ end
+
protected
def validate_scopes
- unless scopes.all? { |scope| Gitlab::Auth::AVAILABLE_SCOPES.include?(scope.to_sym) }
+ unless revoked || scopes.all? { |scope| Gitlab::Auth.available_scopes.include?(scope.to_sym) }
errors.add :scopes, "can only contain available scopes"
end
end
+
+ def set_default_scopes
+ self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty?
+ end
+
+ def self.redis_shared_state_key(user_id)
+ "gitlab:personal_access_token:#{user_id}"
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 6e593d3c86b..6a420663644 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -4,7 +4,6 @@ class Project < ActiveRecord::Base
include Gitlab::ConfigHelper
include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
- include Gitlab::CurrentSettings
include AccessRequestable
include Avatarable
include CacheMarkdownField
@@ -16,7 +15,12 @@ class Project < ActiveRecord::Base
include ValidAttribute
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
+ include Presentable
include Routable
+ include GroupDescendant
+ include Gitlab::SQL::Pattern
+ include DeploymentPlatform
+ include ::Gitlab::Utils::StrongMemoize
extend Gitlab::ConfigHelper
@@ -24,6 +28,18 @@ class Project < ActiveRecord::Base
NUMBER_OF_PERMITTED_BOARDS = 1
UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze
+ # Hashed Storage versions handle rolling out new storage to project and dependents models:
+ # nil: legacy
+ # 1: repository
+ # 2: attachments
+ LATEST_STORAGE_VERSION = 2
+ HASHED_STORAGE_FEATURES = {
+ repository: 1,
+ attachments: 2
+ }.freeze
+
+ # Valids ports to import from
+ VALID_IMPORT_PORTS = [22, 80, 443].freeze
cache_markdown_field :description, pipeline: :description
@@ -31,11 +47,14 @@ class Project < ActiveRecord::Base
:merge_requests_enabled?, :issues_enabled?, to: :project_feature,
allow_nil: true
+ delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage
+
default_value_for :archived, false
default_value_for :visibility_level, gitlab_config_features.visibility_level
+ default_value_for :resolve_outdated_diff_discussions, false
default_value_for :container_registry_enabled, gitlab_config_features.container_registry
- default_value_for(:repository_storage) { current_application_settings.pick_repository_storage }
- default_value_for(:shared_runners_enabled) { current_application_settings.shared_runners_enabled }
+ default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage }
+ default_value_for(:shared_runners_enabled) { Gitlab::CurrentSettings.shared_runners_enabled }
default_value_for :issues_enabled, gitlab_config_features.issues
default_value_for :merge_requests_enabled, gitlab_config_features.merge_requests
default_value_for :builds_enabled, gitlab_config_features.builds
@@ -43,34 +62,33 @@ class Project < ActiveRecord::Base
default_value_for :snippets_enabled, gitlab_config_features.snippets
default_value_for :only_allow_merge_if_all_discussions_are_resolved, false
- after_create :ensure_dir_exist
- after_create :create_project_feature, unless: :project_feature
- after_save :ensure_dir_exist, if: :namespace_id_changed?
- after_save :update_project_statistics, if: :namespace_id_changed?
+ add_authentication_token_field :runners_token
+ before_save :ensure_runners_token
- # set last_activity_at to the same as created_at
+ after_save :update_project_statistics, if: :namespace_id_changed?
+ after_create :create_project_feature, unless: :project_feature
after_create :set_last_activity_at
- def set_last_activity_at
- update_column(:last_activity_at, self.created_at)
- end
-
after_create :set_last_repository_updated_at
- def set_last_repository_updated_at
- update_column(:last_repository_updated_at, self.created_at)
- end
-
- after_destroy :remove_pages
-
- # update visibility_level of forks
after_update :update_forks_visibility_level
+ before_destroy :remove_private_deploy_keys
+ after_destroy -> { run_after_commit { remove_pages } }
+ after_destroy :remove_exports
+
after_validation :check_pending_delete
+ # Storage specific hooks
+ after_initialize :use_hashed_storage
+ after_create :check_repository_absence!
+ after_create :ensure_storage_path_exists
+ after_save :ensure_storage_path_exists, if: :namespace_id_changed?
+
acts_as_taggable
- attr_accessor :new_default_branch
attr_accessor :old_path_with_namespace
+ attr_accessor :template_name
attr_writer :pipeline_status
+ attr_accessor :skip_disk_validation
alias_attribute :title, :name
@@ -78,114 +96,157 @@ class Project < ActiveRecord::Base
belongs_to :creator, class_name: 'User'
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
belongs_to :namespace
+ alias_method :parent, :namespace
+ alias_attribute :parent_id, :namespace_id
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
- has_many :boards, before_add: :validate_board_limit, dependent: :destroy
+ has_many :boards, before_add: :validate_board_limit
# Project services
- has_one :campfire_service, dependent: :destroy
- has_one :drone_ci_service, dependent: :destroy
- has_one :emails_on_push_service, dependent: :destroy
- has_one :pipelines_email_service, dependent: :destroy
- has_one :irker_service, dependent: :destroy
- has_one :pivotaltracker_service, dependent: :destroy
- has_one :hipchat_service, dependent: :destroy
- has_one :flowdock_service, dependent: :destroy
- has_one :assembla_service, dependent: :destroy
- has_one :asana_service, dependent: :destroy
- has_one :gemnasium_service, dependent: :destroy
- has_one :mattermost_slash_commands_service, dependent: :destroy
- has_one :mattermost_service, dependent: :destroy
- has_one :slack_slash_commands_service, dependent: :destroy
- has_one :slack_service, dependent: :destroy
- has_one :buildkite_service, dependent: :destroy
- has_one :bamboo_service, dependent: :destroy
- has_one :teamcity_service, dependent: :destroy
- has_one :pushover_service, dependent: :destroy
- has_one :jira_service, dependent: :destroy
- has_one :redmine_service, dependent: :destroy
- has_one :custom_issue_tracker_service, dependent: :destroy
- has_one :bugzilla_service, dependent: :destroy
- has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project
- has_one :external_wiki_service, dependent: :destroy
- has_one :kubernetes_service, dependent: :destroy, inverse_of: :project
- has_one :prometheus_service, dependent: :destroy, inverse_of: :project
- has_one :mock_ci_service, dependent: :destroy
- has_one :mock_deployment_service, dependent: :destroy
- has_one :mock_monitoring_service, dependent: :destroy
- has_one :microsoft_teams_service, dependent: :destroy
-
- has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
+ has_one :campfire_service
+ has_one :drone_ci_service
+ has_one :emails_on_push_service
+ has_one :pipelines_email_service
+ has_one :irker_service
+ has_one :pivotaltracker_service
+ has_one :hipchat_service
+ has_one :flowdock_service
+ has_one :assembla_service
+ has_one :asana_service
+ has_one :gemnasium_service
+ has_one :mattermost_slash_commands_service
+ has_one :mattermost_service
+ has_one :slack_slash_commands_service
+ has_one :slack_service
+ has_one :buildkite_service
+ has_one :bamboo_service
+ has_one :teamcity_service
+ has_one :pushover_service
+ has_one :jira_service
+ has_one :redmine_service
+ has_one :custom_issue_tracker_service
+ has_one :bugzilla_service
+ has_one :gitlab_issue_tracker_service, inverse_of: :project
+ has_one :external_wiki_service
+ has_one :kubernetes_service, inverse_of: :project
+ has_one :prometheus_service, inverse_of: :project
+ has_one :mock_ci_service
+ has_one :mock_deployment_service
+ has_one :mock_monitoring_service
+ has_one :microsoft_teams_service
+ has_one :packagist_service
+
+ # TODO: replace these relations with the fork network versions
+ has_one :forked_project_link, foreign_key: "forked_to_project_id"
has_one :forked_from_project, through: :forked_project_link
has_many :forked_project_links, foreign_key: "forked_from_project_id"
has_many :forks, through: :forked_project_links, source: :forked_to_project
+ # TODO: replace these relations with the fork network versions
+
+ has_one :root_of_fork_network,
+ foreign_key: 'root_project_id',
+ inverse_of: :root_project,
+ class_name: 'ForkNetwork'
+ has_one :fork_network_member
+ has_one :fork_network, through: :fork_network_member
# Merge Requests for target project should be removed with it
- has_many :merge_requests, dependent: :destroy, foreign_key: 'target_project_id'
- has_many :issues, dependent: :destroy
- has_many :labels, dependent: :destroy, class_name: 'ProjectLabel'
- has_many :services, dependent: :destroy
- has_many :events, dependent: :destroy
- has_many :milestones, dependent: :destroy
- has_many :notes, dependent: :destroy
- has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet'
- has_many :hooks, dependent: :destroy, class_name: 'ProjectHook'
- has_many :protected_branches, dependent: :destroy
- has_many :protected_tags, dependent: :destroy
+ has_many :merge_requests, foreign_key: 'target_project_id'
+ has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest'
+ has_many :issues
+ has_many :labels, class_name: 'ProjectLabel'
+ has_many :services
+ has_many :events
+ has_many :milestones
+ has_many :notes
+ has_many :snippets, class_name: 'ProjectSnippet'
+ has_many :hooks, class_name: 'ProjectHook'
+ has_many :protected_branches
+ has_many :protected_tags
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
- has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source
+ has_many :project_members, -> { where(requested_at: nil) },
+ as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
alias_method :members, :project_members
has_many :users, through: :project_members
- has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'ProjectMember'
+ has_many :requesters, -> { where.not(requested_at: nil) },
+ as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :members_and_requesters, as: :source, class_name: 'ProjectMember'
- has_many :deploy_keys_projects, dependent: :destroy
+ has_many :deploy_keys_projects
has_many :deploy_keys, through: :deploy_keys_projects
- has_many :users_star_projects, dependent: :destroy
+ has_many :users_star_projects
has_many :starrers, through: :users_star_projects, source: :user
- has_many :releases, dependent: :destroy
- has_many :lfs_objects_projects, dependent: :destroy
+ has_many :releases
+ has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :lfs_objects, through: :lfs_objects_projects
- has_many :project_group_links, dependent: :destroy
+ has_many :lfs_file_locks
+ has_many :project_group_links
has_many :invited_groups, through: :project_group_links, source: :group
- has_many :pages_domains, dependent: :destroy
- has_many :todos, dependent: :destroy
- has_many :notification_settings, dependent: :destroy, as: :source
-
- 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
-
- has_many :commit_statuses, dependent: :destroy
- has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
- 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 :pages_domains
+ has_many :todos
+ has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :internal_ids
+
+ has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
+ has_one :project_feature, inverse_of: :project
+ has_one :statistics, class_name: 'ProjectStatistics'
+
+ has_one :cluster_project, class_name: 'Clusters::Project'
+ has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
+
+ # Container repositories need to remove data from the container registry,
+ # which is not managed by the DB. Hence we're still using dependent: :destroy
+ # here.
+ has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :commit_statuses
+ has_many :pipelines, class_name: 'Ci::Pipeline', inverse_of: :project
+
+ # Ci::Build objects store data on the file system such as artifact files and
+ # build traces. Currently there's no efficient way of removing this data in
+ # bulk that doesn't involve loading the rows into memory. As a result we're
+ # still using `dependent: :destroy` here.
+ has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
+ has_many :runner_projects, class_name: 'Ci::RunnerProject'
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
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
- has_many :pipeline_schedules, dependent: :destroy, class_name: 'Ci::PipelineSchedule'
+ has_many :triggers, class_name: 'Ci::Trigger'
+ has_many :environments
+ has_many :deployments
+ has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
+ has_one :auto_devops, class_name: 'ProjectAutoDevops'
+ has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
+
+ has_many :project_badges, class_name: 'ProjectBadge'
+
accepts_nested_attributes_for :variables, allow_destroy: true
- accepts_nested_attributes_for :project_feature
+ accepts_nested_attributes_for :project_feature, update_only: true
+ accepts_nested_attributes_for :import_data
+ accepts_nested_attributes_for :auto_devops, update_only: true
delegate :name, to: :owner, allow_nil: true, prefix: true
- delegate :count, to: :forks, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
- delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team
- delegate :empty_repo?, to: :repository
+ delegate :add_guest, :add_reporter, :add_developer, :add_master, :add_role, to: :team
# Validations
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
+ validates :ci_config_path,
+ format: { without: %r{(\.{2}|\A/)},
+ message: 'cannot include leading slash or directory traversal.' },
+ length: { maximum: 255 },
+ allow_blank: true
validates :name,
presence: true,
length: { maximum: 255 },
@@ -193,11 +254,8 @@ class Project < ActiveRecord::Base
message: Gitlab::Regex.project_name_regex_message }
validates :path,
presence: true,
- dynamic_path: true,
- length: { maximum: 255 },
- format: { with: Gitlab::PathRegex.project_path_format_regex,
- message: Gitlab::PathRegex.project_path_format_message },
- uniqueness: { scope: :namespace_id }
+ project_path: true,
+ length: { maximum: 255 }
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
@@ -205,28 +263,28 @@ class Project < ActiveRecord::Base
validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
validate :check_limit, on: :create
- validate :avatar_type,
- if: ->(project) { project.avatar.present? && project.avatar_changed? }
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+ validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
validate :visibility_level_allowed_by_group
validate :visibility_level_allowed_as_fork
validate :check_wiki_path_conflict
+ validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
validates :repository_storage,
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
+ validates :variables, variable_duplicates: { scope: :environment_scope }
- add_authentication_token_field :runners_token
- before_save :ensure_runners_token
-
- mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Scopes
- default_scope { where(pending_delete: false) }
+ scope :pending_delete, -> { where(pending_delete: true) }
+ scope :without_deleted, -> { where(pending_delete: false) }
- scope :with_deleted, -> { unscope(where: :pending_delete) }
+ scope :with_storage_feature, ->(feature) { where('storage_version >= :version', version: HASHED_STORAGE_FEATURES[feature]) }
+ scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) }
+ scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) }
- scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
+ # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push
+ scope :sorted_by_activity, -> { reorder("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC") }
scope :sorted_by_stars, -> { reorder('projects.star_count DESC') }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
@@ -234,10 +292,10 @@ class Project < ActiveRecord::Base
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 :archived, -> { where(archived: true) }
scope :non_archived, -> { where(archived: false) }
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
scope :with_push, -> { joins(:events).where('events.action = ?', Event::PUSHED) }
-
scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') }
scope :with_statistics, -> { includes(:statistics) }
scope :with_shared_runners, -> { where(shared_runners_enabled: true) }
@@ -262,6 +320,7 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
+ scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -270,14 +329,9 @@ class Project < ActiveRecord::Base
# logged in user.
def self.public_or_visible_to_user(user = nil)
if user
- authorized = user
- .project_authorizations
- .select(1)
- .where('project_authorizations.project_id = projects.id')
-
- levels = Gitlab::VisibilityLevel.levels_for_user(user)
-
- where('EXISTS (?) OR projects.visibility_level IN (?)', authorized, levels)
+ where('EXISTS (?) OR projects.visibility_level IN (?)',
+ user.authorizations_for_projects,
+ Gitlab::VisibilityLevel.levels_for_user(user))
else
public_to_user
end
@@ -298,14 +352,11 @@ class Project < ActiveRecord::Base
elsif user
column = ProjectFeature.quoted_access_level_column(feature)
- authorized = user.project_authorizations.select(1)
- .where('project_authorizations.project_id = projects.id')
-
with_project_feature
.where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))",
visible,
ProjectFeature::PRIVATE,
- authorized)
+ user.authorizations_for_projects)
else
with_feature_access_level(feature, visible)
end
@@ -315,6 +366,7 @@ class Project < ActiveRecord::Base
scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) }
scope :excluding_project, ->(project) { where.not(id: project) }
+ scope :import_started, -> { where(import_status: 'started') }
state_machine :import_status, initial: :none do
event :import_schedule do
@@ -347,12 +399,20 @@ class Project < ActiveRecord::Base
state :failed
after_transition [:none, :finished, :failed] => :scheduled do |project, _|
- project.run_after_commit { add_import_job }
+ project.run_after_commit do
+ job_id = add_import_job
+ update(import_jid: job_id) if job_id
+ end
end
after_transition started: :finished do |project, _|
project.reset_cache_and_import_attrs
- project.perform_housekeeping
+
+ if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists?
+ project.run_after_commit do
+ Projects::AfterImportService.new(project).execute
+ end
+ end
end
end
@@ -365,32 +425,11 @@ class Project < ActiveRecord::Base
#
# query - The search query as a String.
def search(query)
- ptable = arel_table
- ntable = Namespace.arel_table
- pattern = "%#{query}%"
-
- # unscoping unnecessary conditions that'll be applied
- # when executing `where("projects.id IN (#{union.to_sql})")`
- projects = unscoped.select(:id).where(
- ptable[:path].matches(pattern)
- .or(ptable[:name].matches(pattern))
- .or(ptable[:description].matches(pattern))
- )
-
- namespaces = unscoped.select(:id)
- .joins(:namespace)
- .where(ntable[:name].matches(pattern))
-
- union = Gitlab::SQL::Union.new([projects, namespaces])
-
- where("projects.id IN (#{union.to_sql})")
+ fuzzy_search(query, [:path, :name, :description])
end
def search_by_title(query)
- pattern = "%#{query}%"
- table = Project.arel_table
-
- non_archived.where(table[:name].matches(pattern))
+ non_archived.fuzzy_search(query, [:name])
end
def visibility_levels
@@ -435,14 +474,37 @@ class Project < ActiveRecord::Base
end
end
+ # returns all ancestor-groups upto but excluding the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil)
+ Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
+ .base_and_ancestors(upto: top)
+ end
+
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
self[:lfs_enabled] && Gitlab.config.lfs.enabled
end
+ def auto_devops_enabled?
+ if auto_devops&.enabled.nil?
+ Gitlab::CurrentSettings.auto_devops_enabled?
+ else
+ auto_devops.enabled?
+ end
+ end
+
+ def has_auto_devops_implicitly_disabled?
+ auto_devops&.enabled.nil? && !Gitlab::CurrentSettings.auto_devops_enabled?
+ end
+
+ def empty_repo?
+ repository.empty?
+ end
+
def repository_storage_path
- Gitlab.config.repositories.storages[repository_storage]['path']
+ Gitlab.config.repositories.storages[repository_storage]&.legacy_disk_path
end
def team
@@ -450,17 +512,26 @@ class Project < ActiveRecord::Base
end
def repository
- @repository ||= Repository.new(path_with_namespace, self)
+ @repository ||= Repository.new(full_path, self, disk_path: disk_path)
end
+ def cleanup
+ @repository&.cleanup
+ @repository = nil
+ end
+
+ alias_method :reload_repository!, :cleanup
+
def container_registry_url
if Gitlab.config.registry.enabled
- "#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
+ "#{Gitlab.config.registry.host_port}/#{full_path.downcase}"
end
end
def has_container_registry_tags?
- container_repositories.to_a.any?(&:has_tags?) ||
+ return @images if defined?(@images)
+
+ @images = container_repositories.to_a.any?(&:has_tags?) ||
has_root_container_repository_tags?
end
@@ -468,12 +539,16 @@ class Project < ActiveRecord::Base
repository.commit(ref)
end
+ def commit_by(oid:)
+ repository.commit_by(oid: oid)
+ end
+
# ref can't be HEAD, can only be branch/tag name or SHA
def latest_successful_builds_for(ref = default_branch)
latest_pipeline = pipelines.latest_successful_for(ref)
if latest_pipeline
- latest_pipeline.builds.latest.with_artifacts
+ latest_pipeline.builds.latest.with_artifacts_archive
else
builds.none
end
@@ -481,7 +556,7 @@ class Project < ActiveRecord::Base
def merge_base_commit(first_commit_id, second_commit_id)
sha = repository.merge_base(first_commit_id, second_commit_id)
- repository.commit(sha) if sha
+ commit_by(oid: sha) if sha
end
def saved?
@@ -491,17 +566,28 @@ class Project < ActiveRecord::Base
def add_import_job
job_id =
if forked?
- RepositoryForkWorker.perform_async(id, forked_from_project.repository_storage_path,
- forked_from_project.path_with_namespace,
- self.namespace.full_path)
+ RepositoryForkWorker.perform_async(id,
+ forked_from_project.repository_storage_path,
+ forked_from_project.disk_path)
+ elsif gitlab_project_import?
+ # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab-ce/issues/26189 is solved.
+ RepositoryImportWorker.set(retry: false).perform_async(self.id)
else
RepositoryImportWorker.perform_async(self.id)
end
+ log_import_activity(job_id)
+
+ job_id
+ end
+
+ def log_import_activity(job_id, type: :import)
+ job_type = type.to_s.capitalize
+
if job_id
- Rails.logger.info "Import job started for #{path_with_namespace} with job ID #{job_id}"
+ Rails.logger.info("#{job_type} job scheduled for #{full_path} with job ID #{job_id}.")
else
- Rails.logger.error "Import job failed to start for #{path_with_namespace}"
+ Rails.logger.error("#{job_type} job failed to create for #{full_path}.")
end
end
@@ -510,25 +596,20 @@ class Project < ActiveRecord::Base
ProjectCacheWorker.perform_async(self.id)
end
+ update(import_error: nil)
remove_import_data
end
- def perform_housekeeping
- return unless repo_exists?
-
- run_after_commit do
- begin
- Projects::HousekeepingService.new(self).execute
- rescue Projects::HousekeepingService::LeaseTaken => e
- Rails.logger.info("Could not perform housekeeping for project #{self.path_with_namespace} (#{self.id}): #{e}")
- end
- end
- end
-
+ # This method is overriden in EE::Project model
def remove_import_data
import_data&.destroy
end
+ def ci_config_path=(value)
+ # Strip all leading slashes so that //foo -> foo
+ super(value&.delete("\0"))
+ end
+
def import_url=(value)
return super(value) unless Gitlab::UrlSanitizer.valid?(value)
@@ -547,7 +628,7 @@ class Project < ActiveRecord::Base
end
def valid_import_url?
- valid? || errors.messages[:import_url].nil?
+ valid?(:import_url) || errors.messages[:import_url].nil?
end
def create_or_update_import_data(data: nil, credentials: nil)
@@ -558,16 +639,15 @@ class Project < ActiveRecord::Base
project_import_data.data ||= {}
project_import_data.data = project_import_data.data.merge(data)
end
+
if credentials
project_import_data.credentials ||= {}
project_import_data.credentials = project_import_data.credentials.merge(credentials)
end
-
- project_import_data.save
end
def import?
- external_import? || forked? || gitlab_project_import?
+ external_import? || forked? || gitlab_project_import? || bare_repository_import?
end
def no_import?
@@ -587,7 +667,8 @@ class Project < ActiveRecord::Base
end
def import_started?
- import? && import_status == 'started'
+ # import? does SQL work so only run it if it looks like there's an import running
+ import_status == 'started' && import?
end
def import_scheduled?
@@ -606,6 +687,10 @@ class Project < ActiveRecord::Base
Gitlab::UrlSanitizer.new(import_url).masked_url
end
+ def bare_repository_import?
+ import_type == 'bare_repository'
+ end
+
def gitlab_project_import?
import_type == 'gitlab_project'
end
@@ -614,10 +699,6 @@ class Project < ActiveRecord::Base
import_type == 'gitea'
end
- def github_import?
- import_type == 'github'
- end
-
def check_limit
unless creator.can_create_project? || namespace.kind == 'group'
projects_limit = creator.projects_limit
@@ -657,6 +738,26 @@ class Project < ActiveRecord::Base
end
end
+ def pages_https_only
+ return false unless Gitlab.config.pages.external_https
+
+ super
+ end
+
+ def pages_https_only?
+ return false unless Gitlab.config.pages.external_https
+
+ super
+ end
+
+ def validate_pages_https_only
+ return unless pages_https_only?
+
+ unless pages_domains.all?(&:https?)
+ errors.add(:pages_https_only, "cannot be enabled unless all domains have TLS certificates")
+ end
+ end
+
def to_param
if persisted? && errors.include?(:path)
path_was
@@ -668,31 +769,32 @@ class Project < ActiveRecord::Base
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
if full || cross_namespace_reference?(from)
- path_with_namespace
+ full_path
elsif cross_project_reference?(from)
path
end
end
- def to_human_reference(from_project = nil)
- if cross_namespace_reference?(from_project)
+ def to_human_reference(from = nil)
+ if cross_namespace_reference?(from)
name_with_namespace
- elsif cross_project_reference?(from_project)
+ elsif cross_project_reference?(from)
name
end
end
def web_url
- Gitlab::Routing.url_helpers.namespace_project_url(self.namespace, self)
+ Gitlab::Routing.url_helpers.project_url(self)
end
- def new_issue_address(author)
+ def new_issuable_address(author, address_type)
return unless Gitlab::IncomingEmail.supports_issue_creation? && author
author.ensure_incoming_email_token!
+ suffix = address_type == 'merge_request' ? '+merge-request' : ''
Gitlab::IncomingEmail.reply_address(
- "#{path_with_namespace}+#{author.incoming_email_token}")
+ "#{full_path}#{suffix}+#{author.incoming_email_token}")
end
def build_commit_note(commit)
@@ -704,7 +806,7 @@ class Project < ActiveRecord::Base
end
def last_activity_date
- last_activity_at || updated_at
+ [last_activity_at, last_repository_updated_at, updated_at].compact.max
end
def project_id
@@ -712,9 +814,11 @@ class Project < ActiveRecord::Base
end
def get_issue(issue_id, current_user)
- if default_issues_tracker?
- IssuesFinder.new(current_user, project_id: id).find_by(iid: issue_id)
- else
+ issue = IssuesFinder.new(current_user, project_id: id).find_by(iid: issue_id) if issues_enabled?
+
+ if issue
+ issue
+ elsif external_issue_tracker
ExternalIssue.new(issue_id, self)
end
end
@@ -735,8 +839,8 @@ class Project < ActiveRecord::Base
end
end
- def issue_reference_pattern
- issues_tracker.reference_pattern
+ def external_issue_reference_pattern
+ external_issue_tracker.class.reference_pattern(only_long: issues_enabled?)
end
def default_issues_tracker?
@@ -758,7 +862,7 @@ class Project < ActiveRecord::Base
end
def cache_has_external_issue_tracker
- update_column(:has_external_issue_tracker, services.external_issue_trackers.any?)
+ update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write?
end
def has_wiki?
@@ -778,13 +882,15 @@ class Project < ActiveRecord::Base
end
def cache_has_external_wiki
- update_column(:has_external_wiki, services.external_wikis.any?)
+ update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write?
end
- def find_or_initialize_services
+ def find_or_initialize_services(exceptions: [])
services_templates = Service.where(template: true)
- Service.available_services_names.map do |service_name|
+ available_services_names = Service.available_services_names - exceptions
+
+ available_services_names.map do |service_name|
service = find_service(services, service_name)
if service
@@ -795,7 +901,7 @@ class Project < ActiveRecord::Base
if template.nil?
# If no template, we should create an instance. Ex `build_gitlab_ci_service`
- public_send("build_#{service_name}_service")
+ public_send("build_#{service_name}_service") # rubocop:disable GitlabSecurity/PublicSend
else
Service.build_from_template(id, template)
end
@@ -826,14 +932,6 @@ class Project < ActiveRecord::Base
@ci_service ||= ci_services.reorder(nil).find_by(active: true)
end
- def deployment_services
- services.where(category: :deployment)
- end
-
- def deployment_service
- @deployment_service ||= deployment_services.reorder(nil).find_by(active: true)
- end
-
def monitoring_services
services.where(category: :monitoring)
end
@@ -846,20 +944,12 @@ class Project < ActiveRecord::Base
issues_tracker.to_param == 'jira'
end
- def avatar_type
- unless self.avatar.image?
- self.errors.add :avatar, 'only images allowed'
- end
- end
-
def avatar_in_git
repository.avatar
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)
+ Gitlab::Routing.url_helpers.project_avatar_url(self) if avatar_in_git
end
# For compatibility with old code
@@ -879,7 +969,9 @@ class Project < ActiveRecord::Base
def send_move_instructions(old_path_with_namespace)
# New project path needs to be committed to the DB or notification will
# retrieve stale information
- run_after_commit { NotificationService.new.project_was_moved(self, old_path_with_namespace) }
+ run_after_commit do
+ NotificationService.new.project_was_moved(self, old_path_with_namespace)
+ end
end
def owner
@@ -891,15 +983,21 @@ class Project < ActiveRecord::Base
end
def execute_hooks(data, hooks_scope = :push_hooks)
- hooks.send(hooks_scope).each do |hook|
- hook.async_execute(data, hooks_scope.to_s)
+ run_after_commit_or_now do
+ hooks.hooks_for(hooks_scope).each do |hook|
+ hook.async_execute(data, hooks_scope.to_s)
+ end
+
+ SystemHooksService.new.execute_hooks(data, hooks_scope)
end
end
def execute_services(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
- services.send(hooks_scope).each do |service|
- service.async_execute(data)
+ run_after_commit_or_now do
+ services.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend
+ service.async_execute(data)
+ end
end
end
@@ -910,18 +1008,18 @@ class Project < ActiveRecord::Base
false
end
- def repo
- repository.raw
- end
-
def url_to_repo
- gitlab_shell.url_to_repo(path_with_namespace)
+ gitlab_shell.url_to_repo(full_path)
end
def repo_exists?
- @repo_exists ||= repository.exists?
- rescue
- @repo_exists = false
+ strong_memoize(:repo_exists) do
+ begin
+ repository.exists?
+ rescue
+ false
+ end
+ end
end
def root_ref?(branch)
@@ -937,64 +1035,41 @@ class Project < ActiveRecord::Base
end
def user_can_push_to_empty_repo?(user)
+ return false unless empty_repo?
+ return false unless Ability.allowed?(user, :push_code, self)
+
!ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
end
def forked?
- !(forked_project_link.nil? || forked_project_link.forked_from_project.nil?)
- end
+ return true if fork_network && fork_network.root_project != self
- def personal?
- !group
+ # TODO: Use only the above conditional using the `fork_network`
+ # This is the old conditional that looks at the `forked_project_link`, we
+ # fall back to this while we're migrating the new models
+ !(forked_project_link.nil? || forked_project_link.forked_from_project.nil?)
end
- def rename_repo
- path_was = previous_changes['path'].first
- old_path_with_namespace = File.join(namespace.full_path, path_was)
- new_path_with_namespace = File.join(namespace.full_path, path)
+ def fork_source
+ return nil unless forked?
- Rails.logger.error "Attempting to rename #{old_path_with_namespace} -> #{new_path_with_namespace}"
+ forked_from_project || fork_network&.root_project
+ end
- expire_caches_before_rename(old_path_with_namespace)
+ def lfs_storage_project
+ @lfs_storage_project ||= begin
+ result = self
- if has_container_registry_tags?
- Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
+ # TODO: Make this go to the fork_network root immeadiatly
+ # dependant on the discussion in: https://gitlab.com/gitlab-org/gitlab-ce/issues/39769
+ result = result.fork_source while result&.forked?
- # we currently doesn't support renaming repository if it contains images in container registry
- raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
+ result || self
end
+ end
- if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
- # If repository moved successfully we need to send update instructions to users.
- # However we cannot allow rollback since we moved repository
- # So we basically we mute exceptions in next actions
- begin
- gitlab_shell.mv_repository(repository_storage_path, "#{old_path_with_namespace}.wiki", "#{new_path_with_namespace}.wiki")
- send_move_instructions(old_path_with_namespace)
-
- @old_path_with_namespace = old_path_with_namespace
-
- SystemHooksService.new.execute_hooks_for(self, :rename)
-
- @repository = nil
- rescue => e
- Rails.logger.error "Exception renaming #{old_path_with_namespace} -> #{new_path_with_namespace}: #{e}"
- # Returning false does not rollback after_* transaction but gives
- # us information about failing some of tasks
- false
- end
- else
- Rails.logger.error "Repository could not be renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}"
-
- # if we cannot move namespace directory we should rollback
- # db changes in order to prevent out of sync between db and fs
- raise StandardError.new('repository cannot be renamed')
- end
-
- Gitlab::AppLogger.info "Project was renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}"
-
- Gitlab::UploadsTransfer.new.rename_project(path_was, path, namespace.full_path)
- Gitlab::PagesTransfer.new.rename_project(path_was, path, namespace.full_path)
+ def personal?
+ !group
end
# Expires various caches before a project is renamed.
@@ -1011,18 +1086,52 @@ class Project < ActiveRecord::Base
end
end
+ # Check if repository already exists on disk
+ def check_repository_path_availability
+ return true if skip_disk_validation
+ return false unless repository_storage_path
+
+ expires_full_path_cache # we need to clear cache to validate renames correctly
+
+ # Check if repository with same path already exists on disk we can
+ # skip this for the hashed storage because the path does not change
+ if legacy_storage? && repository_with_same_path_already_exists?
+ errors.add(:base, 'There is already a repository with that name on disk')
+ return false
+ end
+
+ true
+ rescue GRPC::Internal # if the path is too long
+ false
+ end
+
+ def create_repository(force: false)
+ # Forked import is handled asynchronously
+ return if forked? && !force
+
+ if gitlab_shell.create_repository(repository_storage, disk_path)
+ repository.after_create
+ true
+ else
+ errors.add(:base, 'Failed to create repository via gitlab-shell')
+ false
+ end
+ end
+
def hook_attrs(backward: true)
attrs = {
+ id: id,
name: name,
description: description,
web_url: web_url,
- avatar_url: avatar_url,
+ avatar_url: avatar_url(only_path: false),
git_ssh_url: ssh_url_to_repo,
git_http_url: http_url_to_repo,
namespace: namespace.name,
visibility_level: visibility_level,
- path_with_namespace: path_with_namespace,
- default_branch: default_branch
+ path_with_namespace: full_path,
+ default_branch: default_branch,
+ ci_config_path: ci_config_path
}
# Backward compatibility
@@ -1039,7 +1148,11 @@ class Project < ActiveRecord::Base
end
def project_member(user)
- project_members.find_by(user_id: user)
+ if project_members.loaded?
+ project_members.find { |member| member.user_id == user.id }
+ else
+ project_members.find_by(user_id: user)
+ end
end
def default_branch
@@ -1064,40 +1177,50 @@ class Project < ActiveRecord::Base
end
def change_head(branch)
- repository.before_change_head
- repository.rugged.references.create('HEAD',
- "refs/heads/#{branch}",
- force: true)
- repository.copy_gitattributes(branch)
- repository.after_change_head
- reload_default_branch
+ if repository.branch_exists?(branch)
+ repository.before_change_head
+ repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}", shell: false)
+ repository.copy_gitattributes(branch)
+ repository.after_change_head
+ reload_default_branch
+ else
+ errors.add(:base, "Could not change HEAD: branch '#{branch}' does not exist")
+ false
+ end
end
- def forked_from?(project)
- forked? && project == forked_from_project
+ def forked_from?(other_project)
+ forked? && forked_from_project == other_project
+ end
+
+ def in_fork_network_of?(other_project)
+ # TODO: Remove this in a next release when all fork_networks are populated
+ # This makes sure all MergeRequests remain valid while the projects don't
+ # have a fork_network yet.
+ return true if forked_from?(other_project)
+
+ return false if fork_network.nil? || other_project.fork_network.nil?
+
+ fork_network == other_project.fork_network
end
def origin_merge_requests
merge_requests.where(source_project_id: self.id)
end
- def create_repository
- # Forked import is handled asynchronously
- unless forked?
- if gitlab_shell.add_repository(repository_storage_path, path_with_namespace)
- repository.after_create
- true
- else
- errors.add(:base, 'Failed to create repository via gitlab-shell')
- false
- end
- end
+ def ensure_repository
+ create_repository(force: true) unless repository_exists?
end
def repository_exists?
!!repository.exists?
end
+ def wiki_repository_exists?
+ wiki.repository_exists?
+ end
+
+ # update visibility_level of forks
def update_forks_visibility_level
return unless visibility_level < visibility_level_was
@@ -1137,6 +1260,23 @@ class Project < ActiveRecord::Base
pipelines.order(id: :desc).find_by(sha: sha, ref: ref)
end
+ def latest_successful_pipeline_for_default_branch
+ if defined?(@latest_successful_pipeline_for_default_branch)
+ return @latest_successful_pipeline_for_default_branch
+ end
+
+ @latest_successful_pipeline_for_default_branch =
+ pipelines.latest_successful_for(default_branch)
+ end
+
+ def latest_successful_pipeline_for(ref = nil)
+ if ref && ref != default_branch
+ pipelines.latest_successful_for(ref)
+ else
+ latest_successful_pipeline_for_default_branch
+ end
+ end
+
def enable_ci
project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED)
end
@@ -1170,7 +1310,11 @@ class Project < ActiveRecord::Base
end
def open_issues_count
- issues.opened.count
+ Projects::OpenIssuesCountService.new(self).count
+ end
+
+ def open_merge_requests_count
+ Projects::OpenMergeRequestsCountService.new(self).count
end
def visibility_level_allowed_as_fork?(level = self.visibility_level)
@@ -1178,7 +1322,7 @@ class Project < ActiveRecord::Base
# self.forked_from_project will be nil before the project is saved, so
# we need to go through the relation
- original_project = forked_project_link.forked_from_project
+ original_project = forked_project_link&.forked_from_project
return true unless original_project
level <= original_project.visibility_level
@@ -1210,7 +1354,7 @@ class Project < ActiveRecord::Base
host = "#{subdomain}.#{Settings.pages.host}".downcase
# The host in URL always needs to be downcased
- url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix|
+ url = Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix|
"#{prefix}#{subdomain}."
end.downcase
@@ -1225,14 +1369,40 @@ class Project < ActiveRecord::Base
end
def pages_path
- File.join(Settings.pages.path, path_with_namespace)
+ # TODO: when we migrate Pages to work with new storage types, change here to use disk_path
+ File.join(Settings.pages.path, full_path)
end
def public_pages_path
File.join(pages_path, 'public')
end
+ def pages_available?
+ Gitlab.config.pages.enabled && !namespace.subgroup?
+ end
+
+ def remove_private_deploy_keys
+ exclude_keys_linked_to_other_projects = <<-SQL
+ NOT EXISTS (
+ SELECT 1
+ FROM deploy_keys_projects dkp2
+ WHERE dkp2.deploy_key_id = deploy_keys_projects.deploy_key_id
+ AND dkp2.project_id != deploy_keys_projects.project_id
+ )
+ SQL
+
+ deploy_keys.where(public: false)
+ .where(exclude_keys_linked_to_other_projects)
+ .delete_all
+ end
+
+ # TODO: what to do here when not using Legacy Storage? Do we still need to rename and delay removal?
def remove_pages
+ # Projects with a missing namespace cannot have their pages removed
+ return unless namespace
+
+ ::Projects::UpdatePagesConfigurationService.new(self).execute
+
# 1. We rename pages to temporary directory
# 2. We wait 5 minutes, due to NFS caching
# 3. We asynchronously remove pages with force
@@ -1243,6 +1413,115 @@ class Project < ActiveRecord::Base
end
end
+ def rename_repo
+ new_full_path = build_full_path
+
+ Rails.logger.error "Attempting to rename #{full_path_was} -> #{new_full_path}"
+
+ if has_container_registry_tags?
+ Rails.logger.error "Project #{full_path_was} cannot be renamed because container registry tags are present!"
+
+ # we currently doesn't support renaming repository if it contains images in container registry
+ raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
+ end
+
+ expire_caches_before_rename(full_path_was)
+
+ if storage.rename_repo
+ Gitlab::AppLogger.info "Project was renamed: #{full_path_was} -> #{new_full_path}"
+ rename_repo_notify!
+ after_rename_repo
+ else
+ Rails.logger.error "Repository could not be renamed: #{full_path_was} -> #{new_full_path}"
+
+ # if we cannot move namespace directory we should rollback
+ # db changes in order to prevent out of sync between db and fs
+ raise StandardError.new('repository cannot be renamed')
+ end
+ end
+
+ def after_rename_repo
+ write_repository_config
+
+ path_before_change = previous_changes['path'].first
+
+ # We need to check if project had been rolled out to move resource to hashed storage or not and decide
+ # if we need execute any take action or no-op.
+
+ unless hashed_storage?(:attachments)
+ Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path)
+ end
+
+ Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path)
+ end
+
+ def write_repository_config(gl_full_path: full_path)
+ # We'd need to keep track of project full path otherwise directory tree
+ # created with hashed storage enabled cannot be usefully imported using
+ # the import rake task.
+ repository.raw_repository.write_config(full_path: gl_full_path)
+ rescue Gitlab::Git::Repository::NoRepository => e
+ Rails.logger.error("Error writing to .git/config for project #{full_path} (#{id}): #{e.message}.")
+ nil
+ end
+
+ def rename_repo_notify!
+ send_move_instructions(full_path_was)
+ expires_full_path_cache
+
+ self.old_path_with_namespace = full_path_was
+ SystemHooksService.new.execute_hooks_for(self, :rename)
+
+ reload_repository!
+ end
+
+ def after_import
+ repository.after_import
+ import_finish
+ remove_import_jid
+ update_project_counter_caches
+ after_create_default_branch
+ end
+
+ def update_project_counter_caches
+ classes = [
+ Projects::OpenIssuesCountService,
+ Projects::OpenMergeRequestsCountService
+ ]
+
+ classes.each do |klass|
+ klass.new(self).refresh_cache
+ end
+ end
+
+ def after_create_default_branch
+ return unless default_branch
+
+ # Ensure HEAD points to the default branch in case it is not master
+ change_head(default_branch)
+
+ if Gitlab::CurrentSettings.default_branch_protection != Gitlab::Access::PROTECTION_NONE && !ProtectedBranch.protected?(self, default_branch)
+ params = {
+ name: default_branch,
+ push_access_levels_attributes: [{
+ access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
+ }],
+ merge_access_levels_attributes: [{
+ access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
+ }]
+ }
+
+ ProtectedBranches::CreateService.new(self, creator, params).execute(skip_authorization: true)
+ end
+ end
+
+ def remove_import_jid
+ return unless import_jid
+
+ Gitlab::SidekiqStatus.unset(import_jid)
+ update_column(:import_jid, nil)
+ end
+
def running_or_pending_build_count(force: false)
Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do
builds.running_or_pending.count(:all)
@@ -1266,8 +1545,8 @@ class Project < ActiveRecord::Base
@errors = original_errors
end
- def add_export_job(current_user:)
- job_id = ProjectExportWorker.perform_async(current_user.id, self.id)
+ def add_export_job(current_user:, params: {})
+ job_id = ProjectExportWorker.perform_async(current_user.id, self.id, params)
if job_id
Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
@@ -1276,49 +1555,77 @@ class Project < ActiveRecord::Base
end
end
+ def import_export_shared
+ @import_export_shared ||= Gitlab::ImportExport::Shared.new(self)
+ end
+
def export_path
- File.join(Gitlab::ImportExport.storage_path, path_with_namespace)
+ return nil unless namespace.present? || hashed_storage?(:repository)
+
+ import_export_shared.archive_path
end
def export_project_path
Dir.glob("#{export_path}/*export.tar.gz").max_by { |f| File.ctime(f) }
end
+ def export_status
+ if export_in_progress?
+ :started
+ elsif export_project_path
+ :finished
+ else
+ :none
+ end
+ end
+
+ def export_in_progress?
+ import_export_shared.active_export_count > 0
+ end
+
def remove_exports
- _, status = Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
- status.zero?
+ return nil unless export_path.present?
+
+ FileUtils.rm_rf(export_path)
end
- def ensure_dir_exist
- gitlab_shell.add_namespace(repository_storage_path, namespace.full_path)
+ def full_path_slug
+ Gitlab::Utils.slugify(full_path.to_s)
+ end
+
+ def has_ci?
+ repository.gitlab_ci_yml || auto_devops_enabled?
end
def predefined_variables
- [
- { 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 }
- ]
+ visibility = Gitlab::VisibilityLevel.string_level(visibility_level)
+
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_PROJECT_ID', value: id.to_s)
+ .append(key: 'CI_PROJECT_NAME', value: path)
+ .append(key: 'CI_PROJECT_PATH', value: full_path)
+ .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug)
+ .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path)
+ .append(key: 'CI_PROJECT_URL', value: web_url)
+ .append(key: 'CI_PROJECT_VISIBILITY', value: visibility)
+ .concat(container_registry_variables)
+ .concat(auto_devops_variables)
end
def container_registry_variables
- return [] unless Gitlab.config.registry.enabled
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables unless Gitlab.config.registry.enabled
- variables = [
- { key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port, public: true }
- ]
+ variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port)
- if container_registry_enabled?
- variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
+ if container_registry_enabled?
+ variables.append(key: 'CI_REGISTRY_IMAGE', value: container_registry_url)
+ end
end
-
- variables
end
- def secret_variables_for(ref)
+ def secret_variables_for(ref:, environment: nil)
+ # EE would use the environment
if protected_for?(ref)
variables
else
@@ -1327,18 +1634,25 @@ class Project < ActiveRecord::Base
end
def protected_for?(ref)
- ProtectedBranch.protected?(self, ref) ||
+ if repository.branch_exists?(ref)
+ ProtectedBranch.protected?(self, ref)
+ elsif repository.tag_exists?(ref)
ProtectedTag.protected?(self, ref)
+ end
end
- def deployment_variables
- return [] unless deployment_service
+ def deployment_variables(environment: nil)
+ deployment_platform(environment: environment)&.predefined_variables || []
+ end
+
+ def auto_devops_variables
+ return [] unless auto_devops_enabled?
- deployment_service.predefined_variables
+ (auto_devops || build_auto_devops)&.predefined_variables
end
def append_or_update_attribute(name, value)
- old_values = public_send(name.to_s)
+ old_values = public_send(name.to_s) # rubocop:disable GitlabSecurity/PublicSend
if Project.reflect_on_association(name).try(:macro) == :has_many && old_values.any?
update_attribute(name, old_values + value)
@@ -1351,15 +1665,15 @@ class Project < ActiveRecord::Base
end
def pushes_since_gc
- Gitlab::Redis.with { |redis| redis.get(pushes_since_gc_redis_key).to_i }
+ Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i }
end
def increment_pushes_since_gc
- Gitlab::Redis.with { |redis| redis.incr(pushes_since_gc_redis_key) }
+ Gitlab::Redis::SharedState.with { |redis| redis.incr(pushes_since_gc_redis_shared_state_key) }
end
def reset_pushes_since_gc
- Gitlab::Redis.with { |redis| redis.del(pushes_since_gc_redis_key) }
+ Gitlab::Redis::SharedState.with { |redis| redis.del(pushes_since_gc_redis_shared_state_key) }
end
def route_map_for(commit_sha)
@@ -1384,10 +1698,6 @@ class Project < ActiveRecord::Base
map.public_path_for_source_path(path)
end
- def parent
- namespace
- end
-
def parent_changed?
namespace_id_changed?
end
@@ -1400,12 +1710,195 @@ class Project < ActiveRecord::Base
end
end
+ # Overridden on EE module
+ def multiple_issue_boards_available?
+ false
+ end
+
+ def issue_board_milestone_available?(user = nil)
+ feature_available?(:issue_board_milestone, user)
+ end
+
+ def full_path_was
+ File.join(namespace.full_path, previous_changes['path'].first)
+ end
+
alias_method :name_with_namespace, :full_name
alias_method :human_name, :full_name
+ # @deprecated cannot remove yet because it has an index with its name in elasticsearch
alias_method :path_with_namespace, :full_path
+ def forks_count
+ Projects::ForksCountService.new(self).count
+ end
+
+ def legacy_storage?
+ [nil, 0].include?(self.storage_version)
+ end
+
+ # Check if Hashed Storage is enabled for the project with at least informed feature rolled out
+ #
+ # @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments)
+ def hashed_storage?(feature)
+ raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature)
+
+ self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature]
+ end
+
+ def renamed?
+ persisted? && path_changed?
+ end
+
+ def merge_method
+ if self.merge_requests_ff_only_enabled
+ :ff
+ elsif self.merge_requests_rebase_enabled
+ :rebase_merge
+ else
+ :merge
+ end
+ end
+
+ def merge_method=(method)
+ case method.to_s
+ when "ff"
+ self.merge_requests_ff_only_enabled = true
+ self.merge_requests_rebase_enabled = true
+ when "rebase_merge"
+ self.merge_requests_ff_only_enabled = false
+ self.merge_requests_rebase_enabled = true
+ when "merge"
+ self.merge_requests_ff_only_enabled = false
+ self.merge_requests_rebase_enabled = false
+ end
+ end
+
+ def ff_merge_must_be_possible?
+ self.merge_requests_ff_only_enabled || self.merge_requests_rebase_enabled
+ end
+
+ def migrate_to_hashed_storage!
+ return if hashed_storage?(:repository)
+
+ update!(repository_read_only: true)
+
+ if repo_reference_count > 0 || wiki_reference_count > 0
+ ProjectMigrateHashedStorageWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
+ else
+ ProjectMigrateHashedStorageWorker.perform_async(id)
+ end
+ end
+
+ def storage_version=(value)
+ super
+
+ @storage = nil if storage_version_changed?
+ end
+
+ def gl_repository(is_wiki:)
+ Gitlab::GlRepository.gl_repository(self, is_wiki)
+ end
+
+ def reference_counter(wiki: false)
+ Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki))
+ end
+
+ # Refreshes the expiration time of the associated import job ID.
+ #
+ # This method can be used by asynchronous importers to refresh the status,
+ # preventing the StuckImportJobsWorker from marking the import as failed.
+ def refresh_import_jid_expiration
+ return unless import_jid
+
+ Gitlab::SidekiqStatus
+ .set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
+ end
+
+ def badges
+ return project_badges unless group
+
+ group_badges_rel = GroupBadge.where(group: group.self_and_ancestors)
+
+ union = Gitlab::SQL::Union.new([project_badges.select(:id),
+ group_badges_rel.select(:id)])
+
+ Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
+ end
+
+ def merge_requests_allowing_push_to_user(user)
+ return MergeRequest.none unless user
+
+ developer_access_exists = user.project_authorizations
+ .where('access_level >= ? ', Gitlab::Access::DEVELOPER)
+ .where('project_authorizations.project_id = merge_requests.target_project_id')
+ .limit(1)
+ .select(1)
+ source_of_merge_requests.opened
+ .where(allow_maintainer_to_push: true)
+ .where('EXISTS (?)', developer_access_exists)
+ end
+
+ def branch_allows_maintainer_push?(user, branch_name)
+ return false unless user
+
+ cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push"
+
+ memoized_results = strong_memoize(:branch_allows_maintainer_push) do
+ Hash.new do |result, cache_key|
+ result[cache_key] = fetch_branch_allows_maintainer_push?(user, branch_name)
+ end
+ end
+
+ memoized_results[cache_key]
+ end
+
private
+ def storage
+ @storage ||=
+ if hashed_storage?(:repository)
+ Storage::HashedProject.new(self)
+ else
+ Storage::LegacyProject.new(self)
+ end
+ end
+
+ def use_hashed_storage
+ if self.new_record? && Gitlab::CurrentSettings.hashed_storage_enabled
+ self.storage_version = LATEST_STORAGE_VERSION
+ end
+ end
+
+ def repo_reference_count
+ reference_counter.value
+ end
+
+ def wiki_reference_count
+ reference_counter(wiki: true).value
+ end
+
+ def check_repository_absence!
+ return if skip_disk_validation
+
+ if repository_storage_path.blank? || repository_with_same_path_already_exists?
+ errors.add(:base, 'There is already a repository with that name on disk')
+ throw :abort
+ end
+ end
+
+ def repository_with_same_path_already_exists?
+ gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git")
+ end
+
+ # set last_activity_at to the same as created_at
+ def set_last_activity_at
+ update_column(:last_activity_at, self.created_at)
+ end
+
+ def set_last_repository_updated_at
+ update_column(:last_repository_updated_at, self.created_at)
+ end
+
def cross_namespace_reference?(from)
case from
when Project
@@ -1422,7 +1915,7 @@ class Project < ActiveRecord::Base
from && self != from
end
- def pushes_since_gc_redis_key
+ def pushes_since_gc_redis_shared_state_key
"projects/#{id}/pushes_since_gc"
end
@@ -1456,7 +1949,7 @@ class Project < ActiveRecord::Base
def pending_delete_twin
return false unless path
- Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
+ Project.pending_delete.find_by_full_path(full_path)
end
##
@@ -1481,4 +1974,22 @@ class Project < ActiveRecord::Base
raise ex
end
+
+ def fetch_branch_allows_maintainer_push?(user, branch_name)
+ check_access = -> do
+ merge_request = source_of_merge_requests.opened
+ .where(allow_maintainer_to_push: true)
+ .find_by(source_branch: branch_name)
+
+ merge_request&.can_be_merged_by?(user)
+ end
+
+ if RequestStore.active?
+ RequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_maintainer_push") do
+ check_access.call
+ end
+ else
+ check_access.call
+ end
+ end
end
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
new file mode 100644
index 00000000000..ed6c1eddbc1
--- /dev/null
+++ b/app/models/project_auto_devops.rb
@@ -0,0 +1,25 @@
+class ProjectAutoDevops < ActiveRecord::Base
+ belongs_to :project
+
+ scope :enabled, -> { where(enabled: true) }
+ scope :disabled, -> { where(enabled: false) }
+
+ validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true }
+
+ def instance_domain
+ Gitlab::CurrentSettings.auto_devops_domain
+ end
+
+ def has_domain?
+ domain.present? || instance_domain.present?
+ end
+
+ def predefined_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ if has_domain?
+ variables.append(key: 'AUTO_DEVOPS_DOMAIN',
+ value: domain.presence || instance_domain)
+ end
+ end
+ end
+end
diff --git a/app/models/project_custom_attribute.rb b/app/models/project_custom_attribute.rb
new file mode 100644
index 00000000000..3f1a7b86a82
--- /dev/null
+++ b/app/models/project_custom_attribute.rb
@@ -0,0 +1,6 @@
+class ProjectCustomAttribute < ActiveRecord::Base
+ belongs_to :project
+
+ validates :project, :key, :value, presence: true
+ validates :key, uniqueness: { scope: [:project_id] }
+end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 48edd0738ee..bfb8d703ec9 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -41,6 +41,8 @@ class ProjectFeature < ActiveRecord::Base
# http://stackoverflow.com/questions/1540645/how-to-disable-default-scope-for-a-belongs-to
belongs_to :project, -> { unscope(where: :pending_delete) }
+ validates :project, presence: true
+
validate :repository_children_level
default_value_for :builds_access_level, value: ENABLED, allows_nil: false
@@ -51,8 +53,11 @@ class ProjectFeature < ActiveRecord::Base
default_value_for :repository_access_level, value: ENABLED, allows_nil: false
def feature_available?(feature, user)
- access_level = public_send(ProjectFeature.access_level_attribute(feature))
- get_permission(user, access_level)
+ get_permission(user, access_level(feature))
+ end
+
+ def access_level(feature)
+ public_send(ProjectFeature.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
def builds_enabled?
@@ -77,7 +82,7 @@ class ProjectFeature < ActiveRecord::Base
# which cannot be higher than repository access level
def repository_children_level
validator = lambda do |field|
- level = public_send(field) || ProjectFeature::ENABLED
+ level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > repository_access_level
self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed
end
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index e3cafd4d1c6..6da6632f4f2 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -1,7 +1,7 @@
require 'carrierwave/orm/activerecord'
class ProjectImportData < ActiveRecord::Base
- belongs_to :project
+ belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials,
key: Gitlab::Application.secrets.db_key_base,
marshal: true,
@@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base
insecure_mode: true,
algorithm: 'aes-256-cbc'
- serialize :data, JSON # rubocop:disable Cop/ActiverecordSerialize
+ 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 9ce2d1153a7..4f289e6e215 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -68,7 +68,7 @@ http://app.asana.com/-/account_api'
end
user = data[:user_name]
- project_name = project.name_with_namespace
+ project_name = project.full_name
data[:commits].each do |commit|
push_msg = "#{user} pushed to branch #{branch} of #{project_name} ( #{commit[:url]} ):"
@@ -84,7 +84,7 @@ http://app.asana.com/-/account_api'
# - fix/ed/es/ing
# - close/s/d
# - closing
- issue_finder = /(fix\w*|clos[ei]\w*+)?\W*(?:https:\/\/app\.asana\.com\/\d+\/\d+\/(\d+)|#(\d+))/i
+ issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\d+/(\d+)|#(\d+))}i
message.scan(issue_finder).each do |tuple|
# tuple will be
diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb
index ae6af732ed4..4234b8044e5 100644
--- a/app/models/project_services/assembla_service.rb
+++ b/app/models/project_services/assembla_service.rb
@@ -1,6 +1,4 @@
class AssemblaService < Service
- include HTTParty
-
prop_accessor :token, :subdomain
validates :token, presence: true, if: :activated?
@@ -31,6 +29,6 @@ class AssemblaService < Service
return unless supported_events.include?(data[:object_kind])
url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}"
- AssemblaService.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' })
+ Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' })
end
end
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index 42939ea0ec8..54e4b3278db 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -117,14 +117,14 @@ class BambooService < CiService
url = build_url(path)
if username.blank? && password.blank?
- HTTParty.get(url, verify: false)
+ Gitlab::HTTP.get(url, verify: false)
else
url << '&os_authType=basic'
- HTTParty.get(url, verify: false,
- basic_auth: {
- username: username,
- password: password
- })
+ Gitlab::HTTP.get(url, verify: false,
+ basic_auth: {
+ username: username,
+ password: password
+ })
end
end
end
diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb
index fc30f6e3365..d2aaff8817a 100644
--- a/app/models/project_services/buildkite_service.rb
+++ b/app/models/project_services/buildkite_service.rb
@@ -71,7 +71,7 @@ class BuildkiteService < CiService
end
def calculate_reactive_cache(sha, ref)
- response = HTTParty.get(commit_status_path(sha), verify: false)
+ response = Gitlab::HTTP.get(commit_status_path(sha), verify: false)
status =
if response.code == 200 && response['status']
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index c3f5b310619..cb4af73807b 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -1,6 +1,4 @@
class CampfireService < Service
- include HTTParty
-
prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
@@ -31,7 +29,6 @@ class CampfireService < Service
def execute(data)
return unless supported_events.include?(data[:object_kind])
- self.class.base_uri base_uri
message = build_message(data)
speak(self.room, message, auth)
end
@@ -69,14 +66,14 @@ class CampfireService < Service
}
}
}
- res = self.class.post(path, auth.merge(body))
+ res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body))
res.code == 201 ? res : nil
end
# 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 = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth)
res.code == 200 ? res["rooms"] : []
end
@@ -86,7 +83,7 @@ class CampfireService < Service
after = push[:after]
message = ""
- message << "[#{project.name_with_namespace}] "
+ message << "[#{project.full_name}] "
message << "#{push[:user_name]} "
if Gitlab::Git.blank_ref?(before)
diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb
index e2ad586aea7..22a65b5145e 100644
--- a/app/models/project_services/chat_message/base_message.rb
+++ b/app/models/project_services/chat_message/base_message.rb
@@ -3,6 +3,7 @@ require 'slack-notifier'
module ChatMessage
class BaseMessage
attr_reader :markdown
+ attr_reader :user_full_name
attr_reader :user_name
attr_reader :user_avatar
attr_reader :project_name
@@ -12,10 +13,19 @@ module ChatMessage
@markdown = params[:markdown] || false
@project_name = params.dig(:project, :path_with_namespace) || params[:project_name]
@project_url = params.dig(:project, :web_url) || params[:project_url]
+ @user_full_name = params.dig(:user, :name) || params[:user_full_name]
@user_name = params.dig(:user, :username) || params[:user_name]
@user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar]
end
+ def user_combined_name
+ if user_full_name.present?
+ "#{user_full_name} (#{user_name})"
+ else
+ user_name
+ end
+ end
+
def pretext
return message if markdown
diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb
index 4b9a2b1e1f3..3273f41dbd2 100644
--- a/app/models/project_services/chat_message/issue_message.rb
+++ b/app/models/project_services/chat_message/issue_message.rb
@@ -29,7 +29,7 @@ module ChatMessage
def activity
{
- title: "Issue #{state} by #{user_name}",
+ title: "Issue #{state} by #{user_combined_name}",
subtitle: "in #{project_link}",
text: issue_link,
image: user_avatar
@@ -39,10 +39,10 @@ module ChatMessage
private
def message
- if state == 'opened'
- "[#{project_link}] Issue #{state} by #{user_name}"
+ if opened_issue?
+ "[#{project_link}] Issue #{state} by #{user_combined_name}"
else
- "[#{project_link}] Issue #{issue_link} #{state} by #{user_name}"
+ "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}"
end
end
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index 7d0de81cdf0..f412b6833d9 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -24,7 +24,7 @@ module ChatMessage
def activity
{
- title: "Merge Request #{state} by #{user_name}",
+ title: "Merge Request #{state} by #{user_combined_name}",
subtitle: "in #{project_link}",
text: merge_request_link,
image: user_avatar
@@ -46,7 +46,7 @@ module ChatMessage
end
def merge_request_message
- "#{user_name} #{state} #{merge_request_link} in #{project_link}: #{title}"
+ "#{user_combined_name} #{state} #{merge_request_link} in #{project_link}: #{title}"
end
def merge_request_link
diff --git a/app/models/project_services/chat_message/note_message.rb b/app/models/project_services/chat_message/note_message.rb
index 2da4c244229..7f9486132e6 100644
--- a/app/models/project_services/chat_message/note_message.rb
+++ b/app/models/project_services/chat_message/note_message.rb
@@ -32,7 +32,7 @@ module ChatMessage
def activity
{
- title: "#{user_name} #{link('commented on ' + target, note_url)}",
+ title: "#{user_combined_name} #{link('commented on ' + target, note_url)}",
subtitle: "in #{project_link}",
text: formatted_title,
image: user_avatar
@@ -42,7 +42,7 @@ module ChatMessage
private
def message
- "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
+ "#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
end
def format_title(title)
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index d63d4ec2b12..2135122278a 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -9,7 +9,7 @@ module ChatMessage
def initialize(data)
super
- @user_name = data.dig(:user, :name) || 'API'
+ @user_name = data.dig(:user, :username) || 'API'
pipeline_attributes = data[:object_attributes]
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@@ -35,7 +35,7 @@ module ChatMessage
def activity
{
- title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status}",
+ title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_combined_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 #{ref_type} #{branch_link} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}"
+ "#{project_link}: Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_combined_name} #{humanized_status} in #{pretty_duration(duration)}"
end
def humanized_status
diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb
index c52dd6ef8ef..8d599c5f116 100644
--- a/app/models/project_services/chat_message/push_message.rb
+++ b/app/models/project_services/chat_message/push_message.rb
@@ -33,7 +33,7 @@ module ChatMessage
end
{
- title: "#{user_name} #{action} #{ref_type}",
+ title: "#{user_combined_name} #{action} #{ref_type}",
subtitle: "in #{project_link}",
text: compare_link,
image: user_avatar
@@ -57,15 +57,15 @@ module ChatMessage
end
def new_branch_message
- "#{user_name} pushed new #{ref_type} #{branch_link} to #{project_link}"
+ "#{user_combined_name} pushed new #{ref_type} #{branch_link} to #{project_link}"
end
def removed_branch_message
- "#{user_name} removed #{ref_type} #{ref} from #{project_link}"
+ "#{user_combined_name} removed #{ref_type} #{ref} from #{project_link}"
end
def push_message
- "#{user_name} pushed to #{ref_type} #{branch_link} of #{project_link} (#{compare_link})"
+ "#{user_combined_name} pushed to #{ref_type} #{branch_link} of #{project_link} (#{compare_link})"
end
def commit_messages
diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb
index a139a8ee727..d84b80f2de2 100644
--- a/app/models/project_services/chat_message/wiki_page_message.rb
+++ b/app/models/project_services/chat_message/wiki_page_message.rb
@@ -31,7 +31,7 @@ module ChatMessage
def activity
{
- title: "#{user_name} #{action} #{wiki_page_link}",
+ title: "#{user_combined_name} #{action} #{wiki_page_link}",
subtitle: "in #{project_link}",
text: title,
image: user_avatar
@@ -41,7 +41,7 @@ module ChatMessage
private
def message
- "#{user_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*"
+ "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*"
end
def description_message
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 6d1a321f651..dab0ea1a681 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -99,11 +99,11 @@ class ChatNotificationService < Service
def get_message(object_kind, data)
case object_kind
when "push", "tag_push"
- ChatMessage::PushMessage.new(data)
+ ChatMessage::PushMessage.new(data) if notify_for_ref?(data)
when "issue"
- ChatMessage::IssueMessage.new(data) unless is_update?(data)
+ ChatMessage::IssueMessage.new(data) unless update?(data)
when "merge_request"
- ChatMessage::MergeMessage.new(data) unless is_update?(data)
+ ChatMessage::MergeMessage.new(data) unless update?(data)
when "note"
ChatMessage::NoteMessage.new(data)
when "pipeline"
@@ -115,7 +115,7 @@ class ChatNotificationService < Service
def get_channel_field(event)
field_name = event_channel_name(event)
- self.public_send(field_name)
+ self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend
end
def build_event_channels
@@ -129,14 +129,14 @@ class ChatNotificationService < Service
end
def project_name
- project.name_with_namespace.gsub(/\s/, '')
+ project.full_name.gsub(/\s/, '')
end
def project_url
project.web_url
end
- def is_update?(data)
+ def update?(data)
data[:object_attributes][:action] == 'update'
end
@@ -145,10 +145,16 @@ class ChatNotificationService < Service
end
def notify_for_ref?(data)
- return true if data[:object_attributes][:tag]
+ return true if data.dig(:object_attributes, :tag)
return true unless notify_only_default_branch?
- data[:object_attributes][:ref] == project.default_branch
+ ref = if data[:ref]
+ Gitlab::Git.ref_name(data[:ref])
+ else
+ data.dig(:object_attributes, :ref)
+ end
+
+ ref == project.default_branch
end
def notify_for_pipeline?(data)
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index f6cade9c290..71b10fc6bc1 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -49,7 +49,7 @@ class DroneCiService < CiService
end
def calculate_reactive_cache(sha, ref)
- response = HTTParty.get(commit_status_path(sha, ref), verify: enable_ssl_verification)
+ response = Gitlab::HTTP.get(commit_status_path(sha, ref), verify: enable_ssl_verification)
status =
if response.code == 200 && response['status']
@@ -114,7 +114,7 @@ class DroneCiService < CiService
end
def merge_request_valid?(data)
- %w(opened reopened).include?(data[:object_attributes][:state]) &&
+ data[:object_attributes][:state] == 'opened' &&
data[:object_attributes][:merge_status] == 'unchecked'
end
end
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index 1a236e232f9..b604d860a87 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -2,7 +2,7 @@ class EmailsOnPushService < Service
boolean_accessor :send_from_committer_email
boolean_accessor :disable_diffs
prop_accessor :recipients
- validates :recipients, presence: true, if: :activated?
+ validates :recipients, presence: true, if: :valid_recipients?
def title
'Emails on push'
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index 720ad61162e..1553f169827 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -1,6 +1,4 @@
class ExternalWikiService < Service
- include HTTParty
-
prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, url: true, if: :activated?
@@ -24,7 +22,7 @@ class ExternalWikiService < Service
end
def execute(_data)
- @response = HTTParty.get(properties['external_wiki_url'], verify: true) rescue nil
+ @response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) rescue nil
if @response != 200
nil
end
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 2db95b9aaa3..4d23a17a545 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -35,9 +35,9 @@ class FlowdockService < Service
data[:after],
token: token,
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"
+ repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}",
+ commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s",
+ diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s"
)
end
end
diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb
index ad4eb9536e1..88c428b4aae 100644
--- a/app/models/project_services/gitlab_issue_tracker_service.rb
+++ b/app/models/project_services/gitlab_issue_tracker_service.rb
@@ -1,5 +1,5 @@
class GitlabIssueTrackerService < IssueTrackerService
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated?
@@ -12,26 +12,26 @@ class GitlabIssueTrackerService < IssueTrackerService
end
def project_url
- namespace_project_issues_url(project.namespace, project)
+ project_issues_url(project)
end
def new_issue_url
- new_namespace_project_issue_url(namespace_id: project.namespace, project_id: project)
+ new_project_issue_url(project)
end
def issue_url(iid)
- namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: iid)
+ project_issue_url(project, id: iid)
end
- def project_path
- namespace_project_issues_path(project.namespace, project)
+ def issue_tracker_path
+ project_issues_path(project)
end
def new_issue_path
- new_namespace_project_issue_path(namespace_id: project.namespace, project_id: project)
+ new_project_issue_path(project)
end
def issue_path(iid)
- namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: iid)
+ project_issue_path(project, id: iid)
end
end
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index e3906943ecd..f31c3f02af2 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -51,9 +51,11 @@ class HipchatService < Service
def execute(data)
return unless supported_events.include?(data[:object_kind])
+
message = create_message(data)
return unless message.present?
- gate[room].send('GitLab', message, message_options(data))
+
+ gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend
end
def test(data)
@@ -85,9 +87,9 @@ class HipchatService < Service
when "push", "tag_push"
create_push_message(data)
when "issue"
- create_issue_message(data) unless is_update?(data)
+ create_issue_message(data) unless update?(data)
when "merge_request"
- create_merge_request_message(data) unless is_update?(data)
+ create_merge_request_message(data) unless update?(data)
when "note"
create_note_message(data)
when "pipeline"
@@ -108,6 +110,7 @@ class HipchatService < Service
message = ""
message << "#{push[:user_name]} "
+
if Gitlab::Git.blank_ref?(before)
message << "pushed new #{ref_type} <a href=\""\
"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\
@@ -117,7 +120,7 @@ class HipchatService < Service
else
message << "pushed to #{ref_type} <a href=\""\
"#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> "
- message << "of <a href=\"#{project.web_url}\">#{project.name_with_namespace.gsub!(/\s/, '')}</a> "
+ message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> "
message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)"
push[:commits].take(MAX_COMMITS).each do |commit|
@@ -271,7 +274,7 @@ class HipchatService < Service
end
def project_name
- project.name_with_namespace.gsub(/\s/, '')
+ project.full_name.gsub(/\s/, '')
end
def project_url
@@ -282,7 +285,7 @@ class HipchatService < Service
"<a href=\"#{project_url}\">#{project_name}</a>"
end
- def is_update?(data)
+ def update?(data)
data[:object_attributes][:action] == 'update'
end
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index 19357f90810..27bdf708c80 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -4,7 +4,7 @@ class IrkerService < Service
prop_accessor :server_host, :server_port, :default_irc_uri
prop_accessor :recipients, :channels
boolean_accessor :colorize_messages
- validates :recipients, presence: true, if: :activated?
+ validates :recipients, presence: true, if: :valid_recipients?
before_validation :get_channels
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index ff138b9066d..df6dcd90985 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -5,8 +5,15 @@ class IssueTrackerService < Service
# Pattern used to extract links from comments
# Override this method on services that uses different patterns
- def reference_pattern
- @reference_pattern ||= %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
+ # This pattern does not support cross-project references
+ # The other code assumes that this pattern is a superset of all
+ # overriden patterns. See ReferenceRegexes::EXTERNAL_PATTERN
+ def self.reference_pattern(only_long: false)
+ if only_long
+ /(\b[A-Z][A-Z0-9_]+-)(?<issue>\d+)/
+ else
+ /(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)/
+ end
end
def default?
@@ -17,7 +24,7 @@ class IssueTrackerService < Service
self.issues_url.gsub(':id', iid.to_s)
end
- def project_path
+ def issue_tracker_path
project_url
end
@@ -70,13 +77,13 @@ class IssueTrackerService < Service
result = false
begin
- response = HTTParty.head(self.project_url, verify: true)
+ response = Gitlab::HTTP.head(self.project_url, verify: true)
if response
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, OpenSSL::SSL::SSLError => error
+ rescue Gitlab::HTTP::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 2450fb43212..ed4bbfb6cfc 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -1,25 +1,28 @@
class JiraService < IssueTrackerService
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
+ include ApplicationHelper
+ include ActionView::Helpers::AssetUrlHelper
validates :url, url: true, presence: true, if: :activated?
validates :api_url, url: true, allow_blank: true
- validates :project_key, presence: true, if: :activated?
+ validates :username, presence: true, if: :activated?
+ validates :password, presence: true, if: :activated?
- prop_accessor :username, :password, :url, :api_url, :project_key,
- :jira_issue_transition_id, :title, :description
+ prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description
before_update :reset_password
- # This is confusing, but JiraService does not really support these events.
- # The values here are required to display correct options in the service
- # configuration screen.
+ alias_method :project_url, :url
+
+ # When these are false GitLab does not create cross reference
+ # comments on JIRA except when an issue gets transitioned.
def self.supported_events
%w(commit merge_request)
end
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
- def reference_pattern
- @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
+ def self.reference_pattern(only_long: true)
+ @reference_pattern ||= /(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)/
end
def initialize_properties
@@ -43,9 +46,11 @@ class JiraService < IssueTrackerService
username: self.username,
password: self.password,
site: URI.join(url, '/').to_s,
- context_path: url.path,
+ context_path: url.path.chomp('/'),
auth_type: :basic,
read_timeout: 120,
+ use_cookies: true,
+ additional_cookies: ['OBBasicAuth=fromDialog'],
use_ssl: url.scheme == 'https'
}
end
@@ -54,10 +59,6 @@ class JiraService < IssueTrackerService
@client ||= JIRA::Client.new(options)
end
- def jira_project
- @jira_project ||= jira_request { client.Project.find(project_key) }
- end
-
def help
"You need to configure JIRA before enabling this service. For more details
read the
@@ -88,18 +89,12 @@ class JiraService < IssueTrackerService
[
{ 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: '' }
+ { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID', placeholder: '' }
]
end
- # URLs to redirect from Gitlab issues pages to jira issue tracker
- def project_url
- "#{url}/issues/?jql=project=#{project_key}"
- end
-
def issues_url
"#{url}/browse/:id"
end
@@ -116,7 +111,7 @@ class JiraService < IssueTrackerService
def close_issue(entity, external_issue)
issue = jira_request { client.Issue.find(external_issue.iid) }
- return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present?
+ return if issue.nil? || has_resolution?(issue) || !jira_issue_transition_id.present?
commit_id = if entity.is_a?(Commit)
entity.id
@@ -130,7 +125,7 @@ class JiraService < IssueTrackerService
# may or may not be allowed. Refresh the issue after transition and check
# if it is closed, so we don't have one comment for every commit.
issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
- add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution
+ add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue)
end
def create_cross_reference_note(mentioned, noteable, author)
@@ -152,8 +147,8 @@ class JiraService < IssueTrackerService
url: resource_url(user_path(author))
},
project: {
- name: self.project.path_with_namespace,
- url: resource_url(namespace_project_path(project.namespace, self.project))
+ name: project.full_path,
+ url: resource_url(namespace_project_path(project.namespace, project)) # rubocop:disable Cop/ProjectPathHelper
},
entity: {
name: noteable_type.humanize.downcase,
@@ -165,14 +160,12 @@ class JiraService < IssueTrackerService
add_comment(data, jira_issue)
end
- # reason why service cannot be tested
- def disabled_title
- "Please fill in Password and Username."
- end
-
def test(_)
result = test_settings
- { success: result.present?, result: result }
+ success = result.present?
+ result = @error if @error && !success
+
+ { success: success, result: result }
end
# JIRA does not need test data.
@@ -183,8 +176,9 @@ class JiraService < IssueTrackerService
def test_settings
return unless client_url.present?
+
# Test settings by getting the project
- jira_request { jira_project.present? }
+ jira_request { client.ServerInfo.all.attrs }
end
private
@@ -225,6 +219,10 @@ class JiraService < IssueTrackerService
end
end
+ def has_resolution?(issue)
+ issue.respond_to?(:resolution) && issue.resolution.present?
+ end
+
def comment_exists?(issue, message)
comments = jira_request { issue.comments }
@@ -266,7 +264,9 @@ class JiraService < IssueTrackerService
url: url,
title: title,
status: status,
- icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' }
+ icon: {
+ title: 'GitLab', url16x16: asset_url('favicon.ico', host: gitlab_config.url)
+ }
}
}
end
@@ -300,7 +300,8 @@ class JiraService < IssueTrackerService
yield
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}"
+ @error = e.message
+ Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{@error}"
nil
end
@@ -316,4 +317,13 @@ class JiraService < IssueTrackerService
url_changed?
end
+
+ def self.event_description(event)
+ case event
+ when "merge_request", "merge_request_events"
+ "JIRA comments will be created when an issue gets referenced in a merge request."
+ when "commit", "commit_events"
+ "JIRA comments will be created when an issue gets referenced in a commit."
+ end
+ end
end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index 48e7802c557..20fed432e55 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -1,5 +1,9 @@
+##
+# NOTE:
+# We'll move this class to Clusters::Platforms::Kubernetes, which contains exactly the same logic.
+# After we've migrated data, we'll remove KubernetesService. This would happen in a few months.
+# If you're modyfiyng this class, please note that you should update the same change in Clusters::Platforms::Kubernetes.
class KubernetesService < DeploymentService
- include Gitlab::CurrentSettings
include Gitlab::Kubernetes
include ReactiveCaching
@@ -24,6 +28,9 @@ class KubernetesService < DeploymentService
validates :token
end
+ before_validation :enforce_namespace_to_lower_case
+
+ validate :deprecation_validation, unless: :template?
validates :namespace,
allow_blank: true,
length: 1..63,
@@ -59,21 +66,21 @@ class KubernetesService < DeploymentService
def fields
[
{ type: 'text',
- name: 'namespace',
- title: 'Kubernetes namespace',
- placeholder: namespace_placeholder },
- { type: 'text',
name: 'api_url',
title: 'API URL',
placeholder: 'Kubernetes API URL, like https://kube.example.com/' },
- { type: 'text',
- name: 'token',
- title: 'Service token',
- placeholder: 'Service token' },
{ type: 'textarea',
name: 'ca_pem',
- title: 'Custom CA bundle',
- placeholder: 'Certificate Authority bundle (PEM format)' }
+ title: 'CA Certificate',
+ placeholder: 'Certificate Authority bundle (PEM format)' },
+ { type: 'text',
+ name: 'namespace',
+ title: 'Project namespace (optional/unique)',
+ placeholder: namespace_placeholder },
+ { type: 'text',
+ name: 'token',
+ title: 'Token',
+ placeholder: 'Service token' }
]
end
@@ -96,18 +103,21 @@ class KubernetesService < DeploymentService
end
def predefined_variables
- variables = [
- { key: 'KUBE_URL', value: api_url, public: true },
- { key: 'KUBE_TOKEN', value: token, public: false },
- { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }
- ]
-
- if ca_pem.present?
- variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true }
- variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true }
+ config = YAML.dump(kubeconfig)
+
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables
+ .append(key: 'KUBE_URL', value: api_url)
+ .append(key: 'KUBE_TOKEN', value: token, public: false)
+ .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
+ .append(key: 'KUBECONFIG', value: config, public: false, file: true)
+
+ if ca_pem.present?
+ variables
+ .append(key: 'KUBE_CA_PEM', value: ca_pem)
+ .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true)
+ end
end
-
- variables
end
# Constructs a list of terminals from the reactive cache
@@ -131,16 +141,43 @@ class KubernetesService < DeploymentService
{ pods: read_pods }
end
+ def kubeclient
+ @kubeclient ||= build_kubeclient!
+ end
+
+ def deprecated?
+ !active
+ end
+
+ def deprecation_message
+ content = _("Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page") % {
+ deprecated_message_content: deprecated_message_content,
+ url: Gitlab::Routing.url_helpers.project_clusters_path(project)
+ }
+ content.html_safe
+ end
+
TEMPLATE_PLACEHOLDER = 'Kubernetes namespace'.freeze
private
+ def kubeconfig
+ to_kubeconfig(
+ url: api_url,
+ namespace: actual_namespace,
+ token: token,
+ ca_pem: ca_pem)
+ end
+
def namespace_placeholder
default_namespace || TEMPLATE_PLACEHOLDER
end
def default_namespace
- "#{project.path}-#{project.id}" if project.present?
+ return unless project
+
+ slug = "#{project.path}-#{project.id}".downcase
+ slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '')
end
def build_kubeclient!(api_path: 'api', api_version: 'v1')
@@ -160,8 +197,9 @@ class KubernetesService < DeploymentService
kubeclient = build_kubeclient!
kubeclient.get_pods(namespace: actual_namespace).as_json
- rescue KubeException => err
+ rescue Kubeclient::HttpError => err
raise err unless err.error_code == 404
+
[]
end
@@ -193,7 +231,27 @@ class KubernetesService < DeploymentService
{
token: token,
ca_pem: ca_pem,
- max_session_time: current_application_settings.terminal_max_session_time
+ max_session_time: Gitlab::CurrentSettings.terminal_max_session_time
}
end
+
+ def enforce_namespace_to_lower_case
+ self.namespace = self.namespace&.downcase
+ end
+
+ def deprecation_validation
+ return if active_changed?(from: true, to: false)
+
+ if deprecated?
+ errors[:base] << deprecation_message
+ end
+ end
+
+ def deprecated_message_content
+ if active?
+ _("Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure")
+ else
+ _("Fields on this page are now uneditable, you can configure")
+ end
+ end
end
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index 4d2037286a2..227d430083d 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -37,7 +37,7 @@ class MattermostSlashCommandsService < SlashCommandsService
private
def command(params)
- pretty_project_name = project.name_with_namespace
+ pretty_project_name = project.full_name
params.merge(
auto_complete: true,
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index 72ddf9a4be3..2221459c90b 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -52,7 +52,7 @@ class MockCiService < CiService
#
#
def commit_status(sha, ref)
- response = HTTParty.get(commit_status_path(sha), verify: false)
+ response = Gitlab::HTTP.get(commit_status_path(sha), verify: false)
read_commit_status(response)
rescue Errno::ECONNREFUSED
:error
diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb
index ee9cd78327a..9af68b4e821 100644
--- a/app/models/project_services/monitoring_service.rb
+++ b/app/models/project_services/monitoring_service.rb
@@ -9,11 +9,11 @@ class MonitoringService < Service
%w()
end
- def environment_metrics(environment)
+ def can_query?
raise NotImplementedError
end
- def deployment_metrics(deployment)
+ def query(_, *_)
raise NotImplementedError
end
end
diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb
new file mode 100644
index 00000000000..ba62a5b7ac0
--- /dev/null
+++ b/app/models/project_services/packagist_service.rb
@@ -0,0 +1,63 @@
+class PackagistService < Service
+ prop_accessor :username, :token, :server
+
+ validates :username, presence: true, if: :activated?
+ validates :token, presence: true, if: :activated?
+
+ default_value_for :push_events, true
+ default_value_for :tag_push_events, true
+
+ after_save :compose_service_hook, if: :activated?
+
+ def title
+ 'Packagist'
+ end
+
+ def description
+ 'Update your project on Packagist, the main Composer repository'
+ end
+
+ def self.to_param
+ 'packagist'
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'username', placeholder: '', required: true },
+ { type: 'text', name: 'token', placeholder: '', required: true },
+ { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false }
+ ]
+ end
+
+ def self.supported_events
+ %w(push merge_request tag_push)
+ end
+
+ def execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ service_hook.execute(data)
+ end
+
+ def test(data)
+ begin
+ result = execute(data)
+ return { success: false, result: result[:message] } if result[:http_status] != 202
+ rescue StandardError => error
+ return { success: false, result: error }
+ end
+
+ { success: true, result: result[:message] }
+ end
+
+ def compose_service_hook
+ hook = service_hook || build_service_hook
+ hook.url = hook_url
+ hook.save
+ end
+
+ def hook_url
+ base_url = server.present? ? server : 'https://packagist.org'
+ "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}"
+ end
+end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index 9d37184be2c..4cf149ac044 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -1,7 +1,7 @@
class PipelinesEmailService < Service
prop_accessor :recipients
boolean_accessor :notify_only_broken_pipelines
- validates :recipients, presence: true, if: :activated?
+ validates :recipients, presence: true, if: :valid_recipients?
def initialize_properties
self.properties ||= { notify_only_broken_pipelines: true }
@@ -39,10 +39,6 @@ class PipelinesEmailService < Service
project.pipelines.any?
end
- def disabled_title
- 'Please setup a pipeline on your repository.'
- end
-
def test_data(project, user)
data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last)
data[:user] = user.hook_attrs
@@ -80,6 +76,6 @@ class PipelinesEmailService < Service
end
def retrieve_recipients(data)
- recipients.to_s.split(',').reject(&:blank?)
+ recipients.to_s.split(/[,(?:\r?\n) ]+/).reject(&:empty?)
end
end
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index f9dfa2e91c3..3476e7d2283 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -1,6 +1,4 @@
class PivotaltrackerService < Service
- include HTTParty
-
API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'.freeze
prop_accessor :token, :restrict_to_branch
@@ -52,7 +50,7 @@ class PivotaltrackerService < Service
'message' => commit[:message]
}
}
- PivotaltrackerService.post(
+ Gitlab::HTTP.post(
API_ENDPOINT,
body: message.to_json,
headers: {
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 217f753f05f..dcaeb65dc32 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -1,17 +1,16 @@
class PrometheusService < MonitoringService
- include ReactiveService
-
- self.reactive_cache_lease_timeout = 30.seconds
- self.reactive_cache_refresh_interval = 30.seconds
- self.reactive_cache_lifetime = 1.minute
+ include PrometheusAdapter
# Access to prometheus is directly through the API
prop_accessor :api_url
+ boolean_accessor :manual_configuration
- with_options presence: true, if: :activated? do
+ with_options presence: true, if: :manual_configuration? do
validates :api_url, url: true
end
+ before_save :synchronize_service_state
+
after_save :clear_reactive_cache!
def initialize_properties
@@ -20,12 +19,20 @@ class PrometheusService < MonitoringService
end
end
+ def show_active_box?
+ false
+ end
+
+ def editable?
+ manual_configuration? || !prometheus_installed?
+ end
+
def title
'Prometheus'
end
def description
- 'Prometheus monitoring'
+ s_('PrometheusService|Time-series monitoring service')
end
def self.to_param
@@ -33,13 +40,21 @@ class PrometheusService < MonitoringService
end
def fields
+ return [] unless editable?
+
[
{
+ type: 'checkbox',
+ name: 'manual_configuration',
+ title: s_('PrometheusService|Active'),
+ required: true
+ },
+ {
type: 'text',
name: 'api_url',
title: 'API URL',
- placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/',
- help: 'By default, Prometheus listens on ‘http://localhost:9090’. It’s not recommended to change the default address and port as this might affect or conflict with other services running on the GitLab server.',
+ placeholder: s_('PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/'),
+ help: s_('PrometheusService|By default, Prometheus listens on ‘http://localhost:9090’. It’s not recommended to change the default address and port as this might affect or conflict with other services running on the GitLab server.'),
required: true
}
]
@@ -47,56 +62,29 @@ class PrometheusService < MonitoringService
# Check we can connect to the Prometheus API
def test(*args)
- client.ping
+ Gitlab::PrometheusClient.new(prometheus_client).ping
{ success: true, result: 'Checked API endpoint' }
- rescue Gitlab::PrometheusError => err
+ rescue Gitlab::PrometheusClient::Error => err
{ success: false, result: err }
end
- def environment_metrics(environment)
- with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &method(:rename_data_to_metrics))
+ def prometheus_client
+ RestClient::Resource.new(api_url) if api_url && manual_configuration? && active?
end
- def deployment_metrics(deployment)
- metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &method(:rename_data_to_metrics))
- metrics&.merge(deployment_time: deployment.created_at.to_i) || {}
- end
+ def prometheus_installed?
+ return false if template?
+ return false unless project
- def additional_environment_metrics(environment)
- with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery.name, environment.id, &:itself)
- end
-
- def additional_deployment_metrics(deployment)
- with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.id, &:itself)
- end
-
- def matched_metrics
- with_reactive_cache(Gitlab::Prometheus::Queries::MatchedMetricsQuery.name, &:itself)
- end
-
- # Cache metrics for specific environment
- def calculate_reactive_cache(query_class_name, *args)
- return unless active? && project && !project.pending_delete?
-
- data = Kernel.const_get(query_class_name).new(client).query(*args)
- {
- success: true,
- data: data,
- last_update: Time.now.utc
- }
- rescue Gitlab::PrometheusError => err
- { success: false, result: err.message }
- end
-
- def client
- @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url)
+ project.clusters.enabled.any? { |cluster| cluster.application_prometheus&.installed? }
end
private
- def rename_data_to_metrics(metrics)
- metrics[:metrics] = metrics.delete :data
- metrics
+ def synchronize_service_state
+ self.active = prometheus_installed? || manual_configuration?
+
+ true
end
end
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index aa7bd4c3c84..8777a44b72f 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -1,6 +1,5 @@
class PushoverService < Service
- include HTTParty
- base_uri 'https://api.pushover.net/1'
+ BASE_URI = 'https://api.pushover.net/1'.freeze
prop_accessor :api_key, :user_key, :device, :priority, :sound
validates :api_key, :user_key, :priority, presence: true, if: :activated?
@@ -88,10 +87,10 @@ class PushoverService < Service
user: user_key,
device: device,
priority: priority,
- title: "#{project.name_with_namespace}",
+ title: "#{project.full_name}",
message: message,
url: data[:project][:web_url],
- url_title: "See project #{project.name_with_namespace}"
+ url_title: "See project #{project.full_name}"
}
# Sound parameter MUST NOT be sent to API if not selected
@@ -99,6 +98,6 @@ class PushoverService < Service
pushover_data[:sound] = sound
end
- PushoverService.post('/messages.json', body: pushover_data)
+ Gitlab::HTTP.post('/messages.json', base_uri: BASE_URI, body: pushover_data)
end
end
diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb
index 4592cb747a0..37ea45109ae 100644
--- a/app/models/project_services/slash_commands_service.rb
+++ b/app/models/project_services/slash_commands_service.rb
@@ -5,7 +5,7 @@ class SlashCommandsService < Service
prop_accessor :token
- has_many :chat_names, foreign_key: :service_id, dependent: :destroy
+ has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
def valid_token?(token)
self.respond_to?(:token) &&
@@ -30,10 +30,10 @@ class SlashCommandsService < Service
def trigger(params)
return unless valid_token?(params[:token])
- user = find_chat_user(params)
+ chat_user = find_chat_user(params)
- if user
- Gitlab::SlashCommands::Command.new(project, user, params).execute
+ if chat_user&.user
+ Gitlab::SlashCommands::Command.new(project, chat_user, params).execute
else
url = authorize_chat_name_url(params)
Gitlab::SlashCommands::Presenters::Access.new(url).authorize
diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb
index cbe137452bd..145313b8e71 100644
--- a/app/models/project_services/teamcity_service.rb
+++ b/app/models/project_services/teamcity_service.rb
@@ -83,7 +83,7 @@ class TeamcityService < CiService
branch = Gitlab::Git.ref_name(data[:ref])
- HTTParty.post(
+ Gitlab::HTTP.post(
build_url('httpAuth/app/rest/buildQueue'),
body: "<build branchName=\"#{branch}\">"\
"<buildType id=\"#{build_type}\"/>"\
@@ -134,10 +134,10 @@ class TeamcityService < CiService
end
def get_path(path)
- HTTParty.get(build_url(path), verify: false,
- basic_auth: {
- username: username,
- password: password
- })
+ Gitlab::HTTP.get(build_url(path), verify: false,
+ basic_auth: {
+ username: username,
+ password: password
+ })
end
end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index aeaf63abab9..87a4350f022 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -14,7 +14,7 @@ class ProjectStatistics < ActiveRecord::Base
def refresh!(only: nil)
STATISTICS_COLUMNS.each do |column, generator|
if only.blank? || only.include?(column)
- public_send("update_#{column}")
+ public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -35,7 +35,9 @@ class ProjectStatistics < ActiveRecord::Base
end
def update_build_artifacts_size
- self.build_artifacts_size = project.builds.sum(:artifacts_size)
+ self.build_artifacts_size =
+ project.builds.sum(:artifacts_size) +
+ Ci::JobArtifact.artifacts_size_for(self.project)
end
def update_storage_size
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 674eacd28e8..33280eda0b9 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -1,40 +1,30 @@
class ProjectTeam
+ include BulkMemberAccessLoad
+
attr_accessor :project
def initialize(project)
@project = project
end
- # Shortcut to add users
- #
- # Use:
- # @team << [@user, :master]
- # @team << [@users, :master]
- #
- def <<(args)
- users, access, current_user = *args
-
- if users.respond_to?(:each)
- add_users(users, access, current_user: current_user)
- else
- add_user(users, access, current_user: current_user)
- end
- end
-
def add_guest(user, current_user: nil)
- self << [user, :guest, current_user]
+ add_user(user, :guest, current_user: current_user)
end
def add_reporter(user, current_user: nil)
- self << [user, :reporter, current_user]
+ add_user(user, :reporter, current_user: current_user)
end
def add_developer(user, current_user: nil)
- self << [user, :developer, current_user]
+ add_user(user, :developer, current_user: current_user)
end
def add_master(user, current_user: nil)
- self << [user, :master, current_user]
+ add_user(user, :master, current_user: current_user)
+ end
+
+ def add_role(user, role, current_user: nil)
+ send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend
end
def find_member(user_id)
@@ -95,6 +85,15 @@ class ProjectTeam
@masters ||= fetch_members(Gitlab::Access::MASTER)
end
+ def owners
+ @owners ||=
+ if group
+ group.owners
+ else
+ [project.owner]
+ end
+ end
+
def import(source_project, current_user = nil)
target_project = project
@@ -146,50 +145,27 @@ class ProjectTeam
def member?(user, min_access_level = Gitlab::Access::GUEST)
return false unless user
- user.authorized_project?(project, min_access_level)
+ max_member_access(user.id) >= min_access_level
end
def human_max_access(user_id)
- Gitlab::Access.options_with_owner.key(max_member_access(user_id))
+ Gitlab::Access.human_access(max_member_access(user_id))
end
# Determine the maximum access level for a group of users in bulk.
#
# Returns a Hash mapping user ID -> maximum access level.
def max_member_access_for_user_ids(user_ids)
- user_ids = user_ids.uniq
- key = "max_member_access:#{project.id}"
-
- access = {}
-
- if RequestStore.active?
- RequestStore.store[key] ||= {}
- access = RequestStore.store[key]
+ max_member_access_for_resource_ids(User, user_ids, project.id) do |user_ids|
+ project.project_authorizations
+ .where(user: user_ids)
+ .group(:user_id)
+ .maximum(:access_level)
end
-
- # Look up only the IDs we need
- user_ids = user_ids - access.keys
-
- return access if user_ids.empty?
-
- users_access = project.project_authorizations
- .where(user: user_ids)
- .group(:user_id)
- .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
def max_member_access(user_id)
- max_member_access_for_user_ids([user_id])[user_id] || Gitlab::Access::NO_ACCESS
+ max_member_access_for_user_ids([user_id])[user_id]
end
private
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index f38fbda7839..52e067cb44c 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -1,5 +1,6 @@
class ProjectWiki
include Gitlab::ShellAdapter
+ include Storage::LegacyProjectWiki
MARKUPS = {
'Markdown' => :markdown,
@@ -20,22 +21,25 @@ class ProjectWiki
end
delegate :empty?, to: :pages
- delegate :repository_storage_path, to: :project
+ delegate :repository_storage_path, :hashed_storage?, to: :project
def path
@project.path + '.wiki'
end
- def path_with_namespace
- @project.path_with_namespace + ".wiki"
+ def full_path
+ @project.full_path + '.wiki'
end
+ # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
+ alias_method :path_with_namespace, :full_path
+
def web_url
- Gitlab::Routing.url_helpers.namespace_project_wiki_url(@project.namespace, @project, :home)
+ Gitlab::Routing.url_helpers.project_wiki_url(@project, :home)
end
def url_to_repo
- gitlab_shell.url_to_repo(path_with_namespace)
+ gitlab_shell.url_to_repo(full_path)
end
def ssh_url_to_repo
@@ -43,19 +47,22 @@ class ProjectWiki
end
def http_url_to_repo
- "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git"
+ "#{Gitlab.config.gitlab.url}/#{full_path}.git"
end
def wiki_base_path
- [Gitlab.config.gitlab.relative_url_root, "/", @project.path_with_namespace, "/wikis"].join('')
+ [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/wikis'].join('')
end
- # Returns the Gollum::Wiki object.
+ # Returns the Gitlab::Git::Wiki object.
def wiki
@wiki ||= begin
- Gollum::Wiki.new(path_to_repo)
- rescue Rugged::OSError
- create_repo!
+ gl_repository = Gitlab::GlRepository.gl_repository(project, true)
+ raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository)
+
+ create_repo!(raw_repository) unless raw_repository.exists?
+
+ Gitlab::Git::Wiki.new(raw_repository)
end
end
@@ -63,10 +70,14 @@ class ProjectWiki
!!repository.exists?
end
+ def has_home_page?
+ !!find_page('home')
+ end
+
# Returns an Array of Gitlab WikiPage instances or an
# empty Array if this Wiki has no pages.
- def pages
- wiki.pages.map { |page| WikiPage.new(self, page, true) }
+ def pages(limit: nil)
+ wiki.pages(limit: limit).map { |page| WikiPage.new(self, page, true) }
end
# Finds a page within the repository based on a tile
@@ -78,20 +89,14 @@ class ProjectWiki
# Returns an initialized WikiPage instance or nil
def find_page(title, version = nil)
page_title, page_dir = page_title_and_dir(title)
- if page = wiki.page(page_title, version, page_dir)
+
+ if page = wiki.page(title: page_title, version: version, dir: page_dir)
WikiPage.new(self, page, true)
- else
- nil
end
end
- def find_file(name, version = nil, try_on_disk = true)
- version = wiki.ref if version.nil? # Gollum::Wiki#file ?
- if wiki_file = wiki.file(name, version, try_on_disk)
- wiki_file
- else
- nil
- end
+ def find_file(name, version = nil)
+ wiki.file(name, version)
end
def create_page(title, content, format = :markdown, message = nil)
@@ -100,26 +105,36 @@ class ProjectWiki
wiki.write_page(title, format.to_sym, content, commit)
update_project_activity
- rescue Gollum::DuplicatePageError => e
+ rescue Gitlab::Git::Wiki::DuplicatePageError => e
@error_message = "Duplicate page: #{e.message}"
return false
end
- def update_page(page, content, format = :markdown, message = nil)
+ def update_page(page, content:, title: nil, format: :markdown, message: nil)
commit = commit_details(:updated, message, page.title)
- wiki.update_page(page, page.name, format.to_sym, content, commit)
+ wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
update_project_activity
end
def delete_page(page, message = nil)
- wiki.delete_page(page, commit_details(:deleted, message, page.title))
+ return unless page
+
+ wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
update_project_activity
end
+ def page_formatted_data(page)
+ page_title, page_dir = page_title_and_dir(page.title)
+
+ wiki.page_formatted_data(title: page_title, dir: page_dir, version: page.version)
+ end
+
def page_title_and_dir(title)
+ return unless title
+
title_array = title.split("/")
title = title_array.pop
[title, title_array.join("/")]
@@ -130,23 +145,15 @@ class ProjectWiki
end
def repository
- @repository ||= Repository.new(path_with_namespace, @project)
+ @repository ||= Repository.new(full_path, @project, disk_path: disk_path, is_wiki: true)
end
def default_branch
wiki.class.default_ref
end
- def create_repo!
- if init_repo(path_with_namespace)
- wiki = Gollum::Wiki.new(path_to_repo)
- else
- raise CouldNotCreateWikiError
- end
-
- repository.after_create
-
- wiki
+ def ensure_repository
+ raise CouldNotCreateWikiError unless wiki.repository_exists?
end
def hook_attrs
@@ -154,31 +161,31 @@ class ProjectWiki
web_url: web_url,
git_ssh_url: ssh_url_to_repo,
git_http_url: http_url_to_repo,
- path_with_namespace: path_with_namespace,
+ path_with_namespace: full_path,
default_branch: default_branch
}
end
private
- def init_repo(path_with_namespace)
- gitlab_shell.add_repository(project.repository_storage_path, path_with_namespace)
+ def create_repo!(raw_repository)
+ gitlab_shell.create_repository(project.repository_storage, disk_path)
+
+ raise CouldNotCreateWikiError unless raw_repository.exists?
+
+ repository.after_create
end
def commit_details(action, message = nil, title = nil)
commit_message = message || default_message(action, title)
- { email: @user.email, name: @user.name, message: commit_message }
+ Gitlab::Git::Wiki::CommitDetails.new(@user.name, @user.email, commit_message)
end
def default_message(action, title)
"#{@user.username} #{action} page: #{title}"
end
- def path_to_repo
- @path_to_repo ||= File.join(project.repository_storage_path, "#{path_with_namespace}.git")
- end
-
def update_project_activity
@project.touch(:last_activity_at, :last_repository_updated_at)
end
diff --git a/app/models/protectable_dropdown.rb b/app/models/protectable_dropdown.rb
index 122fbce257d..c96edc5a259 100644
--- a/app/models/protectable_dropdown.rb
+++ b/app/models/protectable_dropdown.rb
@@ -1,5 +1,9 @@
class ProtectableDropdown
+ REF_TYPES = %i[branches tags].freeze
+
def initialize(project, ref_type)
+ raise ArgumentError, "invalid ref type `#{ref_type}`" unless ref_type.in?(REF_TYPES)
+
@project = project
@ref_type = ref_type
end
@@ -16,7 +20,7 @@ class ProtectableDropdown
private
def refs
- @project.repository.public_send(@ref_type)
+ @project.repository.public_send(@ref_type) # rubocop:disable GitlabSecurity/PublicSend
end
def ref_names
@@ -24,7 +28,7 @@ class ProtectableDropdown
end
def protections
- @project.public_send("protected_#{@ref_type}")
+ @project.public_send("protected_#{@ref_type}") # rubocop:disable GitlabSecurity/PublicSend
end
def non_wildcard_protected_ref_names
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 5f0d0802ac9..609780c5587 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -8,11 +8,13 @@ class ProtectedBranch < ActiveRecord::Base
def self.protected?(project, ref_name)
return true if project.empty_repo? && default_branch_protected?
- self.matching(ref_name, protected_refs: project.protected_branches).present?
+ refs = project.protected_branches.select(:name)
+
+ self.matching(ref_name, protected_refs: refs).present?
end
def self.default_branch_protected?
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
+ Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
+ Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index f38109c0e52..42a9bcf7723 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -5,6 +5,8 @@ class ProtectedTag < ActiveRecord::Base
protected_ref_access_levels :create
def self.protected?(project, ref_name)
- self.matching(ref_name, protected_refs: project.protected_tags).present?
+ refs = project.protected_tags.select(:name)
+
+ self.matching(ref_name, protected_refs: refs).present?
end
end
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
index c7e1319719d..6b6ab3d8279 100644
--- a/app/models/protected_tag/create_access_level.rb
+++ b/app/models/protected_tag/create_access_level.rb
@@ -1,18 +1,6 @@
class ProtectedTag::CreateAccessLevel < ActiveRecord::Base
include ProtectedTagAccess
- 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
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
new file mode 100644
index 00000000000..90c085c888e
--- /dev/null
+++ b/app/models/push_event.rb
@@ -0,0 +1,114 @@
+class PushEvent < Event
+ # This validation exists so we can't accidentally use PushEvent with a
+ # different "action" value.
+ validate :validate_push_action
+
+ # The project is required to build links to commits, commit ranges, etc.
+ #
+ # We're just validating the presence of the ID here as foreign key constraints
+ # should ensure the ID points to a valid project.
+ validates :project_id, presence: true
+
+ # These fields are also not used for push events, thus storing them would be a
+ # waste.
+ validates :target_id, absence: true
+ validates :target_type, absence: true
+
+ delegate :branch?, to: :push_event_payload
+ delegate :tag?, to: :push_event_payload
+ delegate :commit_from, to: :push_event_payload
+ delegate :commit_to, to: :push_event_payload
+ delegate :ref_type, to: :push_event_payload
+ delegate :commit_title, to: :push_event_payload
+
+ delegate :commit_count, to: :push_event_payload
+ alias_method :commits_count, :commit_count
+
+ # Returns events of pushes that either pushed to an existing ref or created a
+ # new one.
+ def self.created_or_pushed
+ actions = [
+ PushEventPayload.actions[:pushed],
+ PushEventPayload.actions[:created]
+ ]
+
+ joins(:push_event_payload)
+ .where(push_event_payloads: { action: actions })
+ end
+
+ # Returns events of pushes to a branch.
+ def self.branch_events
+ ref_type = PushEventPayload.ref_types[:branch]
+
+ joins(:push_event_payload)
+ .where(push_event_payloads: { ref_type: ref_type })
+ end
+
+ # Returns PushEvent instances for which no merge requests have been created.
+ def self.without_existing_merge_requests
+ existing_mrs = MergeRequest.except(:order, :where)
+ .select(1)
+ .where('merge_requests.source_project_id = events.project_id')
+ .where('merge_requests.source_branch = push_event_payloads.ref')
+ .where(state: :opened)
+
+ # For reasons unknown the use of #eager_load will result in the
+ # "push_event_payload" association not being set. Because of this we're
+ # using "joins" here, which does mean an additional query needs to be
+ # executed in order to retrieve the "push_event_association" when the
+ # returned PushEvent is used.
+ joins(:push_event_payload)
+ .where('NOT EXISTS (?)', existing_mrs)
+ .created_or_pushed
+ .branch_events
+ end
+
+ def self.sti_name
+ PUSHED
+ end
+
+ def push?
+ true
+ end
+
+ def push_with_commits?
+ !!(commit_from && commit_to)
+ end
+
+ def valid_push?
+ push_event_payload.ref.present?
+ end
+
+ def new_ref?
+ push_event_payload.created?
+ end
+
+ def rm_ref?
+ push_event_payload.removed?
+ end
+
+ def md_ref?
+ !(rm_ref? || new_ref?)
+ end
+
+ def ref_name
+ push_event_payload.ref
+ end
+
+ alias_method :branch_name, :ref_name
+ alias_method :tag_name, :ref_name
+
+ def commit_id
+ commit_to || commit_from
+ end
+
+ def last_push_to_non_root?
+ branch? && project.default_branch != branch_name
+ end
+
+ def validate_push_action
+ return if action == PUSHED
+
+ errors.add(:action, "the action #{action.inspect} is not valid")
+ end
+end
diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb
new file mode 100644
index 00000000000..6cdb1cd4fe9
--- /dev/null
+++ b/app/models/push_event_payload.rb
@@ -0,0 +1,22 @@
+class PushEventPayload < ActiveRecord::Base
+ include ShaAttribute
+
+ belongs_to :event, inverse_of: :push_event_payload
+
+ validates :event_id, :commit_count, :action, :ref_type, presence: true
+ validates :commit_title, length: { maximum: 70 }
+
+ sha_attribute :commit_from
+ sha_attribute :commit_to
+
+ enum action: {
+ created: 0,
+ removed: 1,
+ pushed: 2
+ }
+
+ enum ref_type: {
+ branch: 0,
+ tag: 1
+ }
+end
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 964175ddab8..20532527346 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -8,5 +8,41 @@ class RedirectRoute < ActiveRecord::Base
presence: true,
uniqueness: { case_sensitive: false }
- scope :matching_path_and_descendants, -> (path) { where('redirect_routes.path = ? OR redirect_routes.path LIKE ?', path, "#{sanitize_sql_like(path)}/%") }
+ scope :matching_path_and_descendants, -> (path) do
+ wheres = if Gitlab::Database.postgresql?
+ 'LOWER(redirect_routes.path) = LOWER(?) OR LOWER(redirect_routes.path) LIKE LOWER(?)'
+ else
+ 'redirect_routes.path = ? OR redirect_routes.path LIKE ?'
+ end
+
+ where(wheres, path, "#{sanitize_sql_like(path)}/%")
+ end
+
+ scope :permanent, -> do
+ if column_permanent_exists?
+ where(permanent: true)
+ else
+ none
+ end
+ end
+
+ scope :temporary, -> do
+ if column_permanent_exists?
+ where(permanent: [false, nil])
+ else
+ all
+ end
+ end
+
+ default_value_for :permanent, false
+
+ def permanent=(value)
+ if self.class.column_permanent_exists?
+ super
+ end
+ end
+
+ def self.column_permanent_exists?
+ ActiveRecord::Base.connection.column_exists?(:redirect_routes, :permanent)
+ end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index c67475357d9..42f1ac43e29 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1,14 +1,28 @@
require 'securerandom'
class Repository
+ REF_MERGE_REQUEST = 'merge-requests'.freeze
+ REF_KEEP_AROUND = 'keep-around'.freeze
+ REF_ENVIRONMENTS = 'environments'.freeze
+ MAX_DIVERGING_COUNT = 1000
+
+ RESERVED_REFS_NAMES = %W[
+ heads
+ tags
+ replace
+ #{REF_ENVIRONMENTS}
+ #{REF_KEEP_AROUND}
+ #{REF_ENVIRONMENTS}
+ ].freeze
+
include Gitlab::ShellAdapter
- include RepositoryMirroring
+ include Gitlab::RepositoryCacheAdapter
- attr_accessor :path_with_namespace, :project
+ attr_accessor :full_path, :disk_path, :project, :is_wiki
delegate :ref_name_for_sha, to: :raw_repository
+ delegate :bundle_to_disk, :create_from_bundle, to: :raw_repository
- CommitError = Class.new(StandardError)
CreateTreeError = Class.new(StandardError)
# Methods that cache data from the Git repository.
@@ -22,7 +36,11 @@ class Repository
CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide
changelog license_blob license_key gitignore koding_yml
gitlab_ci_yml branch_names tag_names branch_count
- tag_count avatar exists? empty? root_ref).freeze
+ tag_count avatar exists? root_ref has_visible_content?
+ issue_template_names merge_request_template_names).freeze
+
+ # Methods that use cache_method but only memoize the value
+ MEMOIZED_CACHED_METHODS = %i(license).freeze
# Certain method caches should be refreshed when certain types of files are
# changed. This Hash maps file types (as returned by Gitlab::FileDetector) to
@@ -35,71 +53,78 @@ class Repository
gitignore: :gitignore,
koding: :koding_yml,
gitlab_ci: :gitlab_ci_yml,
- avatar: :avatar
+ avatar: :avatar,
+ issue_template: :issue_template_names,
+ merge_request_template: :merge_request_template_names
}.freeze
- # Wraps around the given method and caches its output in Redis and an instance
- # variable.
- #
- # This only works for methods that do not take any arguments.
- 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, memoize_only: memoize_only) { __send__(original) }
- end
+ def initialize(full_path, project, disk_path: nil, is_wiki: false)
+ @full_path = full_path
+ @disk_path = disk_path || full_path
+ @project = project
+ @commit_cache = {}
+ @is_wiki = is_wiki
end
- def initialize(path_with_namespace, project)
- @path_with_namespace = path_with_namespace
- @project = project
+ def ==(other)
+ @disk_path == other.disk_path
end
def raw_repository
- return nil unless path_with_namespace
+ return nil unless full_path
@raw_repository ||= initialize_raw_repository
end
+ alias_method :raw, :raw_repository
+
+ def cleanup
+ @raw_repository&.cleanup
+ end
+
# Return absolute path to repository
def path_to_repo
@path_to_repo ||= File.expand_path(
- File.join(repository_storage_path, path_with_namespace + ".git")
+ File.join(repository_storage_path, disk_path + '.git')
)
end
- #
- # Git repository can contains some hidden refs like:
- # /refs/notes/*
- # /refs/git-as-svn/*
- # /refs/pulls/*
- # This refs by default not visible in project page and not cloned to client side.
- #
- # This method return true if repository contains some content visible in project page.
- #
- def has_visible_content?
- branch_count > 0
+ def inspect
+ "#<#{self.class.name}:#{@disk_path}>"
+ end
+
+ def create_hooks
+ Gitlab::Git::Repository.create_hooks(path_to_repo, Gitlab.config.gitlab_shell.hooks_path)
end
def commit(ref = 'HEAD')
return nil unless exists?
+ return ref if ref.is_a?(::Commit)
- commit =
- if ref.is_a?(Gitlab::Git::Commit)
- ref
- else
- Gitlab::Git::Commit.find(raw_repository, ref)
- end
+ find_commit(ref)
+ end
- commit = ::Commit.new(commit, @project) if commit
- commit
- rescue Rugged::OdbError, Rugged::TreeError
- nil
+ # Finding a commit by the passed SHA
+ # Also takes care of caching, based on the SHA
+ def commit_by(oid:)
+ return @commit_cache[oid] if @commit_cache.key?(oid)
+
+ @commit_cache[oid] = find_commit(oid)
end
- def commits(ref, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil)
+ def commits_by(oids:)
+ return [] unless oids.present?
+
+ commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids)
+
+ if commits.present?
+ Commit.decorate(commits, @project)
+ else
+ []
+ end
+ end
+
+ def commits(ref = nil, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil, all: nil)
options = {
repo: raw_repository,
ref: ref,
@@ -109,12 +134,14 @@ class Repository
after: after,
before: before,
follow: Array(path).length == 1,
- skip_merges: skip_merges
+ skip_merges: skip_merges,
+ all: all
}
commits = Gitlab::Git::Commit.where(options)
commits = Commit.decorate(commits, @project) if commits.present?
- commits
+
+ CommitCollection.new(project, commits, ref)
end
def commits_between(from, to)
@@ -123,33 +150,27 @@ class Repository
commits
end
+ # Returns a list of commits that are not present in any reference
+ def new_commits(newrev)
+ refs = ::Gitlab::Git::RevList.new(raw, newrev: newrev).new_refs
+
+ refs.map { |sha| commit(sha.strip) }
+ end
+
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/384
def find_commits_by_message(query, ref = nil, path = nil, limit = 1000, offset = 0)
unless exists? && has_visible_content? && query.present?
return []
end
- ref ||= root_ref
-
- args = %W(
- #{Gitlab.config.git.bin_path} log #{ref} --pretty=%H --skip #{offset}
- --max-count #{limit} --grep=#{query} --regexp-ignore-case
- )
- args = args.concat(%W(-- #{path})) if path.present?
-
- git_log_results = Gitlab::Popen.popen(args, path_to_repo).first.lines
- git_log_results.map { |c| commit(c.chomp) }.compact
+ commits = raw_repository.find_commits_by_message(query, ref, path, limit, offset).map do |c|
+ commit(c)
+ end
+ CommitCollection.new(project, commits, ref)
end
def find_branch(name, fresh_repo: true)
- # Since the Repository object may have in-memory index changes, invalidating the memoized Repository object may
- # cause unintended side effects. Because finding a branch is a read-only operation, we can safely instantiate
- # a new repo here to ensure a consistent state to avoid a libgit2 bug where concurrent access (e.g. via git gc)
- # may cause the branch to "disappear" erroneously or have the wrong SHA.
- #
- # See: https://github.com/libgit2/libgit2/issues/1534 and https://gitlab.com/gitlab-org/gitlab-ce/issues/15392
- raw_repo = fresh_repo ? initialize_raw_repository : raw_repository
-
- raw_repo.find_branch(name)
+ raw_repository.find_branch(name, fresh_repo)
end
def find_tag(name)
@@ -157,32 +178,25 @@ class Repository
end
def add_branch(user, branch_name, ref)
- newrev = commit(ref).try(:sha)
-
- return false unless newrev
-
- GitOperationService.new(user, self).add_branch(branch_name, newrev)
+ branch = raw_repository.add_branch(branch_name, user: user, target: ref)
after_create_branch
- find_branch(branch_name)
+
+ branch
+ rescue Gitlab::Git::Repository::InvalidRef
+ false
end
def add_tag(user, tag_name, target, message = nil)
- newrev = commit(target).try(:id)
- options = { message: message, tagger: user_to_committer(user) } if message
-
- return false unless newrev
-
- GitOperationService.new(user, self).add_tag(tag_name, newrev, options)
-
- find_tag(tag_name)
+ raw_repository.add_tag(tag_name, user: user, target: target, message: message)
+ rescue Gitlab::Git::Repository::InvalidRef
+ false
end
def rm_branch(user, branch_name)
before_remove_branch
- branch = find_branch(branch_name)
- GitOperationService.new(user, self).rm_branch(branch)
+ raw_repository.rm_branch(branch_name, user: user)
after_remove_branch
true
@@ -190,9 +204,8 @@ class Repository
def rm_tag(user, tag_name)
before_remove_tag
- tag = find_tag(tag_name)
- GitOperationService.new(user, self).rm_tag(tag)
+ raw_repository.rm_tag(tag_name, user: user)
after_remove_tag
true
@@ -203,12 +216,20 @@ class Repository
end
def branch_exists?(branch_name)
+ return false unless raw_repository
+
branch_names.include?(branch_name)
end
+ def tag_exists?(tag_name)
+ return false unless raw_repository
+
+ tag_names.include?(tag_name)
+ end
+
def ref_exists?(ref)
- rugged.references.exist?(ref)
- rescue Rugged::ReferenceError
+ !!raw_repository&.ref_exists?(ref)
+ rescue ArgumentError
false
end
@@ -217,19 +238,14 @@ class Repository
# branches or tags, but we want to keep some of these commits around, for
# example if they have comments or CI builds.
def keep_around(sha)
- return unless sha && commit(sha)
+ return unless sha.present? && commit_by(oid: sha)
return if kept_around?(sha)
# This will still fail if the file is corrupted (e.g. 0 bytes)
- begin
- rugged.references.create(keep_around_ref_name(sha), sha, force: true)
- rescue Rugged::ReferenceError => ex
- Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
- rescue Rugged::OSError => ex
- raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/
- Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
- end
+ raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false)
+ rescue Gitlab::Git::CommandError => ex
+ Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
end
def kept_around?(sha)
@@ -237,15 +253,16 @@ class Repository
end
def diverging_commit_counts(branch)
- root_ref_hash = raw_repository.rev_parse_target(root_ref).oid
+ root_ref_hash = raw_repository.commit(root_ref).id
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
# Rugged seems to throw a `ReferenceError` when given branch_names rather
# than SHA-1 hashes
- number_commits_behind = raw_repository
- .count_commits_between(branch.dereferenced_target.sha, root_ref_hash)
-
- number_commits_ahead = raw_repository
- .count_commits_between(root_ref_hash, branch.dereferenced_target.sha)
+ number_commits_behind, number_commits_ahead =
+ raw_repository.count_commits_between(
+ root_ref_hash,
+ branch.dereferenced_target.sha,
+ left_right: true,
+ max_count: MAX_DIVERGING_COUNT)
{ behind: number_commits_behind, ahead: number_commits_ahead }
end
@@ -257,8 +274,9 @@ class Repository
end
def expire_branches_cache
- expire_method_caches(%i(branch_names branch_count))
+ expire_method_caches(%i(branch_names branch_count has_visible_content?))
@local_branches = nil
+ @branch_exists_memo = nil
end
def expire_statistics_caches
@@ -269,17 +287,6 @@ class Repository
expire_method_caches(CACHED_METHODS)
end
- # Expires the caches of a specific set of methods
- def expire_method_caches(methods)
- methods.each do |key|
- cache.expire(key)
-
- ivar = cache_instance_variable_name(key)
-
- remove_instance_variable(ivar) if instance_variable_defined?(ivar)
- end
- end
-
def expire_avatar_cache
expire_method_caches(%i(avatar))
end
@@ -299,7 +306,7 @@ class Repository
expire_method_caches(to_refresh)
- to_refresh.each { |method| send(method) }
+ to_refresh.each { |method| send(method) } # rubocop:disable GitlabSecurity/PublicSend
end
def expire_branch_cache(branch_name = nil)
@@ -327,7 +334,7 @@ class Repository
def expire_emptiness_caches
return unless empty?
- expire_method_caches(%i(empty?))
+ expire_method_caches(%i(has_visible_content?))
end
def lookup_cache
@@ -438,9 +445,9 @@ class Repository
def method_missing(m, *args, &block)
if m == :lookup && !block_given?
lookup_cache[m] ||= {}
- lookup_cache[m][args.join(":")] ||= raw_repository.send(m, *args, &block)
+ lookup_cache[m][args.join(":")] ||= raw_repository.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
else
- raw_repository.send(m, *args, &block)
+ raw_repository.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -449,34 +456,38 @@ class Repository
end
def blob_at(sha, path)
- unless Gitlab::Git.blank_ref?(sha)
- Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project)
- end
+ Blob.decorate(raw_repository.blob_at(sha, path), project)
rescue Gitlab::Git::Repository::NoRepository
nil
end
- def blob_by_oid(oid)
- Gitlab::Git::Blob.raw(self, oid)
+ # items is an Array like: [[oid, path], [oid1, path1]]
+ def blobs_at(items)
+ raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) }
end
def root_ref
- if raw_repository
- raw_repository.root_ref
- else
- # When the repo does not exist we raise this error so no data is cached.
- raise Rugged::ReferenceError
- end
+ # When the repo does not exist, or there is no root ref, we raise this error so no data is cached.
+ raw_repository&.root_ref or raise Gitlab::Git::Repository::NoRepository # rubocop:disable Style/AndOr
end
cache_method :root_ref
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/314
def exists?
- refs_directory_exists?
+ return false unless full_path
+
+ raw_repository.exists?
end
cache_method :exists?
- delegate :empty?, to: :raw_repository
- cache_method :empty?
+ # We don't need to cache the output of this method because both exists? and
+ # has_visible_content? are already memoized and cached. There's no guarantee
+ # that the values are expired and loaded atomically.
+ def empty?
+ return true unless exists?
+
+ !has_visible_content?
+ end
# The size of this repository in megabytes.
def size
@@ -492,11 +503,7 @@ class Repository
def commit_count_for_ref(ref)
return 0 unless exists?
- begin
- cache.fetch(:"commit_count_#{ref}") { raw_repository.commit_count(ref) }
- rescue Rugged::ReferenceError
- 0
- end
+ cache.fetch(:"commit_count_#{ref}") { raw_repository.commit_count(ref) }
end
delegate :branch_names, to: :raw_repository
@@ -505,17 +512,31 @@ class Repository
delegate :tag_names, to: :raw_repository
cache_method :tag_names, fallback: []
- delegate :branch_count, :tag_count, to: :raw_repository
+ delegate :branch_count, :tag_count, :has_visible_content?, to: :raw_repository
cache_method :branch_count, fallback: 0
cache_method :tag_count, fallback: 0
+ cache_method :has_visible_content?, fallback: false
def avatar
- if tree = file_on_head(:avatar)
- tree.path
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38327
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ if tree = file_on_head(:avatar)
+ tree.path
+ end
end
end
cache_method :avatar
+ def issue_template_names
+ Gitlab::Template::IssueTemplate.dropdown_names(project)
+ end
+ cache_method :issue_template_names, fallback: []
+
+ def merge_request_template_names
+ Gitlab::Template::MergeRequestTemplate.dropdown_names(project)
+ end
+ cache_method :merge_request_template_names, fallback: []
+
def readme
if readme = tree(:head)&.readme
ReadmeBlob.new(readme, self)
@@ -545,7 +566,7 @@ class Repository
def license_key
return unless exists?
- Licensee.license(path).try(:key)
+ raw_repository.license_short_name
end
cache_method :license_key
@@ -605,39 +626,23 @@ class Repository
end
end
- # Returns url for submodule
- #
- # Ex.
- # @repository.submodule_url_for('master', 'rack')
- # # => git@localhost:rack.git
- #
- def submodule_url_for(ref, path)
- if submodules(ref).any?
- submodule = submodules(ref)[path]
-
- if submodule
- submodule['url']
- end
- end
- end
-
def last_commit_for_path(sha, path)
- sha = last_commit_id_for_path(sha, path)
- commit(sha)
+ commit = raw_repository.last_commit_for_path(sha, path)
+ ::Commit.new(commit, @project) if commit
end
def last_commit_id_for_path(sha, path)
key = path.blank? ? "last_commit_id_for_path:#{sha}" : "last_commit_id_for_path:#{sha}:#{Digest::SHA1.hexdigest(path)}"
cache.fetch(key) do
- args = %W(#{Gitlab.config.git.bin_path} rev-list --max-count=1 #{sha} -- #{path})
- Gitlab::Popen.popen(args, path_to_repo).first.strip
+ last_commit_for_path(sha, path)&.id
end
end
def next_branch(name, opts = {})
branch_ids = self.branch_names.map do |n|
next 1 if n == name
+
result = n.match(/\A#{name}-([0-9]+)\z/)
result[1].to_i if result
end.compact
@@ -655,7 +660,9 @@ class Repository
def tags_sorted_by(value)
case value
- when 'name'
+ when 'name_asc'
+ VersionSorter.sort(tags) { |tag| tag.name }
+ when 'name_desc'
VersionSorter.rsort(tags) { |tag| tag.name }
when 'updated_desc'
tags_sorted_by_committed_date.reverse
@@ -666,10 +673,14 @@ class Repository
end
end
- def contributors
+ # Params:
+ #
+ # order_by: name|email|commits
+ # sort: asc|desc default: 'asc'
+ def contributors(order_by: nil, sort: 'asc')
commits = self.commits(nil, limit: 2000, offset: 0, skip_merges: true)
- commits.group_by(&:author_email).map do |email, commits|
+ commits = commits.group_by(&:author_email).map do |email, commits|
contributor = Gitlab::Contributor.new
contributor.email = email
@@ -683,31 +694,15 @@ class Repository
contributor
end
- end
-
- def refs_contains_sha(ref_type, sha)
- args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
- names = Gitlab::Popen.popen(args, path_to_repo).first
-
- if names.respond_to?(:split)
- names = names.split("\n").map(&:strip)
-
- names.each do |name|
- name.slice! '* '
- end
-
- names
- else
- []
- end
+ Commit.order_by(collection: commits, order_by: order_by, sort: sort)
end
def branch_names_contains(sha)
- refs_contains_sha('branch', sha)
+ raw_repository.branch_names_contains_sha(sha)
end
def tag_names_contains(sha)
- refs_contains_sha('tag', sha)
+ raw_repository.tag_names_contains_sha(sha)
end
def local_branches
@@ -721,309 +716,159 @@ class Repository
end
def create_dir(user, path, **options)
- options[:user] = user
options[:actions] = [{ action: :create_dir, file_path: path }]
- multi_action(**options)
+ multi_action(user, **options)
end
def create_file(user, path, content, **options)
- options[:user] = user
options[:actions] = [{ action: :create, file_path: path, content: content }]
- multi_action(**options)
+ multi_action(user, **options)
end
def update_file(user, path, content, **options)
previous_path = options.delete(:previous_path)
action = previous_path && previous_path != path ? :move : :update
- options[:user] = user
options[:actions] = [{ action: action, file_path: path, previous_path: previous_path, content: content }]
- multi_action(**options)
+ multi_action(user, **options)
end
def delete_file(user, path, **options)
- options[:user] = user
options[:actions] = [{ action: :delete, file_path: path }]
- multi_action(**options)
+ multi_action(user, **options)
end
- # rubocop:disable Metrics/ParameterLists
- def multi_action(
- user:, branch_name:, message:, actions:,
- author_email: nil, author_name: nil,
- start_branch_name: nil, start_project: project)
-
- GitOperationService.new(user, self).with_branch(
- branch_name,
- start_branch_name: start_branch_name,
- start_project: start_project) do |start_commit|
-
- index = Gitlab::Git::Index.new(raw_repository)
+ def with_cache_hooks
+ result = yield
- if start_commit
- index.read_tree(start_commit.raw_commit.tree)
- parents = [start_commit.sha]
- else
- parents = []
- end
-
- actions.each do |options|
- index.public_send(options.delete(:action), options)
- end
+ return unless result
- options = {
- tree: index.write_tree,
- message: message,
- parents: parents
- }
- options.merge!(get_committer_and_author(user, email: author_email, name: author_name))
+ after_create if result.repo_created?
+ after_create_branch if result.branch_created?
- create_commit(options)
- end
+ result.newrev
end
- # rubocop:enable Metrics/ParameterLists
- def get_committer_and_author(user, email: nil, name: nil)
- committer = user_to_committer(user)
- author = Gitlab::Git.committer_hash(email: email, name: name) || committer
+ def multi_action(user, **options)
+ start_project = options.delete(:start_project)
- {
- author: author,
- committer: committer
- }
- end
+ if start_project
+ options[:start_repository] = start_project.repository.raw_repository
+ end
- def user_to_committer(user)
- Gitlab::Git.committer_hash(email: user.email, name: user.name)
+ with_cache_hooks { raw.multi_action(user, **options) }
end
- def can_be_merged?(source_sha, target_branch)
- our_commit = rugged.branches[target_branch].target
- their_commit = rugged.lookup(source_sha)
-
- if our_commit && their_commit
- !rugged.merge_commits(our_commit, their_commit).conflicts?
- else
- false
+ def merge(user, source_sha, merge_request, message)
+ with_cache_hooks do
+ raw_repository.merge(user, source_sha, merge_request.target_branch, message) do |commit_id|
+ merge_request.update(in_progress_merge_commit_sha: commit_id)
+ nil # Return value does not matter.
+ end
end
end
- def merge(user, source, merge_request, options = {})
- GitOperationService.new(user, self).with_branch(
- merge_request.target_branch) do |start_commit|
- our_commit = start_commit.sha
- their_commit = source
+ def ff_merge(user, source, target_branch, merge_request: nil)
+ their_commit_id = commit(source)&.id
+ raise 'Invalid merge source' if their_commit_id.nil?
- raise 'Invalid merge target' unless our_commit
- raise 'Invalid merge source' unless their_commit
+ merge_request&.update(in_progress_merge_commit_sha: their_commit_id)
- merge_index = rugged.merge_commits(our_commit, their_commit)
- break if merge_index.conflicts?
-
- actual_options = options.merge(
- parents: [our_commit, their_commit],
- tree: merge_index.write_tree(rugged)
- )
-
- commit_id = create_commit(actual_options)
- merge_request.update(in_progress_merge_commit_sha: commit_id)
- commit_id
- end
- rescue Repository::CommitError # when merge_index.conflicts?
- false
+ with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) }
end
def revert(
- user, commit, branch_name,
+ user, commit, branch_name, message,
start_branch_name: nil, start_project: project)
- GitOperationService.new(user, self).with_branch(
- branch_name,
- start_branch_name: start_branch_name,
- start_project: start_project) do |start_commit|
-
- revert_tree_id = check_revert_content(commit, start_commit.sha)
- unless revert_tree_id
- raise Repository::CreateTreeError.new('Failed to revert commit')
- end
- committer = user_to_committer(user)
-
- create_commit(message: commit.revert_message(user),
- author: committer,
- committer: committer,
- tree: revert_tree_id,
- parents: [start_commit.sha])
+ with_cache_hooks do
+ raw_repository.revert(
+ user: user,
+ commit: commit.raw,
+ branch_name: branch_name,
+ message: message,
+ start_branch_name: start_branch_name,
+ start_repository: start_project.repository.raw_repository
+ )
end
end
def cherry_pick(
- user, commit, branch_name,
+ user, commit, branch_name, message,
start_branch_name: nil, start_project: project)
- GitOperationService.new(user, self).with_branch(
- branch_name,
- start_branch_name: start_branch_name,
- start_project: start_project) do |start_commit|
-
- cherry_pick_tree_id = check_cherry_pick_content(commit, start_commit.sha)
- unless cherry_pick_tree_id
- raise Repository::CreateTreeError.new('Failed to cherry-pick commit')
- end
- committer = user_to_committer(user)
-
- create_commit(message: commit.message,
- author: {
- email: commit.author_email,
- name: commit.author_name,
- time: commit.authored_date
- },
- committer: committer,
- tree: cherry_pick_tree_id,
- parents: [start_commit.sha])
+ with_cache_hooks do
+ raw_repository.cherry_pick(
+ user: user,
+ commit: commit.raw,
+ branch_name: branch_name,
+ message: message,
+ start_branch_name: start_branch_name,
+ start_repository: start_project.repository.raw_repository
+ )
end
end
- def resolve_conflicts(user, branch_name, params)
- GitOperationService.new(user, self).with_branch(branch_name) do
- committer = user_to_committer(user)
+ def merged_to_root_ref?(branch_or_name)
+ branch = Gitlab::Git::Branch.find(self, branch_or_name)
- create_commit(params.merge(author: committer, committer: committer))
+ if branch
+ same_head = branch.target == root_ref_sha
+ merged = ancestor?(branch.target, root_ref_sha)
+ !same_head && merged
+ else
+ nil
end
end
- def check_revert_content(target_commit, source_sha)
- args = [target_commit.sha, source_sha]
- args << { mainline: 1 } if target_commit.merge_commit?
-
- revert_index = rugged.revert_commit(*args)
- return false if revert_index.conflicts?
-
- tree_id = revert_index.write_tree(rugged)
- return false unless diff_exists?(source_sha, tree_id)
-
- tree_id
+ def root_ref_sha
+ @root_ref_sha ||= commit(root_ref).sha
end
- def check_cherry_pick_content(target_commit, source_sha)
- args = [target_commit.sha, source_sha]
- args << 1 if target_commit.merge_commit?
-
- cherry_pick_index = rugged.cherrypick_commit(*args)
- return false if cherry_pick_index.conflicts?
-
- tree_id = cherry_pick_index.write_tree(rugged)
- return false unless diff_exists?(source_sha, tree_id)
-
- tree_id
- end
-
- def diff_exists?(sha1, sha2)
- rugged.diff(sha1, sha2).size > 0
- end
-
- def merged_to_root_ref?(branch_name)
- branch_commit = commit(branch_name)
- root_ref_commit = commit(root_ref)
-
- if branch_commit
- same_head = branch_commit.id == root_ref_commit.id
- !same_head && is_ancestor?(branch_commit.id, root_ref_commit.id)
- else
- nil
- end
- end
+ delegate :merged_branch_names, :can_be_merged?, to: :raw_repository
def merge_base(first_commit_id, second_commit_id)
first_commit_id = commit(first_commit_id).try(:id) || first_commit_id
second_commit_id = commit(second_commit_id).try(:id) || second_commit_id
- rugged.merge_base(first_commit_id, second_commit_id)
- rescue Rugged::ReferenceError
- nil
+ raw_repository.merge_base(first_commit_id, second_commit_id)
end
- def is_ancestor?(ancestor_id, descendant_id)
+ def 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)
- else
- merge_base_commit(ancestor_id, descendant_id) == ancestor_id
- end
- end
- end
-
- def empty_repo?
- !exists? || !has_visible_content?
- end
-
- def search_files_by_content(query, ref)
- return [] if empty_repo? || query.blank?
-
- offset = 2
- args = %W(#{Gitlab.config.git.bin_path} grep -i -I -n --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref})
- Gitlab::Popen.popen(args, path_to_repo).first.scrub.split(/^--$/)
- end
-
- def search_files_by_name(query, ref)
- return [] if empty_repo? || query.blank?
- args = %W(#{Gitlab.config.git.bin_path} ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)})
- Gitlab::Popen.popen(args, path_to_repo).first.lines.map(&:strip)
+ raw_repository.ancestor?(ancestor_id, descendant_id)
end
- def with_repo_branch_commit(start_repository, start_branch_name)
- return yield(nil) if start_repository.empty_repo?
-
- branch_name_or_sha =
- if start_repository == self
- start_branch_name
- else
- tmp_ref = "refs/tmp/#{SecureRandom.hex}/head"
-
- fetch_ref(
- start_repository.path_to_repo,
- "#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}",
- tmp_ref
- )
-
- start_repository.commit(start_branch_name).sha
- end
-
- yield(commit(branch_name_or_sha))
+ def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true)
+ unless remote_name
+ remote_name = "tmp-#{SecureRandom.hex}"
+ tmp_remote_name = true
+ end
+ add_remote(remote_name, url, mirror_refmap: refmap)
+ fetch_remote(remote_name, forced: forced, prune: prune)
ensure
- rugged.references.delete(tmp_ref) if tmp_ref
+ remove_remote(remote_name) if tmp_remote_name
end
- def add_remote(name, url)
- raw_repository.remote_add(name, url)
- rescue Rugged::ConfigError
- raw_repository.remote_update(name, url: url)
+ def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true)
+ gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune)
end
- def remove_remote(name)
- raw_repository.remote_delete(name)
- true
- rescue Rugged::ConfigError
- false
- end
-
- def fetch_remote(remote, forced: false, no_tags: false)
- gitlab_shell.fetch_remote(repository_storage_path, path_with_namespace, remote, forced: forced, no_tags: no_tags)
+ def fetch_source_branch!(source_repository, source_branch, local_ref)
+ raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref)
end
- def fetch_ref(source_path, source_ref, target_ref)
- args = %W(#{Gitlab.config.git.bin_path} fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref})
- Gitlab::Popen.popen(args, path_to_repo)
+ def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:)
+ raw_repository.compare_source_branch(target_branch_name, source_repository.raw_repository, source_branch_name, straight: straight)
end
def create_ref(ref, ref_path)
- fetch_ref(path_to_repo, ref, ref_path)
+ raw_repository.write_ref(ref_path, ref)
end
def ls_files(ref)
@@ -1031,8 +876,16 @@ class Repository
raw_repository.ls_files(actual_ref)
end
- def gitattribute(path, name)
- raw_repository.attributes(path)[name]
+ def search_files_by_content(query, ref)
+ return [] if empty? || query.blank?
+
+ raw_repository.search_files_by_content(query, ref)
+ end
+
+ def search_files_by_name(query, ref)
+ return [] if empty?
+
+ raw_repository.search_files_by_name(query, ref)
end
def copy_gitattributes(ref)
@@ -1045,43 +898,6 @@ class Repository
end
end
- # Caches the supplied block both in a cache and in an instance variable.
- #
- # The cache key and instance variable are named the same way as the value of
- # the `key` argument.
- #
- # This method will return `nil` if the corresponding instance variable is also
- # set to `nil`. This ensures we don't keep yielding the block when it returns
- # `nil`.
- #
- # 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, memoize_only: false, &block)
- ivar = cache_instance_variable_name(key)
-
- if instance_variable_defined?(ivar)
- instance_variable_get(ivar)
- else
- begin
- 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.
- fallback
- end
- end
- end
-
- def cache_instance_variable_name(key)
- :"@#{key.to_s.tr('?!', '')}"
- end
-
def file_on_head(type)
if head = tree(:head)
head.blobs.find do |blob|
@@ -1094,12 +910,39 @@ class Repository
blob_data_at(sha, '.gitlab/route-map.yml')
end
- def gitlab_ci_yml_for(sha)
- blob_data_at(sha, '.gitlab-ci.yml')
+ def gitlab_ci_yml_for(sha, path = '.gitlab-ci.yml')
+ blob_data_at(sha, path)
+ end
+
+ def fetch_ref(source_repository, source_ref:, target_ref:)
+ raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
+ end
+
+ def repository_storage_path
+ @project.repository_storage_path
+ end
+
+ def rebase(user, merge_request)
+ raw.rebase(user, merge_request.id, branch: merge_request.source_branch,
+ branch_sha: merge_request.source_branch_sha,
+ remote_repository: merge_request.target_project.repository.raw,
+ remote_branch: merge_request.target_branch)
end
private
+ # TODO Generice finder, later split this on finders by Ref or Oid
+ # gitlab-org/gitlab-ce#39239
+ def find_commit(oid_or_ref)
+ commit = if oid_or_ref.is_a?(Gitlab::Git::Commit)
+ oid_or_ref
+ else
+ Gitlab::Git::Commit.find(raw_repository, oid_or_ref)
+ end
+
+ ::Commit.new(commit, @project) if commit
+ end
+
def blob_data_at(sha, path)
blob = blob_at(sha, path)
return unless blob
@@ -1108,14 +951,8 @@ class Repository
blob.data
end
- def refs_directory_exists?
- return false unless path_with_namespace
-
- File.exist?(File.join(path_to_repo, 'refs'))
- end
-
def cache
- @cache ||= RepositoryCache.new(path_with_namespace, @project.id)
+ @cache ||= Gitlab::RepositoryCache.new(self)
end
def tags_sorted_by_committed_date
@@ -1134,24 +971,14 @@ class Repository
end
def keep_around_ref_name(sha)
- "refs/keep-around/#{sha}"
+ "refs/#{REF_KEEP_AROUND}/#{sha}"
end
def repository_event(event, tags = {})
- Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags))
- end
-
- def create_commit(params = {})
- params[:message].delete!("\r")
-
- Rugged::Commit.create(rugged, params)
- end
-
- def repository_storage_path
- @project.repository_storage_path
+ Gitlab::Metrics.add_event(event, { path: full_path }.merge(tags))
end
def initialize_raw_repository
- Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git')
+ Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki))
end
end
diff --git a/app/models/route.rb b/app/models/route.rb
index 97e8a6ad9e9..07d96c21cf1 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,4 +1,6 @@
class Route < ActiveRecord::Base
+ include CaseSensitivity
+
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
@@ -8,6 +10,9 @@ class Route < ActiveRecord::Base
presence: true,
uniqueness: { case_sensitive: false }
+ validate :ensure_permanent_paths, if: :path_changed?
+
+ before_validation :delete_conflicting_orphaned_routes
after_create :delete_conflicting_redirects
after_update :delete_conflicting_redirects, if: :path_changed?
after_update :create_redirect_for_old_path
@@ -40,7 +45,7 @@ class Route < ActiveRecord::Base
# We are not calling route.delete_conflicting_redirects here, in hopes
# of avoiding deadlocks. The parent (self, in this method) already
# called it, which deletes conflicts for all descendants.
- route.create_redirect(old_path) if attributes[:path]
+ route.create_redirect(old_path, permanent: permanent_redirect?) if attributes[:path]
end
end
end
@@ -50,16 +55,39 @@ class Route < ActiveRecord::Base
end
def conflicting_redirects
- RedirectRoute.matching_path_and_descendants(path)
+ RedirectRoute.temporary.matching_path_and_descendants(path)
end
- def create_redirect(path)
- RedirectRoute.create(source: source, path: path)
+ def create_redirect(path, permanent: false)
+ RedirectRoute.create(source: source, path: path, permanent: permanent)
end
private
def create_redirect_for_old_path
- create_redirect(path_was) if path_changed?
+ create_redirect(path_was, permanent: permanent_redirect?) if path_changed?
+ end
+
+ def permanent_redirect?
+ source_type != "Project"
+ end
+
+ def ensure_permanent_paths
+ return if path.nil?
+
+ errors.add(:path, "has been taken before") if conflicting_redirect_exists?
+ end
+
+ def conflicting_redirect_exists?
+ RedirectRoute.permanent.matching_path_and_descendants(path).exists?
+ end
+
+ def delete_conflicting_orphaned_routes
+ conflicting = self.class.iwhere(path: path)
+ conflicting_orphaned_routes = conflicting.select do |route|
+ route.source.nil?
+ end
+
+ conflicting_orphaned_routes.each(&:destroy)
end
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index edde7bedbab..6e311806be1 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -1,5 +1,5 @@
class SentNotification < ActiveRecord::Base
- serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
+ serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :project
belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -53,13 +53,17 @@ class SentNotification < ActiveRecord::Base
end
def unsubscribable?
- !for_commit?
+ !(for_commit? || for_snippet?)
end
def for_commit?
noteable_type == "Commit"
end
+ def for_snippet?
+ noteable_type.end_with?('Snippet')
+ end
+
def noteable
if for_commit?
project.commit(commit_id) rescue nil
diff --git a/app/models/service.rb b/app/models/service.rb
index 6a0b0a5c522..1dcb79157a2 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,7 +2,9 @@
# and implement a set of methods
class Service < ActiveRecord::Base
include Sortable
- serialize :properties, JSON # rubocop:disable Cop/ActiverecordSerialize
+ include Importable
+
+ serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
default_value_for :active, false
default_value_for :push_events, true
@@ -44,6 +46,7 @@ class Service < ActiveRecord::Base
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 }
+ scope :deployment, -> { where(category: 'deployment') }
default_value_for :category, 'common'
@@ -51,6 +54,14 @@ class Service < ActiveRecord::Base
active
end
+ def show_active_box?
+ true
+ end
+
+ def editable?
+ true
+ end
+
def template?
template
end
@@ -109,10 +120,26 @@ class Service < ActiveRecord::Base
nil
end
+ def api_field_names
+ fields.map { |field| field[:name] }
+ .reject { |field_name| field_name =~ /(password|token|key)/ }
+ end
+
def global_fields
fields
end
+ def configurable_events
+ events = self.class.supported_events
+
+ # No need to disable individual triggers when there is only one
+ if events.count == 1
+ []
+ else
+ events
+ end
+ end
+
def supported_events
self.class.supported_events
end
@@ -135,11 +162,6 @@ class Service < ActiveRecord::Base
true
end
- # reason why service cannot be tested
- def disabled_title
- "Please setup a project repository."
- end
-
# Provide convenient accessor methods
# for each serialized property.
# Also keep track of updated properties in a similar way as ActiveModel::Dirty
@@ -203,7 +225,7 @@ class Service < ActiveRecord::Base
def async_execute(data)
return unless supported_events.include?(data[:object_kind])
- Sidekiq::Client.enqueue(ProjectServiceWorker, id, data)
+ ProjectServiceWorker.perform_async(id, data)
end
def issue_tracker?
@@ -230,6 +252,7 @@ class Service < ActiveRecord::Base
kubernetes
mattermost_slash_commands
mattermost
+ packagist
pipelines_email
pivotaltracker
prometheus
@@ -240,6 +263,7 @@ class Service < ActiveRecord::Base
teamcity
microsoft_teams
]
+
if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring]
end
@@ -254,6 +278,18 @@ class Service < ActiveRecord::Base
service
end
+ def deprecated?
+ false
+ end
+
+ def deprecation_message
+ nil
+ end
+
+ def self.find_by_template
+ find_by(template: true)
+ end
+
private
def cache_project_has_external_issue_tracker
@@ -267,4 +303,31 @@ class Service < ActiveRecord::Base
project.cache_has_external_wiki
end
end
+
+ def self.event_description(event)
+ case event
+ when "push", "push_events"
+ "Event will be triggered by a push to the repository"
+ when "tag_push", "tag_push_events"
+ "Event will be triggered when a new tag is pushed to the repository"
+ when "note", "note_events"
+ "Event will be triggered when someone adds a comment"
+ when "issue", "issue_events"
+ "Event will be triggered when an issue is created/updated/closed"
+ when "confidential_issue", "confidential_issue_events"
+ "Event will be triggered when a confidential issue is created/updated/closed"
+ when "merge_request", "merge_request_events"
+ "Event will be triggered when a merge request is created/updated/merged"
+ when "pipeline", "pipeline_events"
+ "Event will be triggered when a pipeline status changes"
+ when "wiki_page", "wiki_page_events"
+ "Event will be triggered when a wiki page is created/updated"
+ when "commit", "commit_events"
+ "Event will be triggered when a commit is created/updated"
+ end
+ end
+
+ def valid_recipients?
+ activated? && !importing?
+ end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 54014df43b0..644120453cf 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -9,6 +9,7 @@ class Snippet < ActiveRecord::Base
include Mentionable
include Spammable
include Editable
+ include Gitlab::SQL::Pattern
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -25,21 +26,19 @@ class Snippet < ActiveRecord::Base
default_content_html_invalidator || file_name_changed?
end
- default_value_for(:visibility_level) { current_application_settings.default_snippet_visibility }
+ default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_snippet_visibility }
belongs_to :author, class_name: 'User'
belongs_to :project
- has_many :notes, as: :noteable, dependent: :destroy
+ has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :author, presence: true
validates :title, presence: true, length: { maximum: 255 }
validates :file_name,
- length: { maximum: 255 },
- format: { with: Gitlab::Regex.file_name_regex,
- message: Gitlab::Regex.file_name_regex_message }
+ length: { maximum: 255 }
validates :content, presence: true
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
@@ -75,11 +74,32 @@ class Snippet < ActiveRecord::Base
@link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
end
- def to_reference(from_project = nil, full: false)
+ # Returns a collection of snippets that are either public or visible to the
+ # logged in user.
+ #
+ # This method does not verify the user actually has the access to the project
+ # the snippet is in, so it should be only used on a relation that's already scoped
+ # for project access
+ def self.public_or_visible_to_user(user = nil)
+ if user
+ authorized = user
+ .project_authorizations
+ .select(1)
+ .where('project_authorizations.project_id = snippets.project_id')
+
+ levels = Gitlab::VisibilityLevel.levels_for_user(user)
+
+ where('EXISTS (?) OR snippets.visibility_level IN (?) or snippets.author_id = (?)', authorized, levels, user.id)
+ else
+ public_to_user
+ end
+ end
+
+ def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}"
if project.present?
- "#{project.to_reference(from_project, full: full)}#{reference}"
+ "#{project.to_reference(from, full: full)}#{reference}"
else
reference
end
@@ -135,10 +155,7 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:title].matches(pattern).or(t[:file_name].matches(pattern)))
+ fuzzy_search(query, [:title, :file_name])
end
# Searches for snippets with matching content.
@@ -149,10 +166,11 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search_code(query)
- table = Snippet.arel_table
- pattern = "%#{query}%"
+ fuzzy_search(query, [:content])
+ end
- where(table[:content].matches(pattern))
+ def parent_class
+ ::Project
end
end
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb
new file mode 100644
index 00000000000..fae1b64961a
--- /dev/null
+++ b/app/models/storage/hashed_project.rb
@@ -0,0 +1,42 @@
+module Storage
+ class HashedProject
+ attr_accessor :project
+ delegate :gitlab_shell, :repository_storage_path, to: :project
+
+ ROOT_PATH_PREFIX = '@hashed'.freeze
+
+ def initialize(project)
+ @project = project
+ end
+
+ # Base directory
+ #
+ # @return [String] directory where repository is stored
+ def base_dir
+ "#{ROOT_PATH_PREFIX}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash
+ end
+
+ # Disk path is used to build repository and project's wiki path on disk
+ #
+ # @return [String] combination of base_dir and the repository own name without `.git` or `.wiki.git` extensions
+ def disk_path
+ "#{base_dir}/#{disk_hash}" if disk_hash
+ end
+
+ def ensure_storage_path_exists
+ gitlab_shell.add_namespace(repository_storage_path, base_dir)
+ end
+
+ def rename_repo
+ true
+ end
+
+ private
+
+ # Generates the hash for the project path and name on disk
+ # If you need to refer to the repository on disk, use the `#disk_path`
+ def disk_hash
+ @disk_hash ||= Digest::SHA2.hexdigest(project.id.to_s) if project.id
+ end
+ end
+end
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
new file mode 100644
index 00000000000..9d9e5e1d352
--- /dev/null
+++ b/app/models/storage/legacy_project.rb
@@ -0,0 +1,51 @@
+module Storage
+ class LegacyProject
+ attr_accessor :project
+ delegate :namespace, :gitlab_shell, :repository_storage_path, to: :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ # Base directory
+ #
+ # @return [String] directory where repository is stored
+ def base_dir
+ namespace.full_path
+ end
+
+ # Disk path is used to build repository and project's wiki path on disk
+ #
+ # @return [String] combination of base_dir and the repository own name without `.git` or `.wiki.git` extensions
+ def disk_path
+ project.full_path
+ end
+
+ def ensure_storage_path_exists
+ return unless namespace
+
+ gitlab_shell.add_namespace(repository_storage_path, base_dir)
+ end
+
+ def rename_repo
+ new_full_path = project.build_full_path
+
+ if gitlab_shell.mv_repository(repository_storage_path, project.full_path_was, new_full_path)
+ # If repository moved successfully we need to send update instructions to users.
+ # However we cannot allow rollback since we moved repository
+ # So we basically we mute exceptions in next actions
+ begin
+ gitlab_shell.mv_repository(repository_storage_path, "#{project.full_path_was}.wiki", "#{new_full_path}.wiki")
+ return true
+ rescue => e
+ Rails.logger.error "Exception renaming #{project.full_path_was} -> #{new_full_path}: #{e}"
+ # Returning false does not rollback after_* transaction but gives
+ # us information about failing some of tasks
+ return false
+ end
+ end
+
+ false
+ end
+ end
+end
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 414c95f7705..29035480371 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,7 +1,18 @@
class SystemNoteMetadata < ActiveRecord::Base
+ # These notes's action text might contain a reference that is external.
+ # We should always force a deep validation upon references that are found
+ # in this note type.
+ # Other notes can always be safely shown as all its references are
+ # in the same project (i.e. with the same permissions)
+ TYPES_WITH_CROSS_REFERENCES = %w[
+ commit cross_reference
+ close duplicate
+ ].freeze
+
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
- title time_tracking branch milestone discussion task moved opened closed merged
+ title time_tracking branch milestone discussion task moved
+ opened closed merged duplicate locked unlocked
outdated
].freeze
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 7af54b2beb2..8afacd188e0 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -28,11 +28,10 @@ class Todo < ActiveRecord::Base
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :action, :project, :target_type, :user, presence: true
+ validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
- default_scope { reorder(id: :desc) }
-
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
@@ -52,10 +51,14 @@ class Todo < ActiveRecord::Base
# milestones, but still show something if the user has a URL with that
# selected.
def sort(method)
- case method.to_s
- when 'priority', 'label_priority' then order_by_labels_priority
- else order_by(method)
- end
+ sorted =
+ case method.to_s
+ when 'priority', 'label_priority' then order_by_labels_priority
+ else order_by(method)
+ end
+
+ # Break ties with the ID column for pagination
+ sorted.order(id: :desc)
end
# Order by priority depending on which issue/merge request the Todo belongs to
diff --git a/app/models/tree.rb b/app/models/tree.rb
index c89b8eca9be..4c1856b67a8 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -9,10 +9,9 @@ class Tree
@repository = repository
@sha = sha
@path = path
- @recursive = recursive
git_repo = @repository.raw_repository
- @entries = get_entries(git_repo, @sha, @path, recursive: @recursive)
+ @entries = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive)
end
def readme
@@ -58,21 +57,4 @@ class Tree
def sorted_entries
trees + blobs + submodules
end
-
- private
-
- def get_entries(git_repo, sha, path, recursive: false)
- current_path_entries = Gitlab::Git::Tree.where(git_repo, sha, path)
- ordered_entries = []
-
- current_path_entries.each do |entry|
- ordered_entries << entry
-
- if recursive && entry.dir?
- ordered_entries.concat(get_entries(git_repo, sha, entry.path, recursive: true))
- end
- end
-
- ordered_entries
- end
end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index f194d7bdb80..99ad37dc892 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -9,22 +9,15 @@ class Upload < ActiveRecord::Base
validates :model, presence: true
validates :uploader, presence: true
- before_save :calculate_checksum, if: :foreground_checksum?
- after_commit :schedule_checksum, unless: :foreground_checksum?
+ before_save :calculate_checksum!, if: :foreground_checksummable?
+ after_commit :schedule_checksum, if: :checksummable?
- def self.remove_path(path)
- where(path: path).destroy_all
- end
-
- def self.record(uploader)
- remove_path(uploader.relative_path)
+ # as the FileUploader is not mounted, the default CarrierWave ActiveRecord
+ # hooks are not executed and the file will not be deleted
+ after_destroy :delete_file!, if: -> { uploader_class <= FileUploader }
- create(
- size: uploader.file.size,
- path: uploader.relative_path,
- model: uploader.model,
- uploader: uploader.class.to_s
- )
+ def self.hexdigest(path)
+ Digest::SHA256.file(path).hexdigest
end
def absolute_path
@@ -33,20 +26,47 @@ class Upload < ActiveRecord::Base
uploader_class.absolute_path(self)
end
- def calculate_checksum
- return unless exist?
+ def calculate_checksum!
+ self.checksum = nil
+ return unless checksummable?
+
+ self.checksum = self.class.hexdigest(absolute_path)
+ end
- self.checksum = Digest::SHA256.file(absolute_path).hexdigest
+ def build_uploader
+ uploader_class.new(model, mount_point, **uploader_context).tap do |uploader|
+ uploader.upload = self
+ uploader.retrieve_from_store!(identifier)
+ end
end
def exist?
File.exist?(absolute_path)
end
+ def uploader_context
+ {
+ identifier: identifier,
+ secret: secret
+ }.compact
+ end
+
private
- def foreground_checksum?
- size <= CHECKSUM_THRESHOLD
+ def delete_file!
+ build_uploader.remove!
+ end
+
+ def checksummable?
+ checksum.nil? && local? && exist?
+ end
+
+ def local?
+ true
+ end
+
+ def foreground_checksummable?
+ checksummable? && size <= CHECKSUM_THRESHOLD
end
def schedule_checksum
@@ -60,4 +80,12 @@ class Upload < ActiveRecord::Base
def uploader_class
Object.const_get(uploader)
end
+
+ def identifier
+ File.basename(path)
+ end
+
+ def mount_point
+ super&.to_sym
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 6dd1b1415d6..b8c55205ab8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -4,24 +4,31 @@ class User < ActiveRecord::Base
extend Gitlab::ConfigHelper
include Gitlab::ConfigHelper
- include Gitlab::CurrentSettings
+ include Gitlab::SQL::Pattern
+ include AfterCommitQueue
include Avatarable
include Referable
include Sortable
include CaseSensitivity
include TokenAuthenticatable
include IgnorableColumn
+ include FeatureGate
+ include CreatedAtFilterable
+ include IgnorableColumn
+ include BulkMemberAccessLoad
+ include BlocksJsonSerialization
DEFAULT_NOTIFICATION_LEVEL = :participating
- ignore_column :authorized_projects_populated
+ ignore_column :external_email
+ ignore_column :email_provider
+ ignore_column :authentication_token
- 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 }
+ default_value_for(:external) { Gitlab::CurrentSettings.user_default_external }
default_value_for :can_create_group, gitlab_config.default_can_create_group
default_value_for :can_create_team, false
default_value_for :hide_no_ssh_key, false
@@ -29,6 +36,7 @@ class User < ActiveRecord::Base
default_value_for :project_view, :files
default_value_for :notified_of_own_activity, false
default_value_for :preferred_language, I18n.default_locale
+ default_value_for :theme_id, gitlab_config.default_theme
attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base,
@@ -40,20 +48,25 @@ 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 # rubocop:disable Cop/ActiverecordSerialize
+ serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
- :validatable, :omniauthable, :confirmable, :registerable
+ :validatable, :omniauthable, :confirmable, :registerable
+
+ BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
+ "administrator if you think this is an error.".freeze
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
def update_tracked_fields!(request)
+ return if Gitlab::Database.read_only?
+
update_tracked_fields(request)
lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i)
return unless lease.try_obtain
- Users::UpdateService.new(self).execute(validate: false)
+ Users::UpdateService.new(self, user: self).execute(validate: false)
end
attr_accessor :force_random_password
@@ -66,24 +79,26 @@ class User < ActiveRecord::Base
#
# Namespace for personal projects
- has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id, autosave: true
+ has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent
# Profile
has_many :keys, -> do
type = Key.arel_table[:type]
where(type.not_eq('DeployKey').or(type.eq(nil)))
- end, dependent: :destroy
- has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy
+ end, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :gpg_keys
- has_many :emails, dependent: :destroy
- has_many :personal_access_tokens, dependent: :destroy
- has_many :identities, dependent: :destroy, autosave: true
- has_many :u2f_registrations, dependent: :destroy
- has_many :chat_names, dependent: :destroy
+ has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
+ has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :user_synced_attributes_metadata, autosave: true
# Groups
- has_many :members, dependent: :destroy
- has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, source: 'GroupMember'
+ has_many :members
+ has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember'
has_many :groups, through: :group_members
has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group
has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group
@@ -91,35 +106,41 @@ class User < ActiveRecord::Base
# Projects
has_many :groups_projects, through: :groups, source: :projects
has_many :personal_projects, through: :namespace, source: :projects
- has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy
+ has_many :project_members, -> { where(requested_at: nil) }
has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
- has_many :users_star_projects, dependent: :destroy
+ has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, through: :users_star_projects, source: :project
has_many :project_authorizations
has_many :authorized_projects, through: :project_authorizations, source: :project
- 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
- has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event"
- has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
- has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
- has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
- has_many :spam_logs, dependent: :destroy
- has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
- has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
- has_many :todos, dependent: :destroy
- has_many :notification_settings, dependent: :destroy
- has_many :award_emoji, dependent: :destroy
- has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
+ has_many :user_interacted_projects
+ has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project'
+
+ has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :events, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
+ has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :todos
+ has_many :notification_settings, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
- has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
+ has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :custom_attributes, class_name: 'UserCustomAttribute'
+ has_many :callouts, class_name: 'UserCallout'
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
#
# Validations
@@ -134,29 +155,32 @@ class User < ActiveRecord::Base
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
- validates :username,
- dynamic_path: true,
- presence: true,
- uniqueness: { case_sensitive: false }
+ validates :username, presence: true
+
+ validates :namespace, presence: true
+ validate :namespace_move_dir_allowed, if: :username_changed?
- validate :namespace_uniq, if: :username_changed?
- validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :unique_email, if: :email_changed?
validate :owns_notification_email, if: :notification_email_changed?
validate :owns_public_email, if: :public_email_changed?
validate :signup_domain_valid?, on: :create, if: ->(user) { !user.created_by_id }
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
before_validation :sanitize_attrs
before_validation :set_notification_email, if: :email_changed?
before_validation :set_public_email, if: :public_email_changed?
+ before_save :ensure_incoming_email_token
+ before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? }
+ before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) }
+ before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? }
+ before_validation :ensure_namespace_correct
+ after_validation :set_username_errors
+ after_update :username_changed_hook, if: :username_changed?
+ after_destroy :post_destroy_hook
+ after_destroy :remove_key_cache
+ after_commit :update_emails_with_primary_email, on: :update, if: -> { previous_changes.key?('email') }
+ after_commit :update_invalid_gpg_signatures, on: :update, if: -> { previous_changes.key?('email') }
- after_update :update_emails_with_primary_email, if: :email_changed?
- before_save :ensure_authentication_token, :ensure_incoming_email_token
- before_save :ensure_user_rights_and_limits, if: :external_changed?
- after_save :ensure_namespace_correct
after_initialize :set_projects_limit
- after_destroy :post_destroy_hook
# User's Layout preference
enum layout: [:fixed, :fluid]
@@ -166,15 +190,8 @@ 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 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
+ # Note: When adding an option, it MUST go on the end of the array.
+ enum project_view: [:readme, :activity, :files]
delegate :path, to: :namespace, allow_nil: true, prefix: true
@@ -203,15 +220,11 @@ class User < ActiveRecord::Base
end
def inactive_message
- "Your account has been blocked. Please contact your GitLab " \
- "administrator if you think this is an error."
+ BLOCKED_MESSAGE
end
end
end
- mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy
-
# Scopes
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
@@ -219,8 +232,8 @@ class User < ActiveRecord::Base
scope :active, -> { with_state(:active).non_internal }
scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') }
scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) }
- scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'DESC')) }
- scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'ASC')) }
+ scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
+ scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
def self.with_two_factor
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id")
@@ -240,33 +253,39 @@ class User < ActiveRecord::Base
def find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
if login = conditions.delete(:login)
- where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase)
+ where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip)
else
find_by(conditions)
end
end
def sort(method)
- case method.to_s
+ order_method = method || 'id_desc'
+
+ case order_method.to_s
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
else
- order_by(method)
+ order_by(order_method)
end
end
+ def for_github_id(id)
+ joins(:identities).merge(Identity.with_extern_uid(:github, id))
+ end
+
# Find a User by their primary email or any associated secondary email
def find_by_any_email(email)
- sql = 'SELECT *
- FROM users
- WHERE id IN (
- SELECT id FROM users WHERE email = :email
- UNION
- SELECT emails.user_id FROM emails WHERE email = :email
- )
- LIMIT 1;'
+ by_any_email(email).take
+ end
- User.find_by_sql([sql, { email: email }]).first
+ # Returns a relation containing all the users for the given Email address
+ def by_any_email(email)
+ users = where(email: email)
+ emails = joins(:emails).where(emails: { email: email })
+ union = Gitlab::SQL::Union.new([users, emails])
+
+ from("(#{union.to_sql}) #{table_name}")
end
def filter(filter_name)
@@ -296,14 +315,24 @@ class User < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- table = arel_table
- pattern = "%#{query}%"
+ return none if query.blank?
+
+ query = query.downcase
+
+ order = <<~SQL
+ CASE
+ WHEN users.name = %{query} THEN 0
+ WHEN users.username = %{query} THEN 1
+ WHEN users.email = %{query} THEN 2
+ ELSE 3
+ END
+ SQL
where(
- table[:name].matches(pattern)
- .or(table[:email].matches(pattern))
- .or(table[:username].matches(pattern))
- )
+ fuzzy_arel_match(:name, query, lower_exact_match: true)
+ .or(fuzzy_arel_match(:username, query, lower_exact_match: true))
+ .or(arel_table[:email].eq(query))
+ ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
end
# searches user by given pattern
@@ -311,16 +340,20 @@ class User < ActiveRecord::Base
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
def search_with_secondary_emails(query)
- table = arel_table
+ return none if query.blank?
+
+ query = query.downcase
+
email_table = Email.arel_table
- pattern = "%#{query}%"
- matched_by_emails_user_ids = email_table.project(email_table[:user_id]).where(email_table[:email].matches(pattern))
+ matched_by_emails_user_ids = email_table
+ .project(email_table[:user_id])
+ .where(email_table[:email].eq(query))
where(
- table[:name].matches(pattern)
- .or(table[:email].matches(pattern))
- .or(table[:username].matches(pattern))
- .or(table[:id].in(matched_by_emails_user_ids))
+ fuzzy_arel_match(:name, query)
+ .or(fuzzy_arel_match(:username, query))
+ .or(arel_table[:email].eq(query))
+ .or(arel_table[:id].in(matched_by_emails_user_ids))
)
end
@@ -350,7 +383,7 @@ class User < ActiveRecord::Base
# Returns a user for the given SSH key.
def find_by_ssh_key_id(key_id)
- find_by(id: Key.unscoped.select(:user_id).where(id: key_id))
+ Key.find_by(id: key_id)&.user
end
def find_by_full_path(path, follow_redirects: false)
@@ -374,9 +407,11 @@ class User < ActiveRecord::Base
# Return (create if necessary) the ghost user. The ghost user
# owns records previously belonging to deleted users.
def ghost
- unique_internal(where(ghost: true), 'ghost', 'ghost%s@example.com') do |u|
+ email = 'ghost%s@example.com'
+ unique_internal(where(ghost: true), 'ghost', email) do |u|
u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.'
u.name = 'Ghost User'
+ u.notification_email = email
end
end
end
@@ -398,7 +433,7 @@ class User < ActiveRecord::Base
end
def self.non_internal
- where(Hash[internal_attributes.zip([[false, nil]] * internal_attributes.size)])
+ where(internal_attributes.map { |attr| "#{attr} IS NOT TRUE" }.join(" AND "))
end
#
@@ -409,7 +444,7 @@ class User < ActiveRecord::Base
username
end
- def to_reference(_from_project = nil, target_project: nil, full: nil)
+ def to_reference(_from = nil, target_project: nil, full: nil)
"#{self.class.reference_prefix}#{username}"
end
@@ -417,6 +452,10 @@ class User < ActiveRecord::Base
skip_confirmation! if bool
end
+ def skip_reconfirmation=(bool)
+ skip_reconfirmation! if bool
+ end
+
def generate_reset_token
@reset_token, enc = Devise.token_generator.generate(self.class, :reset_password_token)
@@ -430,6 +469,14 @@ class User < ActiveRecord::Base
reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago
end
+ def remember_me!
+ super if ::Gitlab::Database.read_write?
+ end
+
+ def forget_me!
+ super if ::Gitlab::Database.read_write?
+ end
+
def disable_two_factor!
transaction do
update_attributes(
@@ -453,23 +500,16 @@ class User < ActiveRecord::Base
end
def two_factor_u2f_enabled?
- u2f_registrations.exists?
- end
-
- def namespace_uniq
- # Return early if username already failed the first uniqueness validation
- return if errors.key?(:username) &&
- errors[:username].include?('has already been taken')
-
- existing_namespace = Namespace.by_path(username)
- if existing_namespace && existing_namespace != namespace
- errors.add(:username, 'has already been taken')
+ if u2f_registrations.loaded?
+ u2f_registrations.any?
+ else
+ u2f_registrations.exists?
end
end
- def avatar_type
- unless avatar.image?
- errors.add :avatar, "only images allowed"
+ def namespace_move_dir_allowed
+ if namespace&.any_project_has_container_registry_tags?
+ errors.add(:username, 'cannot be changed if a personal project has container registry tags.')
end
end
@@ -491,20 +531,41 @@ class User < ActiveRecord::Base
errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email)
end
+ # see if the new email is already a verified secondary email
+ def check_for_verified_email
+ skip_reconfirmation! if emails.confirmed.where(email: self.email).any?
+ end
+
+ # Note: the use of the Emails services will cause `saves` on the user object, running
+ # through the callbacks again and can have side effects, such as the `previous_changes`
+ # hash and `_was` variables getting munged.
+ # By using an `after_commit` instead of `after_update`, we avoid the recursive callback
+ # scenario, though it then requires us to use the `previous_changes` hash
def update_emails_with_primary_email
+ previous_email = previous_changes[:email][0] # grab this before the DestroyService is called
primary_email_record = emails.find_by(email: email)
- if primary_email_record
- Emails::DestroyService.new(self, email: email).execute
- Emails::CreateService.new(self, email: email_was).execute
- end
+ Emails::DestroyService.new(self, user: self).execute(primary_email_record) if primary_email_record
+
+ # the original primary email was confirmed, and we want that to carry over. We don't
+ # have access to the original confirmation values at this point, so just set confirmed_at
+ Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: confirmed_at)
+ end
+
+ def update_invalid_gpg_signatures
+ gpg_keys.each(&:update_invalid_gpg_signatures)
end
- # Returns the groups a user has access to
+ # Returns the groups a user has access to, either through a membership or a project authorization
def authorized_groups
union = Gitlab::SQL::Union
.new([groups.select(:id), authorized_projects.select(:namespace_id)])
- Group.where("namespaces.id IN (#{union.to_sql})")
+ Group.where("namespaces.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
+ end
+
+ # Returns the groups a user is a member of, either directly or through a parent group
+ def membership_groups
+ Gitlab::GroupHierarchy.new(groups).base_and_descendants
end
# Returns a relation of groups the user has access to, including their parent
@@ -542,6 +603,15 @@ class User < ActiveRecord::Base
authorized_projects(min_access_level).exists?({ id: project.id })
end
+ # Typically used in conjunction with projects table to get projects
+ # a user has been given access to.
+ #
+ # Example use:
+ # `Project.where('EXISTS(?)', user.authorizations_for_projects)`
+ def authorizations_for_projects
+ project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+ end
+
# Returns the projects this user has reporter (or greater) access to, limited
# to at most the given projects.
#
@@ -566,19 +636,41 @@ class User < ActiveRecord::Base
end
def require_ssh_key?
- keys.count == 0 && Gitlab::ProtocolAccess.allowed?('ssh')
+ count = Users::KeysCountService.new(self).count
+
+ count.zero? && Gitlab::ProtocolAccess.allowed?('ssh')
end
- def require_password?
- password_automatically_set? && !ldap_user? && current_application_settings.signin_enabled?
+ def require_password_creation_for_web?
+ allow_password_authentication_for_web? && password_automatically_set?
end
- def require_personal_access_token?
- return false if current_application_settings.signin_enabled? || ldap_user?
+ def require_password_creation_for_git?
+ allow_password_authentication_for_git? && password_automatically_set?
+ end
+
+ def require_personal_access_token_creation_for_git_auth?
+ return false if allow_password_authentication_for_git? || ldap_user?
PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none?
end
+ def require_extra_setup_for_git_auth?
+ require_password_creation_for_git? || require_personal_access_token_creation_for_git_auth?
+ end
+
+ def allow_password_authentication?
+ allow_password_authentication_for_web? || allow_password_authentication_for_git?
+ end
+
+ def allow_password_authentication_for_web?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user?
+ end
+
+ def allow_password_authentication_for_git?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user?
+ end
+
def can_change_username?
gitlab_config.username_changing_enabled
end
@@ -599,42 +691,32 @@ class User < ActiveRecord::Base
Ability.allowed?(self, action, subject)
end
+ def confirm_deletion_with_password?
+ !password_automatically_set? && allow_password_authentication?
+ end
+
def first_name
name.split.first unless name.blank?
end
def projects_limit_left
- projects_limit - personal_projects.count
+ projects_limit - personal_projects_count
end
- def projects_limit_percent
- return 100 if projects_limit.zero?
- (personal_projects.count.to_f / projects_limit) * 100
+ def personal_projects_count
+ @personal_projects_count ||= personal_projects.count
end
- def recent_push(project_ids = nil)
- # Get push events not earlier than 2 hours ago
- events = recent_events.code_push.where("created_at > ?", Time.now - 2.hours)
- events = events.where(project_id: project_ids) if project_ids
+ def recent_push(project = nil)
+ service = Users::LastPushEventService.new(self)
- # Use the latest event that has not been pushed or merged recently
- events.recent.find do |event|
- project = Project.find_by_id(event.project_id)
- next unless project
-
- if project.repository.branch_exists?(event.branch_name)
- merge_requests = MergeRequest.where("created_at >= ?", event.created_at)
- .where(source_project_id: project.id,
- source_branch: event.branch_name)
- merge_requests.empty?
- end
+ if project
+ service.last_event_for_project(project)
+ else
+ service.last_event_for_user
end
end
- def projects_sorted_by_activity
- authorized_projects.sorted_by_activity
- end
-
def several_namespaces?
owned_groups.any? || masters_groups.any?
end
@@ -652,19 +734,15 @@ class User < ActiveRecord::Base
end
def fork_of(project)
- links = ForkedProjectLink.where(
- forked_from_project_id: project,
- forked_to_project_id: personal_projects.unscope(:order)
- )
- if links.any?
- links.first.forked_to_project
- else
- nil
- end
+ namespace.find_fork_of(project)
end
def ldap_user?
- identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"])
+ if identities.loaded?
+ identities.find { |identity| Gitlab::Auth::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? }
+ else
+ identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"])
+ end
end
def ldap_identity
@@ -688,9 +766,9 @@ class User < ActiveRecord::Base
end
def sanitize_attrs
- %w[name username skype linkedin twitter].each do |attr|
- value = public_send(attr)
- public_send("#{attr}=", Sanitize.clean(value)) if value.present?
+ %i[skype linkedin twitter].each do |attr|
+ value = self[attr]
+ self[attr] = Sanitize.clean(value) if value.present?
end
end
@@ -716,12 +794,9 @@ class User < ActiveRecord::Base
# `User.select(:id)` raises
# `ActiveModel::MissingAttributeError: missing attribute: projects_limit`
# without this safeguard!
- return unless has_attribute?(:projects_limit)
+ return unless has_attribute?(:projects_limit) && projects_limit.nil?
- connection_default_value_defined = new_record? && !projects_limit_changed?
- return unless projects_limit.nil? || connection_default_value_defined
-
- self.projects_limit = current_application_settings.default_projects_limit
+ self.projects_limit = Gitlab::CurrentSettings.default_projects_limit
end
def requires_ldap_check?
@@ -749,7 +824,7 @@ class User < ActiveRecord::Base
def with_defaults
User.defaults.each do |k, v|
- public_send("#{k}=", v)
+ public_send("#{k}=", v) # rubocop:disable GitlabSecurity/PublicSend
end
self
@@ -761,13 +836,13 @@ class User < ActiveRecord::Base
end
def full_website_url
- return "http://#{website_url}" if website_url !~ /\Ahttps?:\/\//
+ return "http://#{website_url}" if website_url !~ %r{\Ahttps?://}
website_url
end
def short_website_url
- website_url.sub(/\Ahttps?:\/\//, '')
+ website_url.sub(%r{\Ahttps?://}, '')
end
def all_ssh_keys
@@ -779,9 +854,11 @@ class User < ActiveRecord::Base
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)
+ GravatarService.new.execute(email, size, scale, username: username)
+ end
+
+ def primary_email_verified?
+ confirmed? && !temp_oauth_email?
end
def all_emails
@@ -791,28 +868,53 @@ class User < ActiveRecord::Base
all_emails
end
+ def verified_emails
+ verified_emails = []
+ verified_emails << email if primary_email_verified?
+ verified_emails.concat(emails.confirmed.pluck(:email))
+ verified_emails
+ end
+
+ def verified_email?(check_email)
+ downcased = check_email.downcase
+ email == downcased ? primary_email_verified? : emails.confirmed.where(email: downcased).exists?
+ end
+
def hook_attrs
{
name: name,
username: username,
- avatar_url: avatar_url
+ avatar_url: avatar_url(only_path: false)
}
end
def ensure_namespace_correct
- # Ensure user has namespace
- create_namespace!(path: username, name: username) unless namespace
-
- if username_changed?
- namespace.update_attributes(path: username, name: username)
+ if namespace
+ namespace.path = namespace.name = username if username_changed?
+ else
+ build_namespace(path: username, name: username)
end
end
+ def set_username_errors
+ namespace_path_errors = self.errors.delete(:"namespace.path")
+ self.errors[:username].concat(namespace_path_errors) if namespace_path_errors
+ end
+
+ def username_changed_hook
+ system_hook_service.execute_hooks_for(self, :rename)
+ end
+
def post_destroy_hook
log_info("User \"#{name}\" (#{email}) was removed")
+
system_hook_service.execute_hooks_for(self, :destroy)
end
+ def remove_key_cache
+ Users::KeysCountService.new(self).delete_cache
+ end
+
def delete_async(deleted_by:, params: {})
block if params[:hard_delete]
DeleteUserWorker.perform_async(deleted_by.id, id, params)
@@ -848,7 +950,16 @@ class User < ActiveRecord::Base
end
def manageable_namespaces
- @manageable_namespaces ||= [namespace] + owned_groups + masters_groups
+ @manageable_namespaces ||= [namespace] + manageable_groups
+ end
+
+ def manageable_groups
+ union = Gitlab::SQL::Union.new([owned_groups.select(:id),
+ masters_groups.select(:id)])
+ arel_union = Arel::Nodes::SqlLiteral.new(union.to_sql)
+ owned_and_master_groups = Group.where(Group.arel_table[:id].in(arel_union))
+
+ Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants
end
def namespaces
@@ -889,14 +1000,18 @@ class User < ActiveRecord::Base
def ci_authorized_runners
@ci_authorized_runners ||= begin
runner_ids = Ci::RunnerProject
- .where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})")
+ .where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
.select(:runner_id)
Ci::Runner.specific.where(id: runner_ids)
end
end
def notification_settings_for(source)
- notification_settings.find_or_initialize_by(source: source)
+ if notification_settings.loaded?
+ notification_settings.find { |notification| notification.source == source }
+ else
+ notification_settings.find_or_initialize_by(source: source)
+ end
end
# Lazy load global notification setting
@@ -922,14 +1037,33 @@ class User < ActiveRecord::Base
end
end
+ def todos_done_count(force: false)
+ Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do
+ TodosFinder.new(self, state: :done).execute.count
+ end
+ end
+
+ def todos_pending_count(force: false)
+ Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do
+ TodosFinder.new(self, state: :pending).execute.count
+ end
+ end
+
def update_cache_counts
assigned_open_merge_requests_count(force: true)
assigned_open_issues_count(force: true)
end
+ def update_todos_count_cache
+ todos_done_count(force: true)
+ todos_pending_count(force: true)
+ end
+
def invalidate_cache_counts
invalidate_issue_cache_counts
invalidate_merge_request_cache_counts
+ invalidate_todos_done_count
+ invalidate_todos_pending_count
end
def invalidate_issue_cache_counts
@@ -940,21 +1074,12 @@ class User < ActiveRecord::Base
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
- end
- end
-
- def todos_pending_count(force: false)
- Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do
- TodosFinder.new(self, state: :pending).execute.count
- end
+ def invalidate_todos_done_count
+ Rails.cache.delete(['users', id, 'todos_done_count'])
end
- def update_todos_count_cache
- todos_done_count(force: true)
- todos_pending_count(force: true)
+ def invalidate_todos_pending_count
+ Rails.cache.delete(['users', id, 'todos_pending_count'])
end
# This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth
@@ -969,7 +1094,7 @@ class User < ActiveRecord::Base
if attempts_exceeded?
lock_access! unless access_locked?
else
- Users::UpdateService.new(self).execute(validate: false)
+ Users::UpdateService.new(self, user: self).execute(validate: false)
end
end
@@ -1010,11 +1135,62 @@ class User < ActiveRecord::Base
ensure_rss_token!
end
+ def sync_attribute?(attribute)
+ return true if ldap_user? && attribute == :email
+
+ attributes = Gitlab.config.omniauth.sync_profile_attributes
+
+ if attributes.is_a?(Array)
+ attributes.include?(attribute.to_s)
+ else
+ attributes
+ end
+ end
+
+ def read_only_attribute?(attribute)
+ user_synced_attributes_metadata&.read_only?(attribute)
+ end
+
+ # override, from Devise
+ def lock_access!
+ Gitlab::AppLogger.info("Account Locked: username=#{username}")
+ super
+ end
+
+ # Determine the maximum access level for a group of projects in bulk.
+ #
+ # Returns a Hash mapping project ID -> maximum access level.
+ def max_member_access_for_project_ids(project_ids)
+ max_member_access_for_resource_ids(Project, project_ids) do |project_ids|
+ project_authorizations.where(project: project_ids)
+ .group(:project_id)
+ .maximum(:access_level)
+ end
+ end
+
+ def max_member_access_for_project(project_id)
+ max_member_access_for_project_ids([project_id])[project_id]
+ end
+
+ # Determine the maximum access level for a group of groups in bulk.
+ #
+ # Returns a Hash mapping project ID -> maximum access level.
+ def max_member_access_for_group_ids(group_ids)
+ max_member_access_for_resource_ids(Group, group_ids) do |group_ids|
+ group_members.where(source: group_ids).group(:source_id).maximum(:access_level)
+ end
+ end
+
+ def max_member_access_for_group(group_id)
+ max_member_access_for_group_ids([group_id])[group_id]
+ end
+
protected
# override, from Devise::Validatable
def password_required?
return false if internal?
+
super
end
@@ -1031,7 +1207,9 @@ class User < ActiveRecord::Base
# Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
def send_devise_notification(notification, *args)
- devise_mailer.send(notification, self, *args).deliver_later
+ return true unless can?(:receive_notifications)
+
+ devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
end
# This works around a bug in Devise 4.2.0 that erroneously causes a user to
@@ -1048,8 +1226,9 @@ class User < ActiveRecord::Base
self.can_create_group = false
self.projects_limit = 0
else
- self.can_create_group = gitlab_config.default_can_create_group
- self.projects_limit = current_application_settings.default_projects_limit
+ # Only revert these back to the default if they weren't specifically changed in this update.
+ self.can_create_group = gitlab_config.default_can_create_group unless can_create_group_changed?
+ self.projects_limit = Gitlab::CurrentSettings.default_projects_limit unless projects_limit_changed?
end
end
@@ -1057,15 +1236,15 @@ class User < ActiveRecord::Base
valid = true
error = nil
- if current_application_settings.domain_blacklist_enabled?
- blocked_domains = current_application_settings.domain_blacklist
+ if Gitlab::CurrentSettings.domain_blacklist_enabled?
+ blocked_domains = Gitlab::CurrentSettings.domain_blacklist
if domain_matches?(blocked_domains, email)
error = 'is not from an allowed domain.'
valid = false
end
end
- allowed_domains = current_application_settings.domain_whitelist
+ allowed_domains = Gitlab::CurrentSettings.domain_whitelist
unless allowed_domains.blank?
if domain_matches?(allowed_domains, email)
valid = true
@@ -1134,7 +1313,7 @@ class User < ActiveRecord::Base
&creation_block
)
- Users::UpdateService.new(user).execute(validate: false)
+ Users::UpdateService.new(user, user: user).execute(validate: false)
user
ensure
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
new file mode 100644
index 00000000000..e4b69382626
--- /dev/null
+++ b/app/models/user_callout.rb
@@ -0,0 +1,13 @@
+class UserCallout < ActiveRecord::Base
+ belongs_to :user
+
+ enum feature_name: {
+ gke_cluster_integration: 1
+ }
+
+ validates :user, presence: true
+ validates :feature_name,
+ presence: true,
+ uniqueness: { scope: :user_id },
+ inclusion: { in: UserCallout.feature_names.keys }
+end
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
new file mode 100644
index 00000000000..eff25b31f9b
--- /dev/null
+++ b/app/models/user_custom_attribute.rb
@@ -0,0 +1,6 @@
+class UserCustomAttribute < ActiveRecord::Base
+ belongs_to :user
+
+ validates :user_id, :key, :value, presence: true
+ validates :key, uniqueness: { scope: [:user_id] }
+end
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
new file mode 100644
index 00000000000..dd55a6acb79
--- /dev/null
+++ b/app/models/user_interacted_project.rb
@@ -0,0 +1,59 @@
+class UserInteractedProject < ActiveRecord::Base
+ belongs_to :user
+ belongs_to :project
+
+ validates :project_id, presence: true
+ validates :user_id, presence: true
+
+ CACHE_EXPIRY_TIME = 1.day
+
+ # Schema version required for this model
+ REQUIRED_SCHEMA_VERSION = 20180223120443
+
+ class << self
+ def track(event)
+ # For events without a project, we simply don't care.
+ # An example of this is the creation of a snippet (which
+ # is not related to any project).
+ return unless event.project_id
+
+ attributes = {
+ project_id: event.project_id,
+ user_id: event.author_id
+ }
+
+ cached_exists?(attributes) do
+ transaction(requires_new: true) do
+ begin
+ where(attributes).select(1).first || create!(attributes)
+ true # not caching the whole record here for now
+ rescue ActiveRecord::RecordNotUnique
+ # Note, above queries are not atomic and prone
+ # to race conditions (similar like #find_or_create!).
+ # In the case where we hit this, the record we want
+ # already exists - shortcut and return.
+ true
+ end
+ end
+ end
+ end
+
+ # Check if we can safely call .track (table exists)
+ def available?
+ @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
+ end
+
+ # Flushes cached information about schema
+ def reset_column_information
+ @available_flag = nil
+ super
+ end
+
+ private
+
+ def cached_exists?(project_id:, user_id:, &block)
+ cache_key = "user_interacted_projects:#{project_id}:#{user_id}"
+ Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY_TIME, &block)
+ end
+ end
+end
diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
new file mode 100644
index 00000000000..688432a9d67
--- /dev/null
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -0,0 +1,31 @@
+class UserSyncedAttributesMetadata < ActiveRecord::Base
+ belongs_to :user
+
+ validates :user, presence: true
+
+ SYNCABLE_ATTRIBUTES = %i[name email location].freeze
+
+ def read_only?(attribute)
+ sync_profile_from_provider? && synced?(attribute)
+ end
+
+ def read_only_attributes
+ return [] unless sync_profile_from_provider?
+
+ SYNCABLE_ATTRIBUTES.select { |key| synced?(key) }
+ end
+
+ def synced?(attribute)
+ read_attribute("#{attribute}_synced")
+ end
+
+ def set_attribute_synced(attribute, value)
+ write_attribute("#{attribute}_synced", value)
+ end
+
+ private
+
+ def sync_profile_from_provider?
+ Gitlab::Auth::OAuth::Provider.sync_profile_from_provider?(provider)
+ end
+end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 224eb3cd4d0..0f5536415f7 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -1,4 +1,7 @@
class WikiPage
+ PageChangedError = Class.new(StandardError)
+ PageRenameError = Class.new(StandardError)
+
include ActiveModel::Validations
include ActiveModel::Conversion
include StaticModel
@@ -48,7 +51,7 @@ class WikiPage
# The Gitlab ProjectWiki instance.
attr_reader :wiki
- # The raw Gollum::Page instance.
+ # The raw Gitlab::Git::WikiPage instance.
attr_reader :page
# The attributes Hash used for storing and validating
@@ -73,7 +76,7 @@ class WikiPage
if @attributes[:slug].present?
@attributes[:slug]
else
- wiki.wiki.preview_page(title, '', format).url_path
+ wiki.wiki.preview_slug(title, format)
end
end
@@ -82,7 +85,7 @@ class WikiPage
# The formatted title of this page.
def title
if @attributes[:title]
- self.class.unhyphenize(@attributes[:title])
+ CGI.unescape_html(self.class.unhyphenize(@attributes[:title]))
else
""
end
@@ -100,12 +103,15 @@ class WikiPage
# The hierarchy of the directory this page is contained in.
def directory
- wiki.page_title_and_dir(slug).last
+ wiki.page_title_and_dir(slug)&.last.to_s
end
# The processed/formatted content of this page.
def formatted_content
- @attributes[:formatted_content] ||= @page&.formatted_data
+ # Assuming @page exists, nil formatted_data means we didn't load it
+ # before hand (i.e. page was fetched by Gitaly), so we fetch it separately.
+ # If the page was fetched by Gollum, formatted_data would've been a String.
+ @attributes[:formatted_content] ||= @page&.formatted_data || @wiki.page_formatted_data(@page)
end
# The markup format for the page.
@@ -125,15 +131,24 @@ class WikiPage
@version ||= @page.version
end
- # Returns an array of Gitlab Commit instances.
- def versions
+ def versions(options = {})
+ return [] unless persisted?
+
+ wiki.wiki.page_versions(@page.path, options)
+ end
+
+ def count_versions
return [] unless persisted?
- @page.versions
+ wiki.wiki.count_page_versions(@page.path)
end
- def commit
- versions.first
+ def last_version
+ @last_version ||= versions(limit: 1).first
+ end
+
+ def last_commit_sha
+ last_version&.sha
end
# Returns the Date that this latest version was
@@ -145,7 +160,7 @@ class WikiPage
# Returns boolean True or False if this instance
# is an old version of the page.
def historical?
- @page.historical? && versions.first.sha != version.sha
+ @page.historical? && last_version.sha != version.sha
end
# Returns boolean True or False if this instance
@@ -163,7 +178,7 @@ class WikiPage
# Creates a new Wiki Page.
#
# attr - Hash of attributes to set on the new page.
- # :title - The title for the new page.
+ # :title - The title (optionally including dir) for the new page.
# :content - The raw markup content.
# :format - Optional symbol representing the
# content format. Can be any type
@@ -174,26 +189,55 @@ class WikiPage
#
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
- def create(attr = {})
- @attributes.merge!(attr)
+ def create(attrs = {})
+ update_attributes(attrs)
- save :create_page, title, content, format, message
+ save(page_details: title) do
+ wiki.create_page(title, content, format, message)
+ end
end
# Updates an existing Wiki Page, creating a new version.
#
- # new_content - The raw markup content to replace the existing.
- # format - Optional symbol representing the content format.
- # See ProjectWiki::MARKUPS Hash for available formats.
- # message - Optional commit message to set on the new version.
+ # attrs - Hash of attributes to be updated on the page.
+ # :content - The raw markup content to replace the existing.
+ # :format - Optional symbol representing the content format.
+ # See ProjectWiki::MARKUPS Hash for available formats.
+ # :message - Optional commit message to set on the new version.
+ # :last_commit_sha - Optional last commit sha to validate the page unchanged.
+ # :title - The Title (optionally including dir) to replace existing title
#
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
- def update(new_content = "", format = :markdown, message = nil)
- @attributes[:content] = new_content
- @attributes[:format] = format
+ def update(attrs = {})
+ last_commit_sha = attrs.delete(:last_commit_sha)
+
+ if last_commit_sha && last_commit_sha != self.last_commit_sha
+ raise PageChangedError
+ end
- save :update_page, @page, content, format, message
+ update_attributes(attrs)
+
+ if title_changed?
+ page_details = title
+
+ if wiki.find_page(page_details).present?
+ @attributes[:title] = @page.url_path
+ raise PageRenameError
+ end
+ else
+ page_details = @page.url_path
+ end
+
+ save(page_details: page_details) do
+ wiki.update_page(
+ @page,
+ content: content,
+ format: format,
+ message: attrs[:message],
+ title: title
+ )
+ end
end
# Destroys the Wiki Page.
@@ -217,38 +261,63 @@ class WikiPage
page.version.to_s
end
+ def title_changed?
+ title.present? && self.class.unhyphenize(@page.url_path) != title
+ end
+
private
+ # Process and format the title based on the user input.
+ def process_title(title)
+ return if title.blank?
+
+ title = deep_title_squish(title)
+ current_dirname = File.dirname(title)
+
+ if @page.present?
+ return title[1..-1] if current_dirname == '/'
+ return File.join([directory.presence, title].compact) if current_dirname == '.'
+ end
+
+ title
+ end
+
+ # This method squishes all the filename
+ # i.e: ' foo / bar / page_name' => 'foo/bar/page_name'
+ def deep_title_squish(title)
+ components = title.split(File::SEPARATOR).map(&:squish)
+
+ File.join(components)
+ end
+
+ # Updates the current @attributes hash by merging a hash of params
+ def update_attributes(attrs)
+ attrs[:title] = process_title(attrs[:title]) if attrs[:title].present?
+
+ attrs.slice!(:content, :format, :message, :title)
+
+ @attributes.merge!(attrs)
+ end
+
def set_attributes
attributes[:slug] = @page.url_path
attributes[:title] = @page.title
attributes[:format] = @page.format
end
- def save(method, *args)
- saved = false
-
- project_wiki = wiki
- if valid? && project_wiki.send(method, *args)
+ def save(page_details:)
+ return unless valid?
- page_details = if method == :update_page
- # Use url_path instead of path to omit format extension
- @page.url_path
- else
- title
- end
-
- page_title, page_dir = project_wiki.page_title_and_dir(page_details)
- gollum_wiki = project_wiki.wiki
- @page = gollum_wiki.paged(page_title, page_dir)
+ unless yield
+ errors.add(:base, wiki.error_message)
+ return false
+ end
- set_attributes
+ page_title, page_dir = wiki.page_title_and_dir(page_details)
+ gitlab_git_wiki = wiki.wiki
+ @page = gitlab_git_wiki.page(title: page_title, dir: page_dir)
- @persisted = true
- saved = true
- else
- errors.add(:base, project_wiki.error_message) if project_wiki.error_message
- end
- saved
+ set_attributes
+ @persisted = errors.blank?
end
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 623424c63e0..603218aa6df 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -1,127 +1,21 @@
-class BasePolicy
- class RuleSet
- attr_reader :can_set, :cannot_set
- def initialize(can_set, cannot_set)
- @can_set = can_set
- @cannot_set = cannot_set
- end
+require_dependency 'declarative_policy'
- delegate :size, to: :to_set
+class BasePolicy < DeclarativePolicy::Base
+ desc "User is an instance admin"
+ with_options scope: :user, score: 0
+ condition(:admin) { @user&.admin? }
- def self.empty
- new(Set.new, Set.new)
- end
+ with_options scope: :user, score: 0
+ condition(:external_user) { @user.nil? || @user.external? }
- def self.none
- empty.freeze
- end
+ with_options scope: :user, score: 0
+ condition(:can_create_group) { @user&.can_create_group }
- def can?(ability)
- @can_set.include?(ability) && !@cannot_set.include?(ability)
- end
-
- def include?(ability)
- can?(ability)
- end
-
- def to_set
- @can_set - @cannot_set
- end
-
- def merge(other)
- @can_set.merge(other.can_set)
- @cannot_set.merge(other.cannot_set)
- end
-
- def can!(*abilities)
- @can_set.merge(abilities)
- end
-
- def cannot!(*abilities)
- @cannot_set.merge(abilities)
- end
-
- def freeze
- @can_set.freeze
- @cannot_set.freeze
- super
- end
- end
-
- def self.abilities(user, subject)
- new(user, subject).abilities
- end
-
- def self.class_for(subject)
- return GlobalPolicy if subject == :global
- raise ArgumentError, 'no policy for nil' if subject.nil?
-
- if subject.class.try(:presenter?)
- subject = subject.subject
- end
-
- subject.class.ancestors.each do |klass|
- next unless klass.name
-
- begin
- policy_class = "#{klass.name}Policy".constantize
-
- # NOTE: the < operator here tests whether policy_class
- # inherits from BasePolicy
- return policy_class if policy_class < BasePolicy
- rescue NameError
- nil
- end
- end
-
- raise "no policy for #{subject.class.name}"
- end
-
- attr_reader :user, :subject
- def initialize(user, subject)
- @user = user
- @subject = subject
+ desc "The application is restricted from public visibility"
+ condition(:restricted_public_level, scope: :global) do
+ Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
- def abilities
- return RuleSet.none if @user && @user.blocked?
- return anonymous_abilities if @user.nil?
- collect_rules { rules }
- end
-
- def anonymous_abilities
- collect_rules { anonymous_rules }
- end
-
- def anonymous_rules
- rules
- end
-
- def rules
- raise NotImplementedError
- end
-
- def delegate!(new_subject)
- @rule_set.merge(Ability.allowed(@user, new_subject))
- end
-
- def can?(rule)
- @rule_set.can?(rule)
- end
-
- def can!(*rules)
- @rule_set.can!(*rules)
- end
-
- def cannot!(*rules)
- @rule_set.cannot!(*rules)
- end
-
- private
-
- def collect_rules(&b)
- @rule_set = RuleSet.empty
- yield
- @rule_set
- end
+ # This is prevented in some cases in `gitlab-ee`
+ rule { default }.enable :read_cross_project
end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 2d7405dc240..1ab391a5a9d 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -1,29 +1,24 @@
module Ci
class BuildPolicy < CommitStatusPolicy
- alias_method :build, :subject
+ condition(:protected_ref) do
+ access = ::Gitlab::UserAccess.new(@user, project: @subject.project)
- def rules
- super
-
- # If we can't read build we should also not have that
- # ability when looking at this in context of commit_status
- %w[read create update admin].each do |rule|
- cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build"
- end
-
- if can?(:update_build) && protected_action?
- cannot! :update_build
+ if @subject.tag?
+ !access.can_create_tag?(@subject.ref)
+ else
+ !access.can_update_branch?(@subject.ref)
end
end
- private
-
- def protected_action?
- return false unless build.action?
+ condition(:owner_of_job) do
+ can?(:developer_access) && @subject.triggered_by?(@user)
+ end
- !::Gitlab::UserAccess
- .new(user, project: build.project)
- .can_merge_to_branch?(build.ref)
+ rule { protected_ref }.policy do
+ prevent :update_build
+ prevent :erase_build
end
+
+ rule { can?(:master_access) | owner_of_job }.enable :erase_build
end
end
diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb
index 10aa2d3e72a..6363c382ff8 100644
--- a/app/policies/ci/pipeline_policy.rb
+++ b/app/policies/ci/pipeline_policy.rb
@@ -1,7 +1,19 @@
module Ci
class PipelinePolicy < BasePolicy
- def rules
- delegate! @subject.project
+ delegate { @subject.project }
+
+ condition(:protected_ref) { ref_protected?(@user, @subject.project, @subject.tag?, @subject.ref) }
+
+ rule { protected_ref }.prevent :update_pipeline
+
+ def ref_protected?(user, project, tag, ref)
+ access = ::Gitlab::UserAccess.new(user, project: project)
+
+ if tag
+ !access.can_create_tag?(ref)
+ else
+ !access.can_update_branch?(ref)
+ end
end
end
end
diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb
index 1877e89bb23..dc7a4aed577 100644
--- a/app/policies/ci/pipeline_schedule_policy.rb
+++ b/app/policies/ci/pipeline_schedule_policy.rb
@@ -1,4 +1,32 @@
module Ci
class PipelineSchedulePolicy < PipelinePolicy
+ alias_method :pipeline_schedule, :subject
+
+ condition(:protected_ref) do
+ ref_protected?(@user, @subject.project, @subject.project.repository.tag_exists?(@subject.ref), @subject.ref)
+ end
+
+ condition(:owner_of_schedule) do
+ can?(:developer_access) && pipeline_schedule.owned_by?(@user)
+ end
+
+ condition(:non_owner_of_schedule) do
+ !pipeline_schedule.owned_by?(@user)
+ end
+
+ rule { can?(:developer_access) }.policy do
+ enable :play_pipeline_schedule
+ end
+
+ rule { can?(:master_access) | owner_of_schedule }.policy do
+ enable :update_pipeline_schedule
+ enable :admin_pipeline_schedule
+ end
+
+ rule { can?(:master_access) & non_owner_of_schedule }.policy do
+ enable :take_ownership_pipeline_schedule
+ end
+
+ rule { protected_ref }.prevent :play_pipeline_schedule
end
end
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 416d93ffe63..7dff8470e23 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -1,13 +1,16 @@
module Ci
class RunnerPolicy < BasePolicy
- def rules
- return unless @user
+ with_options scope: :subject, score: 0
+ condition(:shared) { @subject.is_shared? }
- can! :assign_runner if @user.admin?
+ with_options scope: :subject, score: 0
+ condition(:locked, scope: :subject) { @subject.locked? }
- return if @subject.is_shared? || @subject.locked?
+ condition(:authorized_runner) { @user.ci_authorized_runners.include?(@subject) }
- can! :assign_runner if @user.ci_authorized_runners.include?(@subject)
- end
+ rule { anonymous }.prevent_all
+ rule { admin | authorized_runner }.enable :assign_runner
+ rule { ~admin & shared }.prevent :assign_runner
+ rule { ~admin & locked }.prevent :assign_runner
end
end
diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb
index c90c9ac0583..5592ac30812 100644
--- a/app/policies/ci/trigger_policy.rb
+++ b/app/policies/ci/trigger_policy.rb
@@ -1,13 +1,16 @@
module Ci
class TriggerPolicy < BasePolicy
- def rules
- delegate! @subject.project
-
- if can?(:admin_build)
- can! :admin_trigger if @subject.owner.blank? ||
- @subject.owner == @user
- can! :manage_trigger
- end
- end
+ delegate { @subject.project }
+
+ with_options scope: :subject, score: 0
+ condition(:legacy) { @subject.legacy? }
+
+ with_score 0
+ condition(:is_owner) { @user && @subject.owner_id == @user.id }
+
+ rule { ~can?(:admin_build) }.prevent :admin_trigger
+ rule { legacy | is_owner }.enable :admin_trigger
+
+ rule { can?(:admin_build) }.enable :manage_trigger
end
end
diff --git a/app/policies/clusters/cluster_policy.rb b/app/policies/clusters/cluster_policy.rb
new file mode 100644
index 00000000000..1f7c13072b9
--- /dev/null
+++ b/app/policies/clusters/cluster_policy.rb
@@ -0,0 +1,12 @@
+module Clusters
+ class ClusterPolicy < BasePolicy
+ alias_method :cluster, :subject
+
+ delegate { cluster.project }
+
+ rule { can?(:master_access) }.policy do
+ enable :update_cluster
+ enable :admin_cluster
+ end
+ end
+end
diff --git a/app/policies/commit_status_policy.rb b/app/policies/commit_status_policy.rb
index 593df738328..24b2a4cc7fd 100644
--- a/app/policies/commit_status_policy.rb
+++ b/app/policies/commit_status_policy.rb
@@ -1,5 +1,7 @@
class CommitStatusPolicy < BasePolicy
- def rules
- delegate! @subject.project
+ delegate { @subject.project }
+
+ %w[read create update admin].each do |action|
+ rule { ~can?(:"#{action}_commit_status") }.prevent :"#{action}_build"
end
end
diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb
index ebab213e6be..62a22a59be6 100644
--- a/app/policies/deploy_key_policy.rb
+++ b/app/policies/deploy_key_policy.rb
@@ -1,11 +1,11 @@
class DeployKeyPolicy < BasePolicy
- def rules
- return unless @user
+ with_options scope: :subject, score: 0
+ condition(:private_deploy_key) { @subject.private? }
- can! :update_deploy_key if @user.admin?
+ condition(:has_deploy_key) { @user.project_deploy_keys.exists?(id: @subject.id) }
- if @subject.private? && @user.project_deploy_keys.exists?(id: @subject.id)
- can! :update_deploy_key
- end
- end
+ rule { anonymous }.prevent_all
+
+ rule { admin }.enable :update_deploy_key
+ rule { private_deploy_key & has_deploy_key }.enable :update_deploy_key
end
diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb
index 163d070ff90..62b63b9f87b 100644
--- a/app/policies/deployment_policy.rb
+++ b/app/policies/deployment_policy.rb
@@ -1,5 +1,3 @@
class DeploymentPolicy < BasePolicy
- def rules
- delegate! @subject.project
- end
+ delegate { @subject.project }
end
diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb
index 2fa15e64562..375a5535359 100644
--- a/app/policies/environment_policy.rb
+++ b/app/policies/environment_policy.rb
@@ -1,17 +1,9 @@
class EnvironmentPolicy < BasePolicy
- alias_method :environment, :subject
+ delegate { @subject.project }
- def rules
- delegate! environment.project
-
- if can?(:create_deployment) && environment.stop_action?
- can! :stop_environment if can_play_stop_action?
- end
+ condition(:stop_action_allowed) do
+ @subject.stop_action? && can?(:update_build, @subject.stop_action)
end
- private
-
- def can_play_stop_action?
- Ability.allowed?(user, :update_build, environment.stop_action)
- end
+ rule { can?(:create_deployment) & stop_action_allowed }.enable :stop_environment
end
diff --git a/app/policies/external_issue_policy.rb b/app/policies/external_issue_policy.rb
index d9e28bd107a..e031b38078c 100644
--- a/app/policies/external_issue_policy.rb
+++ b/app/policies/external_issue_policy.rb
@@ -1,5 +1,3 @@
class ExternalIssuePolicy < BasePolicy
- def rules
- delegate! @subject.project
- end
+ delegate { @subject.project }
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 2683aaad981..64e550d19d0 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -1,16 +1,61 @@
class GlobalPolicy < BasePolicy
- def rules
- return unless @user
-
- can! :create_group if @user.can_create_group
- can! :read_users_list
-
- unless @user.blocked? || @user.internal?
- can! :log_in unless @user.access_locked?
- can! :access_api
- can! :access_git
- can! :receive_notifications
- can! :use_quick_actions
- end
+ desc "User is blocked"
+ with_options scope: :user, score: 0
+ condition(:blocked) { @user.blocked? }
+
+ desc "User is an internal user"
+ with_options scope: :user, score: 0
+ condition(:internal) { @user.internal? }
+
+ desc "User's access has been locked"
+ with_options scope: :user, score: 0
+ condition(:access_locked) { @user.access_locked? }
+
+ condition(:can_create_fork, scope: :user) { @user.manageable_namespaces.any? { |namespace| @user.can?(:create_projects, namespace) } }
+
+ rule { anonymous }.policy do
+ prevent :log_in
+ prevent :access_api
+ prevent :access_git
+ prevent :receive_notifications
+ prevent :use_quick_actions
+ prevent :create_group
+ end
+
+ rule { default }.policy do
+ enable :log_in
+ enable :access_api
+ enable :access_git
+ enable :receive_notifications
+ enable :use_quick_actions
+ end
+
+ rule { blocked | internal }.policy do
+ prevent :log_in
+ prevent :access_api
+ prevent :access_git
+ prevent :receive_notifications
+ prevent :use_quick_actions
+ end
+
+ rule { can_create_group }.policy do
+ enable :create_group
+ end
+
+ rule { can_create_fork }.policy do
+ enable :create_fork
+ end
+
+ rule { access_locked }.policy do
+ prevent :log_in
+ end
+
+ rule { ~(anonymous & restricted_public_level) }.policy do
+ enable :read_users_list
+ end
+
+ rule { admin }.policy do
+ enable :read_custom_attribute
+ enable :update_custom_attribute
end
end
diff --git a/app/policies/group_label_policy.rb b/app/policies/group_label_policy.rb
index 7b34aa182eb..e3dd3296699 100644
--- a/app/policies/group_label_policy.rb
+++ b/app/policies/group_label_policy.rb
@@ -1,5 +1,3 @@
class GroupLabelPolicy < BasePolicy
- def rules
- delegate! @subject.group
- end
+ delegate { @subject.group }
end
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index 5a3fe814b77..23dd0d7cd23 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -1,25 +1,22 @@
class GroupMemberPolicy < BasePolicy
- def rules
- return unless @user
+ delegate :group
- target_user = @subject.user
- group = @subject.group
+ with_scope :subject
+ condition(:last_owner) { @subject.group.last_owner?(@subject.user) }
- return if group.last_owner?(target_user)
+ desc "Membership is users' own"
+ with_score 0
+ condition(:is_target_user) { @user && @subject.user_id == @user.id }
- can_manage = Ability.allowed?(@user, :admin_group_member, group)
+ rule { anonymous }.prevent_all
+ rule { last_owner }.prevent_all
- if can_manage
- can! :update_group_member
- can! :destroy_group_member
- elsif @user == target_user
- can! :destroy_group_member
- end
-
- additional_rules!
+ rule { can?(:admin_group_member) }.policy do
+ enable :update_group_member
+ enable :destroy_group_member
end
- def additional_rules!
- # This is meant to be overriden in EE
+ rule { is_target_user }.policy do
+ enable :destroy_group_member
end
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index fb07298c6c2..c9cb730c4e9 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -1,50 +1,88 @@
class GroupPolicy < BasePolicy
- def rules
- can! :read_group if @subject.public?
- return unless @user
-
- globally_viewable = @subject.public? || (@subject.internal? && !@user.external?)
- 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 ||= 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
- end
-
- # Only group owner and administrators can admin group
- if owner
- can! :admin_group
- can! :admin_namespace
- can! :admin_group_member
- can! :change_visibility_level
- can! :create_subgroup if @user.can_create_group
- end
-
- if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS
- can! :request_access
- end
- end
+ desc "Group is public"
+ with_options scope: :subject, score: 0
+ condition(:public_group) { @subject.public? }
+
+ with_score 0
+ condition(:logged_in_viewable) { @user && @subject.internal? && !@user.external? }
+
+ condition(:has_access) { access_level != GroupMember::NO_ACCESS }
+
+ condition(:guest) { access_level >= GroupMember::GUEST }
+ condition(:developer) { access_level >= GroupMember::DEVELOPER }
+ condition(:owner) { access_level >= GroupMember::OWNER }
+ condition(:master) { access_level >= GroupMember::MASTER }
+ condition(:reporter) { access_level >= GroupMember::REPORTER }
- def can_read_group?
- return true if @subject.public?
- return true if @user.admin?
- return true if @subject.internal? && !@user.external?
- return true if @subject.users.include?(@user)
+ condition(:nested_groups_supported, scope: :global) { Group.supports_nested_groups? }
+ condition(:has_parent, scope: :subject) { @subject.has_parent? }
+ condition(:share_with_group_locked, scope: :subject) { @subject.share_with_group_lock? }
+ condition(:parent_share_with_group_locked, scope: :subject) { @subject.parent&.share_with_group_lock? }
+ condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
+
+ condition(:has_projects) do
GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
end
+
+ with_options scope: :subject, score: 0
+ condition(:request_access_enabled) { @subject.request_access_enabled }
+
+ rule { public_group }.policy do
+ enable :read_group
+ enable :read_list
+ enable :read_label
+ end
+
+ rule { logged_in_viewable }.enable :read_group
+
+ rule { guest }.policy do
+ enable :read_group
+ enable :upload_file
+ enable :read_label
+ end
+
+ rule { admin } .enable :read_group
+ rule { has_projects } .enable :read_group
+
+ rule { has_access }.enable :read_namespace
+
+ rule { developer }.enable :admin_milestones
+
+ rule { reporter }.policy do
+ enable :admin_label
+ enable :admin_list
+ enable :admin_issue
+ end
+
+ rule { master }.policy do
+ enable :create_projects
+ enable :admin_pipeline
+ enable :admin_build
+ end
+
+ rule { owner }.policy do
+ enable :admin_group
+ enable :admin_namespace
+ enable :admin_group_member
+ enable :change_visibility_level
+ end
+
+ rule { owner & nested_groups_supported }.enable :create_subgroup
+
+ rule { public_group | logged_in_viewable }.enable :view_globally
+
+ rule { default }.enable(:request_access)
+
+ rule { ~request_access_enabled }.prevent :request_access
+ rule { ~can?(:view_globally) }.prevent :request_access
+ rule { has_access }.prevent :request_access
+
+ rule { owner & (~share_with_group_locked | ~has_parent | ~parent_share_with_group_locked | can_change_parent_share_with_group_lock) }.enable :change_share_with_group_lock
+
+ def access_level
+ return GroupMember::NO_ACCESS if @user.nil?
+
+ @access_level ||= @subject.max_member_access_for_user(@user)
+ end
end
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 9501e499507..3f6d7d04667 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -1,14 +1,40 @@
class IssuablePolicy < BasePolicy
- def action_name
- @subject.class.name.underscore
+ delegate { @subject.project }
+
+ condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? }
+
+ # We aren't checking `:read_issue` or `:read_merge_request` in this case
+ # because it could be possible for a user to see an issuable-iid
+ # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be allowed
+ # to read the actual issue after a more expensive `:read_issue` check.
+ #
+ # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee.
+ condition(:visible_to_user, score: 4) do
+ Project.where(id: @subject.project)
+ .public_or_visible_to_user(@user)
+ .with_feature_available_for_user(@subject, @user)
+ .any?
+ end
+
+ condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) }
+
+ desc "User is the assignee or author"
+ condition(:assignee_or_author) do
+ @user && @subject.assignee_or_author?(@user)
end
- def rules
- if @user && @subject.assignee_or_author?(@user)
- can! :"read_#{action_name}"
- can! :"update_#{action_name}"
- end
+ rule { assignee_or_author }.policy do
+ enable :read_issue
+ enable :update_issue
+ enable :read_merge_request
+ enable :update_merge_request
+ end
- delegate! @subject.project
+ rule { locked & ~is_project_member }.policy do
+ prevent :create_note
+ prevent :update_note
+ prevent :admin_note
+ prevent :resolve_note
+ prevent :edit_note
end
end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 88f3179c6ff..ed499511999 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -3,25 +3,20 @@ class IssuePolicy < IssuablePolicy
# Make sure to sync this class checks with issue.rb to avoid security problems.
# Check commit 002ad215818450d2cbbc5fa065850a953dc7ada8 for more information.
- def issue
- @subject
+ desc "User can read confidential issues"
+ condition(:can_read_confidential) do
+ @user && IssueCollection.new([@subject]).visible_to(@user).any?
end
- def rules
- super
+ desc "Issue is confidential"
+ condition(:confidential, scope: :subject) { @subject.confidential? }
- if @subject.confidential? && !can_read_confidential?
- cannot! :read_issue
- cannot! :update_issue
- cannot! :admin_issue
- end
+ rule { confidential & ~can_read_confidential }.policy do
+ prevent :read_issue
+ prevent :read_issue_iid
+ prevent :update_issue
+ prevent :admin_issue
end
- private
-
- def can_read_confidential?
- return false unless @user
-
- IssueCollection.new([@subject]).visible_to(@user).any?
- end
+ rule { can?(:read_issue) | visible_to_user }.enable :read_issue_iid
end
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index bc3afc626fb..e003376d219 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -1,3 +1,3 @@
class MergeRequestPolicy < IssuablePolicy
- # pass
+ rule { can?(:read_merge_request) | visible_to_user }.enable :read_merge_request_iid
end
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index 29bb357e00a..eb01218eb0a 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -1,10 +1,15 @@
class NamespacePolicy < BasePolicy
- def rules
- return unless @user
+ rule { anonymous }.prevent_all
- if @subject.owner == @user || @user.admin?
- can! :create_projects
- can! :admin_namespace
- end
+ condition(:personal_project, scope: :subject) { @subject.kind == 'user' }
+ condition(:can_create_personal_project, scope: :user) { @user.can_create_project? }
+ condition(:owner) { @subject.owner == @user }
+
+ rule { owner | admin }.policy do
+ enable :create_projects
+ enable :admin_namespace
+ enable :read_namespace
end
+
+ rule { personal_project & ~can_create_personal_project }.prevent :create_projects
end
diff --git a/app/policies/nil_policy.rb b/app/policies/nil_policy.rb
new file mode 100644
index 00000000000..13f46ba60f0
--- /dev/null
+++ b/app/policies/nil_policy.rb
@@ -0,0 +1,3 @@
+class NilPolicy < BasePolicy
+ rule { default }.prevent_all
+end
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index 5326061bd07..d4cb5a77e63 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -1,19 +1,26 @@
class NotePolicy < BasePolicy
- def rules
- delegate! @subject.project
+ delegate { @subject.project }
+ delegate { @subject.noteable if @subject.noteable.lockable? }
- return unless @user
+ condition(:is_author) { @user && @subject.author == @user }
+ condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
+ condition(:is_noteable_author) { @user && @subject.noteable.author_id == @user.id }
- if @subject.author == @user
- can! :read_note
- can! :update_note
- can! :admin_note
- can! :resolve_note
- end
+ condition(:editable, scope: :subject) { @subject.editable? }
- if @subject.for_merge_request? &&
- @subject.noteable.author == @user
- can! :resolve_note
- end
+ rule { ~editable | anonymous }.prevent :edit_note
+
+ rule { is_author | admin }.enable :edit_note
+ rule { can?(:master_access) }.enable :edit_note
+
+ rule { is_author }.policy do
+ enable :read_note
+ enable :update_note
+ enable :admin_note
+ enable :resolve_note
+ end
+
+ rule { for_merge_request & is_noteable_author }.policy do
+ enable :resolve_note
end
end
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index e1e5336da8c..cac0530b9f7 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -1,27 +1,28 @@
class PersonalSnippetPolicy < BasePolicy
- def rules
- can! :read_personal_snippet if @subject.public?
- return unless @user
+ condition(:public_snippet, scope: :subject) { @subject.public? }
+ condition(:is_author) { @user && @subject.author == @user }
+ condition(:internal_snippet, scope: :subject) { @subject.internal? }
- if @subject.public?
- can! :comment_personal_snippet
- end
+ rule { public_snippet }.policy do
+ enable :read_personal_snippet
+ enable :comment_personal_snippet
+ end
- if @subject.author == @user
- can! :read_personal_snippet
- can! :update_personal_snippet
- can! :destroy_personal_snippet
- can! :admin_personal_snippet
- can! :comment_personal_snippet
- end
+ rule { is_author }.policy do
+ enable :read_personal_snippet
+ enable :update_personal_snippet
+ enable :destroy_personal_snippet
+ enable :admin_personal_snippet
+ enable :comment_personal_snippet
+ end
- unless @user.external?
- can! :create_personal_snippet
- end
+ rule { ~anonymous }.enable :create_personal_snippet
+ rule { external_user }.prevent :create_personal_snippet
- if @subject.internal? && !@user.external?
- can! :read_personal_snippet
- can! :comment_personal_snippet
- end
+ rule { internal_snippet & ~external_user }.policy do
+ enable :read_personal_snippet
+ enable :comment_personal_snippet
end
+
+ rule { anonymous }.prevent :comment_personal_snippet
end
diff --git a/app/policies/project_label_policy.rb b/app/policies/project_label_policy.rb
index b12b4c5166b..2d0f021118b 100644
--- a/app/policies/project_label_policy.rb
+++ b/app/policies/project_label_policy.rb
@@ -1,5 +1,3 @@
class ProjectLabelPolicy < BasePolicy
- def rules
- delegate! @subject.project
- end
+ delegate { @subject.project }
end
diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb
index 1c038dddd4b..9aedb620be9 100644
--- a/app/policies/project_member_policy.rb
+++ b/app/policies/project_member_policy.rb
@@ -1,22 +1,16 @@
class ProjectMemberPolicy < BasePolicy
- def rules
- # anonymous users have no abilities here
- return unless @user
+ delegate { @subject.project }
- target_user = @subject.user
- project = @subject.project
+ condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner }
+ condition(:target_is_self) { @user && @subject.user == @user }
- return if target_user == project.owner
+ rule { anonymous }.prevent_all
+ rule { target_is_owner }.prevent_all
- can_manage = Ability.allowed?(@user, :admin_project_member, project)
-
- if can_manage
- can! :update_project_member
- can! :destroy_project_member
- end
-
- if @user == target_user
- can! :destroy_project_member
- end
+ rule { can?(:admin_project_member) }.policy do
+ enable :update_project_member
+ enable :destroy_project_member
end
+
+ rule { target_is_self }.enable :destroy_project_member
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 47518dddb61..57ab0c23dcd 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -1,297 +1,363 @@
class ProjectPolicy < BasePolicy
- def rules
- team_access!(user)
+ def self.create_read_update_admin(name)
+ [
+ :"create_#{name}",
+ :"read_#{name}",
+ :"update_#{name}",
+ :"admin_#{name}"
+ ]
+ end
- owner_access! if user.admin? || owner?
- team_member_owner_access! if owner?
+ desc "User is a project owner"
+ condition :owner do
+ (project.owner.present? && project.owner == @user) ||
+ project.group&.has_owner?(@user)
+ end
- if project.public? || (project.internal? && !user.external?)
- guest_access!
- public_access!
- can! :request_access if access_requestable?
- end
+ desc "Project has public builds enabled"
+ condition(:public_builds, scope: :subject) { project.public_builds? }
- archived_access! if project.archived?
+ # For guest access we use #team_member? so we can use
+ # project.members, which gets cached in subject scope.
+ # This is safe because team_access_level is guaranteed
+ # by ProjectAuthorization's validation to be at minimum
+ # GUEST
+ desc "User has guest access"
+ condition(:guest) { team_member? }
- disabled_features!
- end
+ desc "User has reporter access"
+ condition(:reporter) { team_access_level >= Gitlab::Access::REPORTER }
- def project
- @subject
+ desc "User has developer access"
+ condition(:developer) { team_access_level >= Gitlab::Access::DEVELOPER }
+
+ desc "User has master access"
+ condition(:master) { team_access_level >= Gitlab::Access::MASTER }
+
+ desc "Project is public"
+ condition(:public_project, scope: :subject) { project.public? }
+
+ desc "Project is visible to internal users"
+ condition(:internal_access) do
+ project.internal? && !user.external?
end
- def owner?
- return @owner if defined?(@owner)
+ desc "User is a member of the group"
+ condition(:group_member, scope: :subject) { project_group_member? }
- @owner = project.owner == user ||
- (project.group && project.group.has_owner?(user))
+ desc "Project is archived"
+ condition(:archived, scope: :subject) { project.archived? }
+
+ condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? }
+
+ desc "Container registry is disabled"
+ condition(:container_registry_disabled, scope: :subject) do
+ !project.container_registry_enabled
end
- def guest_access!
- can! :read_project
- can! :read_board
- can! :read_list
- can! :read_wiki
- can! :read_issue
- can! :read_label
- can! :read_milestone
- can! :read_project_snippet
- can! :read_project_member
- can! :read_note
- can! :create_project
- can! :create_issue
- can! :create_note
- can! :upload_file
- can! :read_cycle_analytics
-
- if project.public_builds?
- can! :read_pipeline
- can! :read_pipeline_schedule
- can! :read_build
- end
+ desc "Project has an external wiki"
+ condition(:has_external_wiki, scope: :subject) { project.has_external_wiki? }
+
+ desc "Project has request access enabled"
+ condition(:request_access_enabled, scope: :subject) { project.request_access_enabled }
+
+ desc "Has merge requests allowing pushes to user"
+ condition(:has_merge_requests_allowing_pushes, scope: :subject) do
+ project.merge_requests_allowing_push_to_user(user).any?
end
- def reporter_access!
- can! :download_code
- can! :download_wiki_code
- can! :fork_project
- can! :create_project_snippet
- can! :update_issue
- can! :admin_issue
- can! :admin_label
- can! :admin_list
- can! :read_commit_status
- can! :read_build
- can! :read_container_image
- can! :read_pipeline
- can! :read_pipeline_schedule
- can! :read_environment
- can! :read_deployment
- can! :read_merge_request
+ features = %w[
+ merge_requests
+ issues
+ repository
+ snippets
+ wiki
+ builds
+ ]
+
+ features.each do |f|
+ # these are scored high because they are unlikely
+ desc "Project has #{f} disabled"
+ condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) }
end
- # Permissions given when an user is team member of a project
- def team_member_reporter_access!
- can! :build_download_code
- can! :build_read_container_image
+ rule { guest }.enable :guest_access
+ rule { reporter }.enable :reporter_access
+ rule { developer }.enable :developer_access
+ rule { master }.enable :master_access
+ rule { owner | admin }.enable :owner_access
+
+ rule { can?(:owner_access) }.policy do
+ enable :guest_access
+ enable :reporter_access
+ enable :developer_access
+ enable :master_access
+
+ enable :change_namespace
+ enable :change_visibility_level
+ enable :rename_project
+ enable :remove_project
+ enable :archive_project
+ enable :remove_fork_project
+ enable :destroy_merge_request
+ enable :destroy_issue
+ enable :remove_pages
end
- def developer_access!
- can! :admin_merge_request
- can! :update_merge_request
- can! :create_commit_status
- can! :update_commit_status
- can! :create_build
- can! :update_build
- can! :create_pipeline
- can! :update_pipeline
- can! :create_pipeline_schedule
- can! :update_pipeline_schedule
- can! :create_merge_request
- can! :create_wiki
- can! :push_code
- can! :resolve_note
- can! :create_container_image
- can! :update_container_image
- can! :create_environment
- can! :create_deployment
+ rule { can?(:guest_access) }.policy do
+ enable :read_project
+ enable :read_board
+ enable :read_list
+ enable :read_wiki
+ enable :read_issue
+ enable :read_label
+ enable :read_milestone
+ enable :read_project_snippet
+ enable :read_project_member
+ enable :read_note
+ enable :create_project
+ enable :create_issue
+ enable :create_note
+ enable :upload_file
+ enable :read_cycle_analytics
end
- def master_access!
- can! :delete_protected_branch
- can! :update_project_snippet
- can! :update_environment
- can! :update_deployment
- can! :admin_milestone
- can! :admin_project_snippet
- can! :admin_project_member
- can! :admin_note
- can! :admin_wiki
- can! :admin_project
- can! :admin_commit_status
- can! :admin_build
- can! :admin_container_image
- can! :admin_pipeline
- can! :admin_pipeline_schedule
- can! :admin_environment
- can! :admin_deployment
- can! :admin_pages
- can! :read_pages
- can! :update_pages
+ # These abilities are not allowed to admins that are not members of the project,
+ # that's why they are defined separatly.
+ rule { guest & can?(:download_code) }.enable :build_download_code
+ rule { guest & can?(:read_container_image) }.enable :build_read_container_image
+
+ rule { can?(:reporter_access) }.policy do
+ enable :download_code
+ enable :download_wiki_code
+ enable :fork_project
+ enable :create_project_snippet
+ enable :update_issue
+ enable :admin_issue
+ enable :admin_label
+ enable :admin_list
+ enable :read_commit_status
+ enable :read_build
+ enable :read_container_image
+ enable :read_pipeline
+ enable :read_pipeline_schedule
+ enable :read_environment
+ enable :read_deployment
+ enable :read_merge_request
end
- def public_access!
- can! :download_code
- can! :fork_project
- can! :read_commit_status
- can! :read_pipeline
- can! :read_pipeline_schedule
- can! :read_container_image
- can! :build_download_code
- can! :build_read_container_image
- can! :read_merge_request
+ # We define `:public_user_access` separately because there are cases in gitlab-ee
+ # where we enable or prevent it based on other coditions.
+ rule { (~anonymous & public_project) | internal_access }.policy do
+ enable :public_user_access
end
- def owner_access!
- guest_access!
- reporter_access!
- developer_access!
- master_access!
- can! :change_namespace
- can! :change_visibility_level
- can! :rename_project
- can! :remove_project
- can! :archive_project
- can! :remove_fork_project
- can! :destroy_merge_request
- can! :destroy_issue
- can! :remove_pages
+ rule { can?(:public_user_access) }.policy do
+ enable :public_access
+ enable :guest_access
+
+ enable :fork_project
+ enable :build_download_code
+ enable :build_read_container_image
+ enable :request_access
end
- def team_member_owner_access!
- team_member_reporter_access!
+ rule { owner | admin | guest | group_member }.prevent :request_access
+ rule { ~request_access_enabled }.prevent :request_access
+
+ rule { can?(:developer_access) }.policy do
+ enable :admin_merge_request
+ enable :admin_milestone
+ enable :update_merge_request
+ enable :create_commit_status
+ enable :update_commit_status
+ enable :create_build
+ enable :update_build
+ enable :create_pipeline
+ enable :update_pipeline
+ enable :create_pipeline_schedule
+ enable :create_merge_request
+ enable :create_wiki
+ enable :push_code
+ enable :resolve_note
+ enable :create_container_image
+ enable :update_container_image
+ enable :create_environment
+ enable :create_deployment
end
- # Push abilities on the users team role
- def team_access!(user)
- access = project.team.max_member_access(user.id)
+ rule { can?(:master_access) }.policy do
+ enable :delete_protected_branch
+ enable :update_project_snippet
+ enable :update_environment
+ enable :update_deployment
+ enable :admin_project_snippet
+ enable :admin_project_member
+ enable :admin_note
+ enable :admin_wiki
+ enable :admin_project
+ enable :admin_commit_status
+ enable :admin_build
+ enable :admin_container_image
+ enable :admin_pipeline
+ enable :admin_environment
+ enable :admin_deployment
+ enable :admin_pages
+ enable :read_pages
+ enable :update_pages
+ enable :read_cluster
+ enable :create_cluster
+ end
- return if access < Gitlab::Access::GUEST
- guest_access!
+ rule { archived }.policy do
+ prevent :create_merge_request
+ prevent :push_code
+ prevent :delete_protected_branch
+ prevent :update_merge_request
+ prevent :admin_merge_request
+ end
- return if access < Gitlab::Access::REPORTER
- reporter_access!
- team_member_reporter_access!
+ rule { merge_requests_disabled | repository_disabled }.policy do
+ prevent(*create_read_update_admin(:merge_request))
+ end
- return if access < Gitlab::Access::DEVELOPER
- developer_access!
+ rule { issues_disabled & merge_requests_disabled }.policy do
+ prevent(*create_read_update_admin(:label))
+ prevent(*create_read_update_admin(:milestone))
+ end
- return if access < Gitlab::Access::MASTER
- master_access!
+ rule { snippets_disabled }.policy do
+ prevent(*create_read_update_admin(:project_snippet))
end
- def archived_access!
- cannot! :create_merge_request
- cannot! :push_code
- cannot! :delete_protected_branch
- cannot! :update_merge_request
- cannot! :admin_merge_request
+ rule { wiki_disabled & ~has_external_wiki }.policy do
+ prevent(*create_read_update_admin(:wiki))
+ prevent(:download_wiki_code)
end
- def disabled_features!
- repository_enabled = project.feature_available?(:repository, user)
+ rule { builds_disabled | repository_disabled }.policy do
+ prevent(*create_read_update_admin(:build))
+ prevent(*(create_read_update_admin(:pipeline) - [:read_pipeline]))
+ prevent(*create_read_update_admin(:pipeline_schedule))
+ prevent(*create_read_update_admin(:environment))
+ prevent(*create_read_update_admin(:deployment))
+ end
- block_issues_abilities
+ rule { repository_disabled }.policy do
+ prevent :push_code
+ prevent :download_code
+ prevent :fork_project
+ prevent :read_commit_status
+ end
- unless project.feature_available?(:merge_requests, user) && repository_enabled
- cannot!(*named_abilities(:merge_request))
- end
+ rule { container_registry_disabled }.policy do
+ prevent(*create_read_update_admin(:container_image))
+ end
- unless project.feature_available?(:issues, user) || project.feature_available?(:merge_requests, user)
- cannot!(*named_abilities(:label))
- cannot!(*named_abilities(:milestone))
- end
+ rule { anonymous & ~public_project }.prevent_all
+ rule { public_project }.enable(:public_access)
+
+ rule { can?(:public_access) }.policy do
+ enable :read_project
+ enable :read_board
+ enable :read_list
+ enable :read_wiki
+ enable :read_label
+ enable :read_milestone
+ enable :read_project_snippet
+ enable :read_project_member
+ enable :read_merge_request
+ enable :read_note
+ enable :read_pipeline
+ enable :read_pipeline_schedule
+ enable :read_commit_status
+ enable :read_container_image
+ enable :download_code
+ enable :download_wiki_code
+ enable :read_cycle_analytics
- unless project.feature_available?(:snippets, user)
- cannot!(*named_abilities(:project_snippet))
- end
+ # NOTE: may be overridden by IssuePolicy
+ enable :read_issue
+ end
- unless project.feature_available?(:wiki, user) || project.has_external_wiki?
- cannot!(*named_abilities(:wiki))
- cannot!(:download_wiki_code)
- end
+ rule { public_builds }.policy do
+ enable :read_build
+ end
- unless project.feature_available?(:builds, user) && repository_enabled
- cannot!(*named_abilities(:build))
- cannot!(*named_abilities(:pipeline) - [:read_pipeline])
- cannot!(*named_abilities(:pipeline_schedule))
- cannot!(*named_abilities(:environment))
- cannot!(*named_abilities(:deployment))
- end
+ rule { public_builds & can?(:guest_access) }.policy do
+ enable :read_pipeline
+ enable :read_pipeline_schedule
+ end
- unless repository_enabled
- cannot! :push_code
- cannot! :delete_protected_branch
- cannot! :download_code
- cannot! :fork_project
- cannot! :read_commit_status
- end
+ rule { issues_disabled }.policy do
+ prevent :create_issue
+ prevent :update_issue
+ prevent :admin_issue
+ prevent :read_issue
+ end
- unless project.container_registry_enabled
- cannot!(*named_abilities(:container_image))
- end
+ # These rules are included to allow maintainers of projects to push to certain
+ # to run pipelines for the branches they have access to.
+ rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do
+ enable :create_build
+ enable :update_build
+ enable :create_pipeline
+ enable :update_pipeline
end
- def anonymous_rules
- return unless project.public?
+ private
- base_readonly_access!
+ def team_member?
+ return false if @user.nil?
- # Allow to read builds by anonymous user if guests are allowed
- can! :read_build if project.public_builds?
+ greedy_load_subject = false
- disabled_features!
- end
+ # when scoping by subject, we want to be greedy
+ # and load *all* the members with one query.
+ greedy_load_subject ||= DeclarativePolicy.preferred_scope == :subject
- def block_issues_abilities
- unless project.feature_available?(:issues, user)
- cannot! :read_issue if project.default_issues_tracker?
- cannot! :create_issue
- cannot! :update_issue
- cannot! :admin_issue
- end
- end
+ # in this case we're likely to have loaded #members already
+ # anyways, and #member? would fail with an error
+ greedy_load_subject ||= !@user.persisted?
- def named_abilities(name)
- [
- :"read_#{name}",
- :"create_#{name}",
- :"update_#{name}",
- :"admin_#{name}"
- ]
+ if greedy_load_subject
+ project.team.members.include?(user)
+ else
+ # otherwise we just make a specific query for
+ # this particular user.
+ team_access_level >= Gitlab::Access::GUEST
+ end
end
- private
+ def project_group_member?
+ return false if @user.nil?
- def project_group_member?(user)
project.group &&
(
- project.group.members_with_parents.exists?(user_id: user.id) ||
- project.group.requesters.exists?(user_id: user.id)
+ project.group.members_with_parents.exists?(user_id: @user.id) ||
+ project.group.requesters.exists?(user_id: @user.id)
)
end
- def access_requestable?
- project.request_access_enabled &&
- !owner? &&
- !user.admin? &&
- !project.team.member?(user) &&
- !project_group_member?(user)
+ def team_access_level
+ return -1 if @user.nil?
+
+ # NOTE: max_member_access has its own cache
+ project.team.max_member_access(@user.id)
end
- # A base set of abilities for read-only users, which
- # is then augmented as necessary for anonymous and other
- # read-only users.
- def base_readonly_access!
- can! :read_project
- can! :read_board
- can! :read_list
- can! :read_wiki
- can! :read_label
- can! :read_milestone
- can! :read_project_snippet
- can! :read_project_member
- can! :read_merge_request
- can! :read_note
- can! :read_pipeline
- can! :read_pipeline_schedule
- can! :read_commit_status
- can! :read_container_image
- can! :download_code
- can! :download_wiki_code
- can! :read_cycle_analytics
+ def feature_available?(feature)
+ case project.project_feature.access_level(feature)
+ when ProjectFeature::DISABLED
+ false
+ when ProjectFeature::PRIVATE
+ guest? || admin?
+ else
+ true
+ end
+ end
- # NOTE: may be overridden by IssuePolicy
- can! :read_issue
+ def project
+ @subject
end
end
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index bc5c4f32f79..dd270643bbf 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -1,25 +1,45 @@
class ProjectSnippetPolicy < BasePolicy
- def rules
- # We have to check both project feature visibility and a snippet visibility and take the stricter one
- # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573
- return unless @subject.project.feature_available?(:snippets, @user)
- return unless Ability.allowed?(@user, :read_project, @subject.project)
-
- can! :read_project_snippet if @subject.public?
- return unless @user
-
- if @user && (@subject.author == @user || @user.admin?)
- can! :read_project_snippet
- can! :update_project_snippet
- can! :admin_project_snippet
- end
-
- if @subject.internal? && !@user.external?
- can! :read_project_snippet
- end
-
- if @subject.project.team.member?(@user)
- can! :read_project_snippet
- end
+ delegate :project
+
+ desc "Snippet is public"
+ condition(:public_snippet, scope: :subject) { @subject.public? }
+ condition(:private_snippet, scope: :subject) { @subject.private? }
+ condition(:public_project, scope: :subject) { @subject.project.public? }
+
+ condition(:is_author) { @user && @subject.author == @user }
+
+ condition(:internal, scope: :subject) { @subject.internal? }
+
+ # We have to check both project feature visibility and a snippet visibility and take the stricter one
+ # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573
+ rule { ~can?(:read_project) }.policy do
+ prevent :read_project_snippet
+ prevent :update_project_snippet
+ prevent :admin_project_snippet
+ end
+
+ # we have to use this complicated prevent because the delegated project policy
+ # is overly greedy in allowing :read_project_snippet, since it doesn't have any
+ # information about the snippet. However, :read_project_snippet on the *project*
+ # is used to hide/show various snippet-related controls, so we can't just move
+ # all of the handling here.
+ rule do
+ all?(private_snippet | (internal & external_user),
+ ~project.guest,
+ ~admin,
+ ~is_author)
+ end.prevent :read_project_snippet
+
+ rule { internal & ~is_author & ~admin }.policy do
+ prevent :update_project_snippet
+ prevent :admin_project_snippet
+ end
+
+ rule { public_snippet }.enable :read_project_snippet
+
+ rule { is_author | admin }.policy do
+ enable :read_project_snippet
+ enable :update_project_snippet
+ enable :admin_project_snippet
end
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 229846e368c..0905ddd9b38 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -1,19 +1,13 @@
class UserPolicy < BasePolicy
- include Gitlab::CurrentSettings
+ desc "The current user is the user in question"
+ condition(:user_is_self, score: 0) { @subject == @user }
- def rules
- can! :read_user if @user || !restricted_public_level?
+ desc "This is the ghost user"
+ condition(:subject_ghost, scope: :subject, score: 0) { @subject.ghost? }
- if @user
- if @user.admin? || @subject == @user
- can! :destroy_user
- end
+ rule { ~restricted_public_level }.enable :read_user
+ rule { ~anonymous }.enable :read_user
- cannot! :destroy_user if @subject.ghost?
- end
- end
-
- def restricted_public_level?
- current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
- end
+ rule { user_is_self | admin }.enable :destroy_user
+ rule { subject_ghost }.prevent :destroy_user
end
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index c495c3f39bb..255475e1fe6 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -17,5 +17,16 @@ module Ci
"Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
end
end
+
+ def trigger_variables
+ return [] unless trigger_request
+
+ @trigger_variables ||=
+ if pipeline.variables.any?
+ pipeline.variables.map(&:to_runner_variable)
+ else
+ trigger_request.user_variables
+ end
+ end
end
end
diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb
new file mode 100644
index 00000000000..98d68bc7a83
--- /dev/null
+++ b/app/presenters/ci/group_variable_presenter.rb
@@ -0,0 +1,21 @@
+module Ci
+ class GroupVariablePresenter < Gitlab::View::Presenter::Delegated
+ presents :variable
+
+ def placeholder
+ 'GROUP_VARIABLE'
+ end
+
+ def form_path
+ group_settings_ci_cd_path(group)
+ end
+
+ def edit_path
+ group_variables_path(group)
+ end
+
+ def delete_path
+ group_variables_path(group)
+ end
+ end
+end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index a542bdd8295..099b4720fb6 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -1,7 +1,18 @@
module Ci
class PipelinePresenter < Gitlab::View::Presenter::Delegated
+ FAILURE_REASONS = {
+ config_error: 'CI/CD YAML configuration error!'
+ }.freeze
+
presents :pipeline
+ def failure_reason
+ return unless pipeline.failure_reason?
+
+ FAILURE_REASONS[pipeline.failure_reason.to_sym] ||
+ pipeline.failure_reason
+ end
+
def status_title
if auto_canceled?
"Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb
new file mode 100644
index 00000000000..96159f88c59
--- /dev/null
+++ b/app/presenters/ci/variable_presenter.rb
@@ -0,0 +1,21 @@
+module Ci
+ class VariablePresenter < Gitlab::View::Presenter::Delegated
+ presents :variable
+
+ def placeholder
+ 'PROJECT_VARIABLE'
+ end
+
+ def form_path
+ project_settings_ci_cd_path(project)
+ end
+
+ def edit_path
+ project_variables_path(project)
+ end
+
+ def delete_path
+ project_variables_path(project)
+ end
+ end
+end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
new file mode 100644
index 00000000000..a424da5ab24
--- /dev/null
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -0,0 +1,13 @@
+module Clusters
+ class ClusterPresenter < Gitlab::View::Presenter::Delegated
+ presents :cluster
+
+ def gke_cluster_url
+ "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
+ end
+
+ def can_toggle_cluster?
+ can?(current_user, :update_cluster, cluster) && created?
+ end
+ end
+end
diff --git a/app/presenters/group_member_presenter.rb b/app/presenters/group_member_presenter.rb
new file mode 100644
index 00000000000..8f53dfa105e
--- /dev/null
+++ b/app/presenters/group_member_presenter.rb
@@ -0,0 +1,15 @@
+class GroupMemberPresenter < MemberPresenter
+ private
+
+ def admin_member_permission
+ :admin_group_member
+ end
+
+ def update_member_permission
+ :update_group_member
+ end
+
+ def destroy_member_permission
+ :destroy_group_member
+ end
+end
diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb
new file mode 100644
index 00000000000..7d2f9303b8f
--- /dev/null
+++ b/app/presenters/member_presenter.rb
@@ -0,0 +1,38 @@
+class MemberPresenter < Gitlab::View::Presenter::Delegated
+ presents :member
+
+ def access_level_roles
+ member.class.access_level_roles
+ end
+
+ def can_resend_invite?
+ invite? &&
+ can?(current_user, admin_member_permission, source)
+ end
+
+ def can_update?
+ can?(current_user, update_member_permission, member)
+ end
+
+ def can_remove?
+ can?(current_user, destroy_member_permission, member)
+ end
+
+ def can_approve?
+ request? && can_update?
+ end
+
+ private
+
+ def admin_member_permission
+ raise NotImplementedError
+ end
+
+ def update_member_permission
+ raise NotImplementedError
+ end
+
+ def destroy_member_permission
+ raise NotImplementedError
+ end
+end
diff --git a/app/presenters/members_presenter.rb b/app/presenters/members_presenter.rb
new file mode 100644
index 00000000000..e4aba37b69e
--- /dev/null
+++ b/app/presenters/members_presenter.rb
@@ -0,0 +1,15 @@
+class MembersPresenter < Gitlab::View::Presenter::Delegated
+ include Enumerable
+
+ presents :members
+
+ def to_ary
+ to_a
+ end
+
+ def each
+ members.each do |member|
+ yield member.present(current_user: current_user)
+ end
+ end
+end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 8bf35953d29..9f3f2637183 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -3,6 +3,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
include GitlabRoutingHelper
include MarkupHelper
include TreeHelper
+ include Gitlab::Utils::StrongMemoize
presents :merge_request
@@ -20,42 +21,37 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def cancel_merge_when_pipeline_succeeds_path
if can_cancel_merge_when_pipeline_succeeds?(current_user)
- cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(
- project.namespace,
- project,
- merge_request)
+ cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request)
end
end
def create_issue_to_resolve_discussions_path
if can?(current_user, :create_issue, project) && project.issues_enabled?
- new_namespace_project_issue_path(project.namespace,
- project,
- merge_request_to_resolve_discussions_of: iid)
+ new_project_issue_path(project, merge_request_to_resolve_discussions_of: iid)
end
end
def remove_wip_path
- if can?(current_user, :update_merge_request, merge_request.project)
- remove_wip_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ if work_in_progress? && can?(current_user, :update_merge_request, merge_request.project)
+ remove_wip_project_merge_request_path(project, merge_request)
end
end
def merge_path
if can_be_merged_by?(current_user)
- merge_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ merge_project_merge_request_path(project, merge_request)
end
end
def revert_in_fork_path
- if user_can_fork_project? && can_be_reverted?(current_user)
+ if user_can_fork_project? && cached_can_be_reverted?
continue_params = {
to: merge_request_path(merge_request),
notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.",
notice_now: edit_in_new_fork_notice_now
}
- namespace_project_forks_path(merge_request.project.namespace, merge_request.project,
+ project_forks_path(merge_request.project,
namespace_key: current_user.namespace.id,
continue: continue_params)
end
@@ -69,7 +65,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
notice_now: edit_in_new_fork_notice_now
}
- namespace_project_forks_path(project.namespace, project,
+ project_forks_path(project,
namespace_key: current_user.namespace.id,
continue: continue_params)
end
@@ -77,19 +73,31 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def conflict_resolution_path
if conflicts.can_be_resolved_in_ui? && conflicts.can_be_resolved_by?(current_user)
- conflicts_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ conflicts_project_merge_request_path(project, merge_request)
+ end
+ end
+
+ def rebase_path
+ if !rebase_in_progress? && should_be_rebased? && can_push_to_source_branch?
+ rebase_project_merge_request_path(project, merge_request)
+ end
+ end
+
+ def target_branch_tree_path
+ if target_branch_exists?
+ project_tree_path(project, target_branch)
end
end
def target_branch_commits_path
if target_branch_exists?
- namespace_project_commits_path(project.namespace, project, target_branch)
+ project_commits_path(project, target_branch)
end
end
def source_branch_path
if source_branch_exists?
- namespace_project_branch_path(source_project.namespace, source_project, source_branch)
+ project_branch_path(source_project, source_branch)
end
end
@@ -99,7 +107,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
if source_branch_exists?
namespace = link_to(namespace, project_path(source_project))
- branch = link_to(branch, namespace_project_commits_path(source_project.namespace, source_project, source_branch))
+ branch = link_to(branch, project_tree_path(source_project, source_branch))
end
if for_fork?
@@ -136,7 +144,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
merge_request: merge_request,
closes_issues: closing_issues
).assignable_issues
- path = assign_related_issues_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ path = assign_related_issues_project_merge_request_path(project, merge_request)
if issues.present?
pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue"
link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post
@@ -144,15 +152,29 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def can_revert_on_current_merge_request?
- user_can_collaborate_with_project? && can_be_reverted?(current_user)
+ user_can_collaborate_with_project? && cached_can_be_reverted?
end
def can_cherry_pick_on_current_merge_request?
user_can_collaborate_with_project? && can_be_cherry_picked?
end
+ def can_push_to_source_branch?
+ return false unless source_branch_exists?
+
+ !!::Gitlab::UserAccess
+ .new(current_user, project: source_project)
+ .can_push_to_branch?(source_branch)
+ end
+
private
+ def cached_can_be_reverted?
+ strong_memoize(:can_be_reverted) do
+ can_be_reverted?(current_user)
+ end
+ end
+
def conflicts
@conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request)
end
@@ -162,7 +184,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def pipeline
- @pipeline ||= head_pipeline
+ @pipeline ||= actual_head_pipeline
end
def issues_sentence(project, issues)
@@ -175,7 +197,8 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def user_can_collaborate_with_project?
can?(current_user, :push_code, project) ||
- (current_user && current_user.already_forked?(project))
+ (current_user && current_user.already_forked?(project)) ||
+ can_push_to_source_branch?
end
def user_can_fork_project?
diff --git a/app/presenters/project_member_presenter.rb b/app/presenters/project_member_presenter.rb
new file mode 100644
index 00000000000..7f42d2b70df
--- /dev/null
+++ b/app/presenters/project_member_presenter.rb
@@ -0,0 +1,15 @@
+class ProjectMemberPresenter < MemberPresenter
+ private
+
+ def admin_member_permission
+ :admin_project_member
+ end
+
+ def update_member_permission
+ :update_project_member
+ end
+
+ def destroy_member_permission
+ :destroy_project_member
+ end
+end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
new file mode 100644
index 00000000000..484ac64580d
--- /dev/null
+++ b/app/presenters/project_presenter.rb
@@ -0,0 +1,338 @@
+class ProjectPresenter < Gitlab::View::Presenter::Delegated
+ include ActionView::Helpers::NumberHelper
+ include ActionView::Helpers::UrlHelper
+ include GitlabRoutingHelper
+ include StorageHelper
+ include TreeHelper
+ include Gitlab::Utils::StrongMemoize
+
+ presents :project
+
+ def statistics_anchors(show_auto_devops_callout:)
+ [
+ files_anchor_data,
+ commits_anchor_data,
+ branches_anchor_data,
+ tags_anchor_data,
+ readme_anchor_data,
+ changelog_anchor_data,
+ license_anchor_data,
+ contribution_guide_anchor_data,
+ gitlab_ci_anchor_data,
+ autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
+ kubernetes_cluster_anchor_data
+ ].compact.select { |item| item.enabled }
+ end
+
+ def statistics_buttons(show_auto_devops_callout:)
+ [
+ changelog_anchor_data,
+ license_anchor_data,
+ contribution_guide_anchor_data,
+ autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
+ kubernetes_cluster_anchor_data,
+ gitlab_ci_anchor_data,
+ koding_anchor_data
+ ].compact.reject { |item| item.enabled }
+ end
+
+ def empty_repo_statistics_anchors
+ [
+ autodevops_anchor_data,
+ kubernetes_cluster_anchor_data
+ ].compact.select { |item| item.enabled }
+ end
+
+ def empty_repo_statistics_buttons
+ [
+ new_file_anchor_data,
+ readme_anchor_data,
+ license_anchor_data,
+ autodevops_anchor_data,
+ kubernetes_cluster_anchor_data
+ ].compact.reject { |item| item.enabled }
+ end
+
+ def default_view
+ return anonymous_project_view unless current_user
+
+ user_view = current_user.project_view
+
+ if can?(current_user, :download_code, project)
+ user_view
+ elsif user_view == "activity"
+ "activity"
+ elsif can?(current_user, :read_wiki, project)
+ "wiki"
+ elsif feature_available?(:issues, current_user)
+ "projects/issues/issues"
+ else
+ "customize_workflow"
+ end
+ end
+
+ def readme_path
+ filename_path(:readme)
+ end
+
+ def changelog_path
+ filename_path(:changelog)
+ end
+
+ def license_path
+ filename_path(:license_blob)
+ end
+
+ def ci_configuration_path
+ filename_path(:gitlab_ci_yml)
+ end
+
+ def contribution_guide_path
+ if project && contribution_guide = repository.contribution_guide
+ project_blob_path(
+ project,
+ tree_join(project.default_branch,
+ contribution_guide.name)
+ )
+ end
+ end
+
+ def add_license_path
+ add_special_file_path(file_name: 'LICENSE')
+ end
+
+ def add_changelog_path
+ add_special_file_path(file_name: 'CHANGELOG')
+ end
+
+ def add_contribution_guide_path
+ add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide')
+ end
+
+ def add_ci_yml_path
+ add_special_file_path(file_name: '.gitlab-ci.yml')
+ end
+
+ def add_readme_path
+ add_special_file_path(file_name: 'README.md')
+ end
+
+ def add_koding_stack_path
+ project_new_blob_path(
+ project,
+ default_branch || 'master',
+ file_name: '.koding.yml',
+ commit_message: "Add Koding stack script",
+ content: <<-CONTENT.strip_heredoc
+ provider:
+ aws:
+ access_key: '${var.aws_access_key}'
+ secret_key: '${var.aws_secret_key}'
+ resource:
+ aws_instance:
+ #{project.path}-vm:
+ instance_type: t2.nano
+ user_data: |-
+
+ # Created by GitLab UI for :>
+
+ echo _KD_NOTIFY_@Installing Base packages...@
+
+ apt-get update -y
+ apt-get install git -y
+
+ echo _KD_NOTIFY_@Cloning #{project.name}...@
+
+ export KODING_USER=${var.koding_user_username}
+ export REPO_URL=#{root_url}${var.koding_queryString_repo}.git
+ export BRANCH=${var.koding_queryString_branch}
+
+ sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH
+
+ echo _KD_NOTIFY_@#{project.name} cloned.@
+ CONTENT
+ )
+ end
+
+ def license_short_name
+ license = repository.license
+ license&.nickname || license&.name || 'LICENSE'
+ end
+
+ def can_current_user_push_code?
+ strong_memoize(:can_current_user_push_code) do
+ if empty_repo?
+ can?(current_user, :push_code, project)
+ else
+ can_current_user_push_to_branch?(default_branch)
+ end
+ end
+ end
+
+ def can_current_user_push_to_branch?(branch)
+ return false unless repository.branch_exists?(branch)
+
+ ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch)
+ end
+
+ def files_anchor_data
+ OpenStruct.new(enabled: true,
+ label: _('Files (%{human_size})') % { human_size: storage_counter(statistics.total_repository_size) },
+ link: project_tree_path(project))
+ end
+
+ def commits_anchor_data
+ OpenStruct.new(enabled: true,
+ label: n_('Commit (%{commit_count})', 'Commits (%{commit_count})', statistics.commit_count) % { commit_count: number_with_delimiter(statistics.commit_count) },
+ link: project_commits_path(project, repository.root_ref))
+ end
+
+ def branches_anchor_data
+ OpenStruct.new(enabled: true,
+ label: n_('Branch (%{branch_count})', 'Branches (%{branch_count})', repository.branch_count) % { branch_count: number_with_delimiter(repository.branch_count) },
+ link: project_branches_path(project))
+ end
+
+ def tags_anchor_data
+ OpenStruct.new(enabled: true,
+ label: n_('Tag (%{tag_count})', 'Tags (%{tag_count})', repository.tag_count) % { tag_count: number_with_delimiter(repository.tag_count) },
+ link: project_tags_path(project))
+ end
+
+ def new_file_anchor_data
+ if current_user && can_current_user_push_code?
+ OpenStruct.new(enabled: false,
+ label: _('New file'),
+ link: project_new_blob_path(project, default_branch || 'master'),
+ class_modifier: 'new')
+ end
+ end
+
+ def readme_anchor_data
+ if current_user && can_current_user_push_code? && repository.readme.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add Readme'),
+ link: add_readme_path)
+ elsif repository.readme.present?
+ OpenStruct.new(enabled: true,
+ label: _('Readme'),
+ link: default_view != 'readme' ? readme_path : '#readme')
+ end
+ end
+
+ def changelog_anchor_data
+ if current_user && can_current_user_push_code? && repository.changelog.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add Changelog'),
+ link: add_changelog_path)
+ elsif repository.changelog.present?
+ OpenStruct.new(enabled: true,
+ label: _('Changelog'),
+ link: changelog_path)
+ end
+ end
+
+ def license_anchor_data
+ if current_user && can_current_user_push_code? && repository.license_blob.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add License'),
+ link: add_license_path)
+ elsif repository.license_blob.present?
+ OpenStruct.new(enabled: true,
+ label: license_short_name,
+ link: license_path)
+ end
+ end
+
+ def contribution_guide_anchor_data
+ if current_user && can_current_user_push_code? && repository.contribution_guide.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add Contribution guide'),
+ link: add_contribution_guide_path)
+ elsif repository.contribution_guide.present?
+ OpenStruct.new(enabled: true,
+ label: _('Contribution guide'),
+ link: contribution_guide_path)
+ end
+ end
+
+ def autodevops_anchor_data(show_auto_devops_callout: false)
+ if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
+ OpenStruct.new(enabled: auto_devops_enabled?,
+ label: auto_devops_enabled? ? _('Auto DevOps enabled') : _('Enable Auto DevOps'),
+ link: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings'))
+ elsif auto_devops_enabled?
+ OpenStruct.new(enabled: true,
+ label: _('Auto DevOps enabled'),
+ link: nil)
+ end
+ end
+
+ def kubernetes_cluster_anchor_data
+ if current_user && can?(current_user, :create_cluster, project)
+ cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project)
+
+ if clusters.empty?
+ cluster_link = new_project_cluster_path(project)
+ end
+
+ OpenStruct.new(enabled: !clusters.empty?,
+ label: clusters.empty? ? _('Add Kubernetes cluster') : _('Kubernetes configured'),
+ link: cluster_link)
+ end
+ end
+
+ def gitlab_ci_anchor_data
+ if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled?
+ OpenStruct.new(enabled: false,
+ label: _('Set up CI/CD'),
+ link: add_ci_yml_path)
+ elsif repository.gitlab_ci_yml.present?
+ OpenStruct.new(enabled: true,
+ label: _('CI/CD configuration'),
+ link: ci_configuration_path)
+ end
+ end
+
+ def koding_anchor_data
+ if current_user && can_current_user_push_code? && koding_enabled? && repository.koding_yml.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Set up Koding'),
+ link: add_koding_stack_path)
+ end
+ end
+
+ private
+
+ def filename_path(filename)
+ if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
+ project_blob_path(
+ project,
+ tree_join(default_branch, blob.name)
+ )
+ end
+ end
+
+ def anonymous_project_view
+ if !project.empty_repo? && can?(current_user, :download_code, project)
+ 'files'
+ else
+ 'activity'
+ end
+ end
+
+ def add_special_file_path(file_name:, commit_message: nil, branch_name: nil)
+ commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name }
+ project_new_blob_path(
+ project,
+ project.default_branch || 'master',
+ file_name: file_name,
+ commit_message: commit_message,
+ branch_name: branch_name
+ )
+ end
+
+ def koding_enabled?
+ Gitlab::CurrentSettings.koding_enabled?
+ end
+end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 229311eb6ee..c226586fba5 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -7,7 +7,7 @@ module Projects
delegate :size, to: :available_public_keys, prefix: true
def new_key
- @key ||= DeployKey.new
+ @key ||= DeployKey.new.tap { |dk| dk.deploy_keys_projects.build }
end
def enabled_keys
diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb
index ad7ad020b03..bdc22d71202 100644
--- a/app/serializers/analytics_build_entity.rb
+++ b/app/serializers/analytics_build_entity.rb
@@ -35,6 +35,6 @@ class AnalyticsBuildEntity < Grape::Entity
private
def url_to(route, build, id = nil)
- public_send("#{route}_url", build.project.namespace, build.project, id || build)
+ public_send("#{route}_url", build.project.namespace, build.project, id || build) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/serializers/analytics_issue_entity.rb b/app/serializers/analytics_issue_entity.rb
index 44c50f18613..b7d95ea020f 100644
--- a/app/serializers/analytics_issue_entity.rb
+++ b/app/serializers/analytics_issue_entity.rb
@@ -24,6 +24,6 @@ class AnalyticsIssueEntity < Grape::Entity
private
def url_to(route, id)
- public_send("#{route}_url", request.project.namespace, request.project, id)
+ public_send("#{route}_url", request.project.namespace, request.project, id) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
index 564612202b5..3e355a13e06 100644
--- a/app/serializers/analytics_stage_entity.rb
+++ b/app/serializers/analytics_stage_entity.rb
@@ -7,6 +7,7 @@ class AnalyticsStageEntity < Grape::Entity
expose :description
expose :median, as: :value do |stage|
- stage.median && !stage.median.zero? ? distance_of_time_in_words(stage.median) : nil
+ # median returns a BatchLoader instance which we first have to unwrap by using to_i
+ !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil
end
end
diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb
new file mode 100644
index 00000000000..6e03cd02392
--- /dev/null
+++ b/app/serializers/award_emoji_entity.rb
@@ -0,0 +1,4 @@
+class AwardEmojiEntity < Grape::Entity
+ expose :name
+ expose :user, using: API::Entities::UserSafe
+end
diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb
index 4e6c15f673b..8cade280b0c 100644
--- a/app/serializers/base_serializer.rb
+++ b/app/serializers/base_serializer.rb
@@ -1,6 +1,9 @@
class BaseSerializer
- def initialize(parameters = {})
- @request = EntityRequest.new(parameters)
+ attr_reader :params
+
+ def initialize(params = {})
+ @params = params
+ @request = EntityRequest.new(params)
end
def represent(resource, opts = {}, entity_class = nil)
diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb
new file mode 100644
index 00000000000..ad039a2623d
--- /dev/null
+++ b/app/serializers/blob_entity.rb
@@ -0,0 +1,13 @@
+class BlobEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :name, :mode
+
+ expose :icon do |blob|
+ IconsHelper.file_type_icon_class('file', blob.mode, blob.name)
+ end
+
+ expose :url do |blob|
+ project_blob_path(request.project, File.join(request.ref, blob.path))
+ end
+end
diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb
index 301b718d060..f2d76a8ad81 100644
--- a/app/serializers/build_action_entity.rb
+++ b/app/serializers/build_action_entity.rb
@@ -6,10 +6,7 @@ class BuildActionEntity < Grape::Entity
end
expose :path do |build|
- play_namespace_project_job_path(
- build.project.namespace,
- build.project,
- build)
+ play_project_job_path(build.project, build)
end
expose :playable?, as: :playable
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index cb55c98f7c6..6e0e33bc09b 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -9,24 +9,15 @@ class BuildArtifactEntity < Grape::Entity
expose :artifacts_expire_at, as: :expire_at
expose :path do |job|
- download_namespace_project_job_artifacts_path(
- project.namespace,
- project,
- job)
+ download_project_job_artifacts_path(project, job)
end
expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
- keep_namespace_project_job_artifacts_path(
- project.namespace,
- project,
- job)
+ keep_project_job_artifacts_path(project, job)
end
expose :browse_path do |job|
- browse_namespace_project_job_artifacts_path(
- project.namespace,
- project,
- job)
+ browse_project_job_artifacts_path(project, job)
end
private
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index eeb5399aa8b..69d46f5ec14 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -6,8 +6,8 @@ class BuildDetailsEntity < JobEntity
expose :pipeline, using: PipelineEntity
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)
+ expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build|
+ erase_project_job_path(project, build)
end
expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do
@@ -16,23 +16,24 @@ class BuildDetailsEntity < JobEntity
end
expose :path do |build|
- namespace_project_merge_request_path(project.namespace, project, build.merge_request)
+ project_merge_request_path(build.merge_request.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)
+ new_project_issue_path(project, issue: build_failed_issue_options)
end
expose :raw_path do |build|
- raw_namespace_project_job_path(project.namespace, project, build)
+ raw_project_job_path(project, build)
end
private
def build_failed_issue_options
- { title: "Build Failed ##{build.id}",
- description: namespace_project_job_path(project.namespace, project, build) }
+ { title: "Job Failed ##{build.id}",
+ description: "Job [##{build.id}](#{project_job_path(project, build)}) failed for #{build.sha}:\n" }
end
def current_user
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
new file mode 100644
index 00000000000..b22a0b666ef
--- /dev/null
+++ b/app/serializers/cluster_application_entity.rb
@@ -0,0 +1,6 @@
+class ClusterApplicationEntity < Grape::Entity
+ expose :name
+ expose :status_name, as: :status
+ expose :status_reason
+ expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
+end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
new file mode 100644
index 00000000000..7e5b0997878
--- /dev/null
+++ b/app/serializers/cluster_entity.rb
@@ -0,0 +1,7 @@
+class ClusterEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :status_name, as: :status
+ expose :status_reason
+ expose :applications, using: ClusterApplicationEntity
+end
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
new file mode 100644
index 00000000000..2e13c1501e7
--- /dev/null
+++ b/app/serializers/cluster_serializer.rb
@@ -0,0 +1,7 @@
+class ClusterSerializer < BaseSerializer
+ entity ClusterEntity
+
+ def represent_status(resource)
+ represent(resource, { only: [:status, :status_reason, :applications] })
+ end
+end
diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb
index 31763955f97..c8dd98cc04d 100644
--- a/app/serializers/commit_entity.rb
+++ b/app/serializers/commit_entity.rb
@@ -1,4 +1,4 @@
-class CommitEntity < API::Entities::RepoCommit
+class CommitEntity < API::Entities::Commit
include RequestAwareEntity
expose :author, using: UserEntity
@@ -8,16 +8,10 @@ class CommitEntity < API::Entities::RepoCommit
end
expose :commit_url do |commit|
- namespace_project_commit_url(
- request.project.namespace,
- request.project,
- commit)
+ project_commit_url(request.project, commit)
end
expose :commit_path do |commit|
- namespace_project_commit_path(
- request.project.namespace,
- request.project,
- commit)
+ project_commit_path(request.project, commit)
end
end
diff --git a/app/serializers/concerns/with_pagination.rb b/app/serializers/concerns/with_pagination.rb
new file mode 100644
index 00000000000..89631b73fcf
--- /dev/null
+++ b/app/serializers/concerns/with_pagination.rb
@@ -0,0 +1,22 @@
+module WithPagination
+ attr_accessor :paginator
+
+ def with_pagination(request, response)
+ tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) }
+ end
+
+ def paginated?
+ paginator.present?
+ end
+
+ # super is `BaseSerializer#represent` here.
+ #
+ # we shouldn't try to paginate single resources
+ def represent(resource, opts = {})
+ if paginated? && resource.respond_to?(:page)
+ super(paginator.paginate(resource), opts)
+ else
+ super(resource, opts)
+ end
+ end
+end
diff --git a/app/serializers/container_repositories_serializer.rb b/app/serializers/container_repositories_serializer.rb
new file mode 100644
index 00000000000..56dc70b5687
--- /dev/null
+++ b/app/serializers/container_repositories_serializer.rb
@@ -0,0 +1,3 @@
+class ContainerRepositoriesSerializer < BaseSerializer
+ entity ContainerRepositoryEntity
+end
diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb
new file mode 100644
index 00000000000..1103cf30a07
--- /dev/null
+++ b/app/serializers/container_repository_entity.rb
@@ -0,0 +1,25 @@
+class ContainerRepositoryEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :location
+
+ expose :tags_path do |repository|
+ project_registry_repository_tags_path(project, repository, format: :json)
+ end
+
+ expose :destroy_path, if: -> (*) { can_destroy? } do |repository|
+ project_container_registry_path(project, repository, format: :json)
+ end
+
+ private
+
+ alias_method :repository, :object
+
+ def project
+ request.project
+ end
+
+ def can_destroy?
+ can?(request.current_user, :update_container_image, project)
+ end
+end
diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb
new file mode 100644
index 00000000000..8f1488e6cbb
--- /dev/null
+++ b/app/serializers/container_tag_entity.rb
@@ -0,0 +1,23 @@
+class ContainerTagEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :name, :location, :revision, :short_revision, :total_size, :created_at
+
+ expose :destroy_path, if: -> (*) { can_destroy? } do |tag|
+ project_registry_repository_tag_path(project, tag.repository, tag.name)
+ end
+
+ private
+
+ alias_method :tag, :object
+
+ def project
+ request.project
+ end
+
+ def can_destroy?
+ # TODO: We check permission against @project, not tag,
+ # as tag is no AR object that is attached to project
+ can?(request.current_user, :update_container_image, project)
+ end
+end
diff --git a/app/serializers/container_tags_serializer.rb b/app/serializers/container_tags_serializer.rb
new file mode 100644
index 00000000000..6ff3adff135
--- /dev/null
+++ b/app/serializers/container_tags_serializer.rb
@@ -0,0 +1,17 @@
+class ContainerTagsSerializer < BaseSerializer
+ entity ContainerTagEntity
+
+ def with_pagination(request, response)
+ tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
+ end
+
+ def paginated?
+ @paginator.present?
+ end
+
+ def represent(resource, opts = {})
+ resource = @paginator.paginate(resource) if paginated?
+
+ super(resource, opts)
+ end
+end
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index 068013c8829..2678f99510c 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -3,19 +3,20 @@ class DeployKeyEntity < Grape::Entity
expose :user_id
expose :title
expose :fingerprint
- expose :can_push
expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
expose :almost_orphaned?, as: :almost_orphaned
expose :created_at
expose :updated_at
- expose :projects, using: ProjectEntity do |deploy_key|
- deploy_key.projects.select { |project| options[:user].can?(:read_project, project) }
+ expose :deploy_keys_projects, using: DeployKeysProjectEntity do |deploy_key|
+ deploy_key.deploy_keys_projects
+ .without_project_deleted
+ .select { |deploy_key_project| Ability.allowed?(options[:user], :read_project, deploy_key_project.project) }
end
expose :can_edit
private
def can_edit
- options[:user].can?(:update_deploy_key, object)
+ Ability.allowed?(options[:user], :update_deploy_key, object)
end
end
diff --git a/app/serializers/deploy_keys_project_entity.rb b/app/serializers/deploy_keys_project_entity.rb
new file mode 100644
index 00000000000..568ef5ab75e
--- /dev/null
+++ b/app/serializers/deploy_keys_project_entity.rb
@@ -0,0 +1,4 @@
+class DeployKeysProjectEntity < Grape::Entity
+ expose :can_push
+ expose :project, using: ProjectEntity
+end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index e493c9162fd..241c689bccd 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -11,10 +11,7 @@ class DeploymentEntity < Grape::Entity
end
expose :ref_path do |deployment|
- namespace_project_tree_path(
- deployment.project.namespace,
- deployment.project,
- id: deployment.ref)
+ project_tree_path(deployment.project, id: deployment.ref)
end
end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
new file mode 100644
index 00000000000..6e68d275047
--- /dev/null
+++ b/app/serializers/diff_file_entity.rb
@@ -0,0 +1,41 @@
+class DiffFileEntity < Grape::Entity
+ include DiffHelper
+ include SubmoduleHelper
+ include BlobHelper
+ include IconsHelper
+ include ActionView::Helpers::TagHelper
+
+ expose :submodule?, as: :submodule
+
+ expose :submodule_link do |diff_file|
+ submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first
+ end
+
+ expose :blob_path do |diff_file|
+ diff_file.blob.path
+ end
+
+ expose :blob_icon do |diff_file|
+ blob_icon(diff_file.b_mode, diff_file.file_path)
+ end
+
+ expose :file_path
+ expose :deleted_file?, as: :deleted_file
+ expose :renamed_file?, as: :renamed_file
+ expose :old_path
+ expose :new_path
+ expose :mode_changed?, as: :mode_changed
+ expose :a_mode
+ expose :b_mode
+ expose :text?, as: :text
+
+ expose :old_path_html do |diff_file|
+ old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
+ old_path
+ end
+
+ expose :new_path_html do |diff_file|
+ _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
+ new_path
+ end
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
new file mode 100644
index 00000000000..bbbcf6a97c1
--- /dev/null
+++ b/app/serializers/discussion_entity.rb
@@ -0,0 +1,48 @@
+class DiscussionEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :reply_id
+ expose :expanded?, as: :expanded
+
+ expose :notes, using: NoteEntity
+
+ expose :individual_note?, as: :individual_note
+ expose :resolvable?, as: :resolvable
+ expose :resolved?, as: :resolved
+ expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
+ resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
+ end
+ expose :resolve_with_issue_path do |discussion|
+ new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
+ end
+
+ expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file }
+
+ expose :diff_discussion?, as: :diff_discussion
+
+ expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion|
+ options[:context].render_to_string(
+ partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: discussion.diff_file,
+ discussion_expanded: true,
+ plain: true },
+ layout: false,
+ formats: [:html]
+ )
+ end
+
+ expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion|
+ diff_file = discussion.diff_file
+ partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
+ options[:context].render_to_string(
+ partial: "projects/diffs/#{partial}",
+ locals: { diff_file: diff_file,
+ position: discussion.position.to_json,
+ click_to_comment: false },
+ layout: false,
+ formats: [:html]
+ )
+ end
+end
diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb
new file mode 100644
index 00000000000..ed5e1224bb2
--- /dev/null
+++ b/app/serializers/discussion_serializer.rb
@@ -0,0 +1,3 @@
+class DiscussionSerializer < BaseSerializer
+ entity DiscussionEntity
+end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 4e8a3c67b21..ba0ae6ba8a0 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -10,32 +10,24 @@ class EnvironmentEntity < Grape::Entity
expose :stop_action?
expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment|
- metrics_namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ metrics_project_environment_path(environment.project, environment)
end
expose :environment_path do |environment|
- namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ project_environment_path(environment.project, environment)
end
expose :stop_path do |environment|
- stop_namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ stop_project_environment_path(environment.project, environment)
end
expose :terminal_path, if: ->(environment, _) { environment.has_terminals? } do |environment|
can?(request.current_user, :admin_environment, environment.project) &&
- terminal_namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ terminal_project_environment_path(environment.project, environment)
+ end
+
+ expose :folder_path do |environment|
+ folder_project_environments_path(environment.project, environment.folder_name)
end
expose :created_at, :updated_at
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index d0a60f134da..84722f33f59 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -1,4 +1,6 @@
class EnvironmentSerializer < BaseSerializer
+ include WithPagination
+
Item = Struct.new(:name, :size, :latest)
entity EnvironmentEntity
@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
tap { @itemize = true }
end
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
-
def itemized?
@itemize
end
- def paginated?
- @paginator.present?
- end
-
def represent(resource, opts = {})
if itemized?
itemize(resource).map do |item|
@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) }
end
else
- resource = @paginator.paginate(resource) if paginated?
-
super(resource, opts)
end
end
@@ -36,9 +28,9 @@ class EnvironmentSerializer < BaseSerializer
private
def itemize(resource)
- items = resource.order('folder_name ASC')
+ items = resource.order('folder ASC')
.group('COALESCE(environment_type, name)')
- .select('COALESCE(environment_type, name) AS folder_name',
+ .select('COALESCE(environment_type, name) AS folder',
'COUNT(*) AS size', 'MAX(id) AS last_id')
# It makes a difference when you call `paginate` method, because
@@ -49,7 +41,7 @@ class EnvironmentSerializer < BaseSerializer
environments = resource.where(id: items.map(&:last_id)).index_by(&:id)
items.map do |item|
- Item.new(item.folder_name, item.size, environments[item.last_id])
+ Item.new(item.folder, item.size, environments[item.last_id])
end
end
end
diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb
deleted file mode 100644
index 935d67a4f37..00000000000
--- a/app/serializers/event_entity.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-class EventEntity < Grape::Entity
- expose :author, using: UserEntity
- expose :updated_at
-end
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
new file mode 100644
index 00000000000..15ec0f89bb2
--- /dev/null
+++ b/app/serializers/group_child_entity.rb
@@ -0,0 +1,97 @@
+class GroupChildEntity < Grape::Entity
+ include ActionView::Helpers::NumberHelper
+ include RequestAwareEntity
+ include MarkupHelper
+
+ expose :id, :name, :description, :visibility, :full_name,
+ :created_at, :updated_at, :avatar_url
+
+ expose :type do |instance|
+ type
+ end
+
+ expose :can_edit do |instance|
+ can_edit?
+ end
+
+ expose :edit_path do |instance|
+ # We know `type` will be one either `project` or `group`.
+ # The `edit_polymorphic_path` helper would try to call the path helper
+ # with a plural: `edit_groups_path(instance)` or `edit_projects_path(instance)`
+ # while our methods are `edit_group_path` or `edit_group_path`
+ public_send("edit_#{type}_path", instance) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ expose :relative_path do |instance|
+ polymorphic_path(instance)
+ end
+
+ expose :permission do |instance|
+ membership&.human_access
+ end
+
+ # Project only attributes
+ expose :star_count,
+ if: lambda { |_instance, _options| project? }
+
+ # Group only attributes
+ expose :children_count, :parent_id, :project_count, :subgroup_count,
+ unless: lambda { |_instance, _options| project? }
+
+ expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
+ leave_group_members_path(instance)
+ end
+
+ expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance|
+ if membership
+ can?(request.current_user, :destroy_group_member, membership)
+ else
+ false
+ end
+ end
+
+ expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+ number_with_delimiter(instance.project_count)
+ end
+
+ expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+ number_with_delimiter(instance.member_count)
+ end
+
+ expose :markdown_description do |instance|
+ markdown_description
+ end
+
+ private
+
+ def membership
+ return unless request.current_user
+
+ @membership ||= request.current_user.members.find_by(source: object)
+ end
+
+ def project?
+ object.is_a?(Project)
+ end
+
+ def type
+ object.class.name.downcase
+ end
+
+ def markdown_description
+ markdown_field(object, :description)
+ end
+
+ def can_edit?
+ return false unless request.respond_to?(:current_user)
+
+ if project?
+ # Avoid checking rights for each project, as it might be expensive if the
+ # user cannot read cross project.
+ can?(request.current_user, :read_cross_project) &&
+ can?(request.current_user, :admin_project, object)
+ else
+ can?(request.current_user, :admin_group, object)
+ end
+ end
+end
diff --git a/app/serializers/group_child_serializer.rb b/app/serializers/group_child_serializer.rb
new file mode 100644
index 00000000000..2baef0a5703
--- /dev/null
+++ b/app/serializers/group_child_serializer.rb
@@ -0,0 +1,51 @@
+class GroupChildSerializer < BaseSerializer
+ include WithPagination
+
+ attr_reader :hierarchy_root, :should_expand_hierarchy
+
+ entity GroupChildEntity
+
+ def expand_hierarchy(hierarchy_root = nil)
+ @hierarchy_root = hierarchy_root
+ @should_expand_hierarchy = true
+
+ self
+ end
+
+ def represent(resource, opts = {}, entity_class = nil)
+ if should_expand_hierarchy
+ paginator.paginate(resource) if paginated?
+ represent_hierarchies(resource, opts)
+ else
+ super(resource, opts)
+ end
+ end
+
+ protected
+
+ def represent_hierarchies(children, opts)
+ if children.is_a?(GroupDescendant)
+ represent_hierarchy(children.hierarchy(hierarchy_root), opts).first
+ else
+ hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root)
+ # When an array was passed, we always want to represent an array.
+ # Even if the hierarchy only contains one element
+ represent_hierarchy(Array.wrap(hierarchies), opts)
+ end
+ end
+
+ def represent_hierarchy(hierarchy, opts)
+ serializer = self.class.new(params)
+
+ if hierarchy.is_a?(Hash)
+ hierarchy.map do |parent, children|
+ serializer.represent(parent, opts)
+ .merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
+ end
+ elsif hierarchy.is_a?(Array)
+ hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
+ else
+ serializer.represent(hierarchy, opts)
+ end
+ end
+end
diff --git a/app/serializers/group_entity.rb b/app/serializers/group_entity.rb
index 7c872a3e986..6d8466da902 100644
--- a/app/serializers/group_entity.rb
+++ b/app/serializers/group_entity.rb
@@ -45,6 +45,6 @@ class GroupEntity < Grape::Entity
end
expose :avatar_url do |group|
- group_icon(group)
+ group_icon_url(group)
end
end
diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb
index 26e8566828b..8cf7eb63bcf 100644
--- a/app/serializers/group_serializer.rb
+++ b/app/serializers/group_serializer.rb
@@ -1,19 +1,5 @@
class GroupSerializer < BaseSerializer
- entity GroupEntity
-
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
+ include WithPagination
- def paginated?
- @paginator.present?
- end
-
- def represent(resource, opts = {})
- if paginated?
- super(@paginator.paginate(resource), opts)
- else
- super(resource, opts)
- end
- end
+ entity GroupEntity
end
diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb
new file mode 100644
index 00000000000..62cf0b21e1e
--- /dev/null
+++ b/app/serializers/group_variable_entity.rb
@@ -0,0 +1,7 @@
+class GroupVariableEntity < Grape::Entity
+ expose :id
+ expose :key
+ expose :value
+
+ expose :protected?, as: :protected
+end
diff --git a/app/serializers/group_variable_serializer.rb b/app/serializers/group_variable_serializer.rb
new file mode 100644
index 00000000000..8f8205924aa
--- /dev/null
+++ b/app/serializers/group_variable_serializer.rb
@@ -0,0 +1,3 @@
+class GroupVariableSerializer < BaseSerializer
+ entity GroupVariableEntity
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index bd5211b8e58..6f31fbd6b7c 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -1,18 +1,8 @@
class IssuableEntity < Grape::Entity
+ include RequestAwareEntity
+
expose :id
expose :iid
- expose :author_id
expose :description
- expose :lock_version
- expose :milestone_id
- expose :state
expose :title
- expose :updated_by_id
- expose :created_at
- expose :updated_at
- expose :deleted_at
- expose :time_estimate
- expose :total_time_spent
- expose :human_time_estimate
- expose :human_total_time_spent
end
diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb
new file mode 100644
index 00000000000..29138c803df
--- /dev/null
+++ b/app/serializers/issuable_sidebar_entity.rb
@@ -0,0 +1,12 @@
+class IssuableSidebarEntity < Grape::Entity
+ include TimeTrackableEntity
+ include RequestAwareEntity
+
+ expose :participants, using: ::API::Entities::UserBasic do |issuable|
+ issuable.participants(request.current_user)
+ end
+
+ expose :subscribed do |issuable|
+ issuable.subscribed?(request.current_user, issuable.project)
+ end
+end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 35df95549b7..b5e2334b6e3 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -1,16 +1,41 @@
class IssueEntity < IssuableEntity
- include RequestAwareEntity
+ include TimeTrackableEntity
- expose :branch_name
+ expose :state
+ expose :milestone_id
+ expose :updated_by_id
+ expose :created_at
+ expose :updated_at
+ expose :milestone, using: API::Entities::Milestone
+ expose :labels, using: LabelEntity
+ expose :lock_version
+ expose :author_id
expose :confidential
+ expose :discussion_locked
expose :assignees, using: API::Entities::UserBasic
expose :due_date
expose :moved_to_id
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)
+ project_issue_path(issue.project, issue)
+ end
+
+ expose :current_user do
+ expose :can_create_note do |issue|
+ can?(request.current_user, :create_note, issue)
+ end
+
+ expose :can_update do |issue|
+ can?(request.current_user, :update_issue, issue)
+ end
+ end
+
+ expose :create_note_path do |issue|
+ project_notes_path(issue.project, target_type: 'issue', target_id: issue.id)
+ end
+
+ expose :preview_note_path do |issue|
+ preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id)
end
end
diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb
index 4fff54a9126..2555595379b 100644
--- a/app/serializers/issue_serializer.rb
+++ b/app/serializers/issue_serializer.rb
@@ -1,3 +1,16 @@
class IssueSerializer < BaseSerializer
- entity IssueEntity
+ # This overrided method takes care of which entity should be used
+ # to serialize the `issue` based on `basic` key in `opts` param.
+ # Hence, `entity` doesn't need to be declared on the class scope.
+ def represent(merge_request, opts = {})
+ entity =
+ case opts[:serializer]
+ when 'sidebar'
+ IssueSidebarEntity
+ else
+ IssueEntity
+ end
+
+ super(merge_request, opts, entity)
+ end
end
diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb
new file mode 100644
index 00000000000..6c823dbfe95
--- /dev/null
+++ b/app/serializers/issue_sidebar_entity.rb
@@ -0,0 +1,3 @@
+class IssueSidebarEntity < IssuableSidebarEntity
+ expose :assignees, using: API::Entities::UserBasic
+end
diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb
index d6de43bcbcb..523b522d449 100644
--- a/app/serializers/job_entity.rb
+++ b/app/serializers/job_entity.rb
@@ -4,6 +4,8 @@ class JobEntity < Grape::Entity
expose :id
expose :name
+ expose :started?, as: :started
+
expose :build_path do |build|
build.target_url || path_to(:namespace_project_job, build)
end
@@ -46,6 +48,6 @@ class JobEntity < Grape::Entity
end
def path_to(route, build)
- send("#{route}_path", build.project.namespace, build.project, build)
+ send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
index ad565654342..4452161051e 100644
--- a/app/serializers/label_entity.rb
+++ b/app/serializers/label_entity.rb
@@ -1,5 +1,6 @@
class LabelEntity < Grape::Entity
- expose :id
+ expose :id, if: ->(label, _) { !label.is_a?(GlobalLabel) }
+
expose :title
expose :color
expose :description
diff --git a/app/serializers/lfs_file_lock_entity.rb b/app/serializers/lfs_file_lock_entity.rb
new file mode 100644
index 00000000000..264a77adc3f
--- /dev/null
+++ b/app/serializers/lfs_file_lock_entity.rb
@@ -0,0 +1,11 @@
+class LfsFileLockEntity < Grape::Entity
+ root 'locks', 'lock'
+
+ expose :path
+ expose(:id) { |entity| entity.id.to_s }
+ expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_s(:iso8601) }
+
+ expose :owner do
+ expose(:name) { |entity| entity.user&.name }
+ end
+end
diff --git a/app/serializers/lfs_file_lock_serializer.rb b/app/serializers/lfs_file_lock_serializer.rb
new file mode 100644
index 00000000000..ba8fb1a461d
--- /dev/null
+++ b/app/serializers/lfs_file_lock_serializer.rb
@@ -0,0 +1,3 @@
+class LfsFileLockSerializer < BaseSerializer
+ entity LfsFileLockEntity
+end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index 8461f158bb5..e4aec977f01 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -1,11 +1,8 @@
-class MergeRequestBasicEntity < Grape::Entity
+class MergeRequestBasicEntity < IssuableSidebarEntity
expose :assignee_id
expose :merge_status
expose :merge_error
expose :state
expose :source_branch_exists?, as: :source_branch_exists
- expose :time_estimate
- expose :total_time_spent
- expose :human_time_estimate
- expose :human_total_time_spent
+ expose :rebase_in_progress?, as: :rebase_in_progress
end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
deleted file mode 100644
index 7bb981041cc..00000000000
--- a/app/serializers/merge_request_entity.rb
+++ /dev/null
@@ -1,189 +0,0 @@
-class MergeRequestEntity < IssuableEntity
- include RequestAwareEntity
-
- expose :in_progress_merge_commit_sha
- expose :locked_at
- expose :merge_commit_sha
- expose :merge_error
- expose :merge_params
- expose :merge_status
- expose :merge_user_id
- expose :merge_when_pipeline_succeeds
- expose :source_branch
- expose :source_project_id
- expose :target_branch
- expose :target_project_id
-
- # Events
- expose :merge_event, using: EventEntity
- expose :closed_event, using: EventEntity
-
- # User entities
- expose :author, using: UserEntity
- expose :merge_user, using: UserEntity
-
- # Diff sha's
- expose :diff_head_sha do |merge_request|
- merge_request.diff_head_sha if merge_request.diff_head_commit
- end
-
- expose :merge_commit_sha
- expose :merge_commit_message
- expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline
-
- # Booleans
- expose :work_in_progress?, as: :work_in_progress
- expose :source_branch_exists?, as: :source_branch_exists
- expose :mergeable_discussions_state?, as: :mergeable_discussions_state
- expose :branch_missing?, as: :branch_missing
- 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?
- end
-
- expose :only_allow_merge_if_pipeline_succeeds do |merge_request|
- merge_request.project.only_allow_merge_if_pipeline_succeeds?
- end
-
- # CI related
- expose :has_ci?, as: :has_ci
- expose :ci_status do |merge_request|
- presenter(merge_request).ci_status
- end
-
- expose :issues_links do
- expose :assign_to_closing do |merge_request|
- presenter(merge_request).assign_to_closing_issues_link
- end
-
- expose :closing do |merge_request|
- presenter(merge_request).closing_issues_links
- end
-
- expose :mentioned_but_not_closing do |merge_request|
- presenter(merge_request).mentioned_issues_links
- end
- end
-
- expose :source_branch_with_namespace_link do |merge_request|
- presenter(merge_request).source_branch_with_namespace_link
- end
-
- expose :source_branch_path do |merge_request|
- presenter(merge_request).source_branch_path
- end
-
- expose :current_user do
- expose :can_remove_source_branch do |merge_request|
- merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
- end
-
- expose :can_revert_on_current_merge_request do |merge_request|
- presenter(merge_request).can_revert_on_current_merge_request?
- end
-
- expose :can_cherry_pick_on_current_merge_request do |merge_request|
- presenter(merge_request).can_cherry_pick_on_current_merge_request?
- end
- end
-
- # Paths
- #
- expose :target_branch_commits_path do |merge_request|
- 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
-
- expose :remove_wip_path do |merge_request|
- presenter(merge_request).remove_wip_path
- end
-
- expose :cancel_merge_when_pipeline_succeeds_path do |merge_request|
- presenter(merge_request).cancel_merge_when_pipeline_succeeds_path
- end
-
- expose :create_issue_to_resolve_discussions_path do |merge_request|
- presenter(merge_request).create_issue_to_resolve_discussions_path
- end
-
- expose :merge_path do |merge_request|
- presenter(merge_request).merge_path
- end
-
- expose :cherry_pick_in_fork_path do |merge_request|
- presenter(merge_request).cherry_pick_in_fork_path
- end
-
- expose :revert_in_fork_path do |merge_request|
- presenter(merge_request).revert_in_fork_path
- end
-
- expose :email_patches_path do |merge_request|
- namespace_project_merge_request_path(merge_request.project.namespace,
- merge_request.project,
- merge_request,
- format: :patch)
- end
-
- expose :plain_diff_path do |merge_request|
- namespace_project_merge_request_path(merge_request.project.namespace,
- merge_request.project,
- merge_request,
- format: :diff)
- end
-
- expose :status_path do |merge_request|
- namespace_project_merge_request_path(merge_request.target_project.namespace,
- merge_request.target_project,
- merge_request,
- format: :json)
- end
-
- expose :ci_environments_status_path do |merge_request|
- ci_environments_status_namespace_project_merge_request_path(merge_request.project.namespace,
- merge_request.project,
- merge_request)
- end
-
- expose :merge_commit_message_with_description do |merge_request|
- merge_request.merge_commit_message(include_description: true)
- end
-
- expose :diverged_commits_count do |merge_request|
- if merge_request.open? && merge_request.diverged_from_target_branch?
- merge_request.diverged_commits_count
- else
- 0
- end
- end
-
- expose :commit_change_content_path do |merge_request|
- commit_change_content_namespace_project_merge_request_path(merge_request.project.namespace,
- merge_request.project,
- merge_request)
- end
-
- private
-
- delegate :current_user, to: :request
-
- def presenter(merge_request)
- @presenters ||= {}
- @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user)
- end
-end
diff --git a/app/serializers/merge_request_metrics_entity.rb b/app/serializers/merge_request_metrics_entity.rb
new file mode 100644
index 00000000000..3548107ac16
--- /dev/null
+++ b/app/serializers/merge_request_metrics_entity.rb
@@ -0,0 +1,6 @@
+class MergeRequestMetricsEntity < Grape::Entity
+ expose :latest_closed_at, as: :closed_at
+ expose :merged_at
+ expose :latest_closed_by, as: :closed_by, using: UserEntity
+ expose :merged_by, using: UserEntity
+end
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index f67034ce47a..caf193bdae3 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -1,9 +1,16 @@
class MergeRequestSerializer < BaseSerializer
# This overrided method takes care of which entity should be used
- # to serialize the `merge_request` based on `basic` key in `opts` param.
+ # to serialize the `merge_request` based on `serializer` key in `opts` param.
# Hence, `entity` doesn't need to be declared on the class scope.
def represent(merge_request, opts = {})
- entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity
+ entity =
+ case opts[:serializer]
+ when 'basic', 'sidebar'
+ MergeRequestBasicEntity
+ else # It's 'widget'
+ MergeRequestWidgetEntity
+ end
+
super(merge_request, opts, entity)
end
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
new file mode 100644
index 00000000000..4a812e39ee1
--- /dev/null
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -0,0 +1,241 @@
+class MergeRequestWidgetEntity < IssuableEntity
+ expose :state
+ expose :in_progress_merge_commit_sha
+ expose :merge_commit_sha
+ expose :merge_error
+ expose :merge_params
+ expose :merge_status
+ expose :merge_user_id
+ expose :merge_when_pipeline_succeeds
+ expose :source_branch
+ expose :source_project_id
+ expose :target_branch
+ expose :target_project_id
+ expose :allow_maintainer_to_push
+
+ expose :should_be_rebased?, as: :should_be_rebased
+ expose :ff_only_enabled do |merge_request|
+ merge_request.project.merge_requests_ff_only_enabled
+ end
+
+ expose :metrics do |merge_request|
+ metrics = build_metrics(merge_request)
+
+ MergeRequestMetricsEntity.new(metrics).as_json
+ end
+
+ expose :rebase_commit_sha
+ expose :rebase_in_progress?, as: :rebase_in_progress
+
+ expose :can_push_to_source_branch do |merge_request|
+ presenter(merge_request).can_push_to_source_branch?
+ end
+
+ expose :rebase_path do |merge_request|
+ presenter(merge_request).rebase_path
+ end
+
+ # User entities
+ expose :merge_user, using: UserEntity
+
+ # Diff sha's
+ expose :diff_head_sha do |merge_request|
+ merge_request.diff_head_sha.presence
+ end
+
+ expose :merge_commit_message
+ expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline
+
+ # Booleans
+ expose :merge_ongoing?, as: :merge_ongoing
+ expose :work_in_progress?, as: :work_in_progress
+ expose :source_branch_exists?, as: :source_branch_exists
+
+ expose :mergeable_discussions_state?, as: :mergeable_discussions_state do |merge_request|
+ # This avoids calling MergeRequest#mergeable_discussions_state without
+ # considering the state of the MR first. If a MR isn't mergeable, we can
+ # safely short-circuit it.
+ if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
+ merge_request.mergeable_discussions_state?
+ else
+ false
+ end
+ end
+
+ expose :branch_missing?, as: :branch_missing
+ expose :commits_count
+ expose :cannot_be_merged?, as: :has_conflicts
+ expose :can_be_merged?, as: :can_be_merged
+ expose :mergeable?, as: :mergeable
+ expose :remove_source_branch?, as: :remove_source_branch
+
+ expose :project_archived do |merge_request|
+ merge_request.project.archived?
+ end
+
+ expose :only_allow_merge_if_pipeline_succeeds do |merge_request|
+ merge_request.project.only_allow_merge_if_pipeline_succeeds?
+ end
+
+ # CI related
+ expose :has_ci?, as: :has_ci
+ expose :ci_status do |merge_request|
+ presenter(merge_request).ci_status
+ end
+
+ expose :issues_links do
+ expose :assign_to_closing do |merge_request|
+ presenter(merge_request).assign_to_closing_issues_link
+ end
+
+ expose :closing do |merge_request|
+ presenter(merge_request).closing_issues_links
+ end
+
+ expose :mentioned_but_not_closing do |merge_request|
+ presenter(merge_request).mentioned_issues_links
+ end
+ end
+
+ expose :source_branch_with_namespace_link do |merge_request|
+ presenter(merge_request).source_branch_with_namespace_link
+ end
+
+ expose :source_branch_path do |merge_request|
+ presenter(merge_request).source_branch_path
+ end
+
+ expose :current_user do
+ expose :can_remove_source_branch do |merge_request|
+ merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
+ end
+
+ expose :can_revert_on_current_merge_request do |merge_request|
+ presenter(merge_request).can_revert_on_current_merge_request?
+ end
+
+ expose :can_cherry_pick_on_current_merge_request do |merge_request|
+ presenter(merge_request).can_cherry_pick_on_current_merge_request?
+ end
+
+ expose :can_create_note do |issue|
+ can?(request.current_user, :create_note, issue.project)
+ end
+
+ expose :can_update do |issue|
+ can?(request.current_user, :update_issue, issue)
+ end
+ end
+
+ # Paths
+ #
+ expose :target_branch_commits_path do |merge_request|
+ presenter(merge_request).target_branch_commits_path
+ end
+
+ expose :target_branch_tree_path do |merge_request|
+ presenter(merge_request).target_branch_tree_path
+ end
+
+ expose :new_blob_path do |merge_request|
+ if presenter(merge_request).can_push_to_source_branch?
+ project_new_blob_path(merge_request.source_project, merge_request.source_branch)
+ end
+ end
+
+ expose :conflict_resolution_path do |merge_request|
+ presenter(merge_request).conflict_resolution_path
+ end
+
+ expose :remove_wip_path do |merge_request|
+ presenter(merge_request).remove_wip_path
+ end
+
+ expose :cancel_merge_when_pipeline_succeeds_path do |merge_request|
+ presenter(merge_request).cancel_merge_when_pipeline_succeeds_path
+ end
+
+ expose :create_issue_to_resolve_discussions_path do |merge_request|
+ presenter(merge_request).create_issue_to_resolve_discussions_path
+ end
+
+ expose :merge_path do |merge_request|
+ presenter(merge_request).merge_path
+ end
+
+ expose :cherry_pick_in_fork_path do |merge_request|
+ presenter(merge_request).cherry_pick_in_fork_path
+ end
+
+ expose :revert_in_fork_path do |merge_request|
+ presenter(merge_request).revert_in_fork_path
+ end
+
+ expose :email_patches_path do |merge_request|
+ project_merge_request_path(merge_request.project, merge_request, format: :patch)
+ end
+
+ expose :plain_diff_path do |merge_request|
+ project_merge_request_path(merge_request.project, merge_request, format: :diff)
+ end
+
+ expose :status_path do |merge_request|
+ project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ end
+
+ expose :ci_environments_status_path do |merge_request|
+ ci_environments_status_project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ expose :merge_commit_message_with_description do |merge_request|
+ merge_request.merge_commit_message(include_description: true)
+ end
+
+ expose :diverged_commits_count do |merge_request|
+ if merge_request.open? && merge_request.diverged_from_target_branch?
+ merge_request.diverged_commits_count
+ else
+ 0
+ end
+ end
+
+ expose :create_note_path do |merge_request|
+ project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id)
+ end
+
+ expose :commit_change_content_path do |merge_request|
+ commit_change_content_project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ private
+
+ delegate :current_user, to: :request
+
+ def presenter(merge_request)
+ @presenters ||= {}
+ @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user)
+ end
+
+ # Once SchedulePopulateMergeRequestMetricsWithEventsData fully runs,
+ # we can remove this method and just serialize MergeRequest#metrics
+ # instead. See https://gitlab.com/gitlab-org/gitlab-ce/issues/41587
+ def build_metrics(merge_request)
+ # There's no need to query and serialize metrics data for merge requests that are not
+ # merged or closed.
+ return unless merge_request.merged? || merge_request.closed?
+ return merge_request.metrics if merge_request.merged? && merge_request.metrics&.merged_by_id
+ return merge_request.metrics if merge_request.closed? && merge_request.metrics&.latest_closed_by_id
+
+ build_metrics_from_events(merge_request)
+ end
+
+ def build_metrics_from_events(merge_request)
+ closed_event = merge_request.closed_event
+ merge_event = merge_request.merge_event
+
+ MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at,
+ latest_closed_by: closed_event&.author,
+ merged_at: merge_event&.updated_at,
+ merged_by: merge_event&.author)
+ end
+end
diff --git a/app/serializers/note_attachment_entity.rb b/app/serializers/note_attachment_entity.rb
new file mode 100644
index 00000000000..1ad50568ab9
--- /dev/null
+++ b/app/serializers/note_attachment_entity.rb
@@ -0,0 +1,5 @@
+class NoteAttachmentEntity < Grape::Entity
+ expose :url
+ expose :filename
+ expose :image?, as: :image
+end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
new file mode 100644
index 00000000000..4ccf0bca476
--- /dev/null
+++ b/app/serializers/note_entity.rb
@@ -0,0 +1,72 @@
+class NoteEntity < API::Entities::Note
+ include RequestAwareEntity
+
+ expose :type
+
+ expose :author, using: NoteUserEntity
+
+ expose :human_access do |note|
+ note.project.team.human_max_access(note.author_id)
+ end
+
+ unexpose :note, as: :body
+ expose :note
+
+ expose :redacted_note_html, as: :note_html
+
+ expose :last_edited_at, if: -> (note, _) { note.edited? }
+ expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? }
+
+ expose :current_user do
+ expose :can_edit do |note|
+ Ability.can_edit_note?(request.current_user, note)
+ end
+ end
+
+ expose :resolved?, as: :resolved
+ expose :resolvable?, as: :resolvable
+ expose :resolved_by, using: NoteUserEntity
+
+ expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
+ SystemNoteHelper.system_note_icon_name(note)
+ end
+
+ expose :discussion_id do |note|
+ note.discussion_id(request.noteable)
+ end
+
+ expose :emoji_awardable?, as: :emoji_awardable
+ expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
+ expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
+ if note.for_personal_snippet?
+ toggle_award_emoji_snippet_note_path(note.noteable, note)
+ else
+ toggle_award_emoji_project_note_path(note.project, note.id)
+ end
+ end
+
+ expose :report_abuse_path do |note|
+ new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
+ end
+
+ expose :path do |note|
+ if note.for_personal_snippet?
+ snippet_note_path(note.noteable, note)
+ else
+ project_note_path(note.project, note)
+ end
+ end
+
+ expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
+ end
+
+ expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
+ end
+
+ expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
+ expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
+ delete_attachment_project_note_path(note.project, note)
+ end
+end
diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb
new file mode 100644
index 00000000000..2afe40d7a34
--- /dev/null
+++ b/app/serializers/note_serializer.rb
@@ -0,0 +1,3 @@
+class NoteSerializer < BaseSerializer
+ entity NoteEntity
+end
diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb
new file mode 100644
index 00000000000..7289f3a0222
--- /dev/null
+++ b/app/serializers/note_user_entity.rb
@@ -0,0 +1,3 @@
+class NoteUserEntity < UserEntity
+ unexpose :web_url
+end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 6d1fd9d459f..6457294b285 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -10,18 +10,17 @@ class PipelineEntity < Grape::Entity
expose :created_at, :updated_at
expose :path do |pipeline|
- namespace_project_pipeline_path(
- pipeline.project.namespace,
- pipeline.project,
- pipeline)
+ project_pipeline_path(pipeline.project, pipeline)
end
expose :flags do
expose :latest?, as: :latest
expose :stuck?, as: :stuck
+ expose :auto_devops_source?, as: :auto_devops
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
+ expose :failure_reason?, as: :failure_reason
end
expose :details do
@@ -46,21 +45,20 @@ class PipelineEntity < Grape::Entity
end
expose :commit, using: CommitEntity
+ expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
+
+ expose :failure_reason, if: -> (pipeline, _) { pipeline.failure_reason? } do |pipeline|
+ pipeline.present.failure_reason
+ end
expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
- retry_namespace_project_pipeline_path(pipeline.project.namespace,
- pipeline.project,
- pipeline.id)
+ retry_project_pipeline_path(pipeline.project, pipeline)
end
expose :cancel_path, if: -> (*) { can_cancel? } do |pipeline|
- cancel_namespace_project_pipeline_path(pipeline.project.namespace,
- pipeline.project,
- pipeline.id)
+ cancel_project_pipeline_path(pipeline.project, pipeline)
end
- expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
-
private
alias_method :pipeline, :object
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 661bf17983c..7181f8a6b04 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,16 +1,10 @@
class PipelineSerializer < BaseSerializer
+ include WithPagination
+
InvalidResourceError = Class.new(StandardError)
entity PipelineDetailsEntity
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
-
- def paginated?
- @paginator.present?
- end
-
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb
index a471a7e6a88..b3e5fd21e97 100644
--- a/app/serializers/project_entity.rb
+++ b/app/serializers/project_entity.rb
@@ -1,11 +1,11 @@
class ProjectEntity < Grape::Entity
include RequestAwareEntity
-
+
expose :id
expose :name
expose :full_path do |project|
- namespace_project_path(project.namespace, project)
+ project_path(project)
end
expose :full_name do |project|
diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb
new file mode 100644
index 00000000000..74de1e79a8f
--- /dev/null
+++ b/app/serializers/project_serializer.rb
@@ -0,0 +1,3 @@
+class ProjectSerializer < BaseSerializer
+ entity ProjectEntity
+end
diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb
index ed7dacc2dbd..e9999a36d8a 100644
--- a/app/serializers/runner_entity.rb
+++ b/app/serializers/runner_entity.rb
@@ -5,7 +5,7 @@ class RunnerEntity < Grape::Entity
expose :edit_path,
if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner|
- edit_namespace_project_runner_path(project.namespace, project, runner)
+ edit_project_runner_path(project, runner)
end
private
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index cee0089056f..4523b15152e 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -14,16 +14,14 @@ class StageEntity < Grape::Entity
expose :detailed_status, as: :status, with: StatusEntity
expose :path do |stage|
- namespace_project_pipeline_path(
- stage.pipeline.project.namespace,
+ project_pipeline_path(
stage.pipeline.project,
stage.pipeline,
anchor: stage.name)
end
expose :dropdown_path do |stage|
- stage_namespace_project_pipeline_path(
- stage.pipeline.project.namespace,
+ stage_project_pipeline_path(
stage.pipeline.project,
stage.pipeline,
stage: stage.name,
diff --git a/app/serializers/submodule_entity.rb b/app/serializers/submodule_entity.rb
new file mode 100644
index 00000000000..ed1f1ae0ef0
--- /dev/null
+++ b/app/serializers/submodule_entity.rb
@@ -0,0 +1,23 @@
+class SubmoduleEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :name, :mode
+
+ expose :icon do |blob|
+ 'archive'
+ end
+
+ expose :url do |blob|
+ submodule_links(blob, request).first
+ end
+
+ expose :tree_url do |blob|
+ submodule_links(blob, request).last
+ end
+
+ private
+
+ def submodule_links(blob, request)
+ @submodule_links ||= SubmoduleHelper.submodule_links(blob, request.ref, request.repository)
+ end
+end
diff --git a/app/serializers/time_trackable_entity.rb b/app/serializers/time_trackable_entity.rb
new file mode 100644
index 00000000000..e81cd7bec72
--- /dev/null
+++ b/app/serializers/time_trackable_entity.rb
@@ -0,0 +1,11 @@
+module TimeTrackableEntity
+ extend ActiveSupport::Concern
+ extend Grape
+
+ included do
+ expose :time_estimate
+ expose :total_time_spent
+ expose :human_time_estimate
+ expose :human_total_time_spent
+ end
+end
diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb
new file mode 100644
index 00000000000..9f1b485347f
--- /dev/null
+++ b/app/serializers/tree_entity.rb
@@ -0,0 +1,13 @@
+class TreeEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :name, :mode
+
+ expose :icon do |tree|
+ IconsHelper.file_type_icon_class('folder', tree.mode, tree.name)
+ end
+
+ expose :url do |tree|
+ project_tree_path(request.project, File.join(request.ref, tree.path))
+ end
+end
diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb
new file mode 100644
index 00000000000..496f070ddbd
--- /dev/null
+++ b/app/serializers/tree_root_entity.rb
@@ -0,0 +1,25 @@
+# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`.
+class TreeRootEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :path
+
+ expose :trees, using: TreeEntity
+ expose :blobs, using: BlobEntity
+ expose :submodules, using: SubmoduleEntity
+
+ expose :parent_tree_url do |tree|
+ path = tree.path.sub(%r{\A/}, '')
+ next unless path.present?
+
+ path_segments = path.split('/')
+ path_segments.pop
+ parent_tree_path = path_segments.join('/')
+
+ project_tree_path(request.project, File.join(request.ref, parent_tree_path))
+ end
+
+ expose :last_commit_path do |tree|
+ logs_file_project_ref_path(request.project, request.ref, tree.path)
+ end
+end
diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb
new file mode 100644
index 00000000000..713ade23bc9
--- /dev/null
+++ b/app/serializers/tree_serializer.rb
@@ -0,0 +1,3 @@
+class TreeSerializer < BaseSerializer
+ entity TreeRootEntity
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
new file mode 100644
index 00000000000..49a71ebac61
--- /dev/null
+++ b/app/serializers/user_serializer.rb
@@ -0,0 +1,3 @@
+class UserSerializer < BaseSerializer
+ entity UserEntity
+end
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
new file mode 100644
index 00000000000..d576745c073
--- /dev/null
+++ b/app/serializers/variable_entity.rb
@@ -0,0 +1,7 @@
+class VariableEntity < Grape::Entity
+ expose :id
+ expose :key
+ expose :value
+
+ expose :protected?, as: :protected
+end
diff --git a/app/serializers/variable_serializer.rb b/app/serializers/variable_serializer.rb
new file mode 100644
index 00000000000..32ae82ab51c
--- /dev/null
+++ b/app/serializers/variable_serializer.rb
@@ -0,0 +1,3 @@
+class VariableSerializer < BaseSerializer
+ entity VariableEntity
+end
diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb
index b2a543daa00..46e19230328 100644
--- a/app/services/access_token_validation_service.rb
+++ b/app/services/access_token_validation_service.rb
@@ -5,10 +5,11 @@ class AccessTokenValidationService
REVOKED = :revoked
INSUFFICIENT_SCOPE = :insufficient_scope
- attr_reader :token
+ attr_reader :token, :request
- def initialize(token)
+ def initialize(token, request: nil)
@token = token
+ @request = request
end
def validate(scopes: [])
@@ -27,12 +28,20 @@ class AccessTokenValidationService
end
# True if the token's scope contains any of the passed scopes.
- def include_any_scope?(scopes)
- if scopes.blank?
+ def include_any_scope?(required_scopes)
+ if required_scopes.blank?
true
else
- # Check whether the token is allowed access to any of the required scopes.
- Set.new(scopes).intersection(Set.new(token.scopes)).present?
+ # We're comparing each required_scope against all token scopes, which would
+ # take quadratic time. This consideration is irrelevant here because of the
+ # small number of records involved.
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12300/#note_33689006
+ token_scopes = token.scopes.map(&:to_sym)
+
+ required_scopes.any? do |scope|
+ scope = API::Scope.new(scope) unless scope.is_a?(API::Scope)
+ scope.sufficient?(token_scopes, request)
+ end
end
end
end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index 8e11a2a36a7..0521393dd27 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -7,7 +7,7 @@ class AkismetService
@options = options
end
- def is_spam?
+ def spam?
return false unless akismet_enabled?
params = {
@@ -39,12 +39,12 @@ class AkismetService
private
def akismet_client
- @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
+ @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key,
Gitlab.config.gitlab.url)
end
def akismet_enabled?
- current_application_settings.akismet_enabled
+ Gitlab::CurrentSettings.akismet_enabled
end
def submit(type)
@@ -58,7 +58,7 @@ class AkismetService
}
begin
- akismet_client.public_send(type, options[:ip_address], options[:user_agent], params)
+ akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend
true
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb
new file mode 100644
index 00000000000..35d45f25a71
--- /dev/null
+++ b/app/services/applications/create_service.rb
@@ -0,0 +1,13 @@
+module Applications
+ class CreateService
+ def initialize(current_user, params)
+ @current_user = current_user
+ @params = params
+ @ip_address = @params.delete(:ip_address)
+ end
+
+ def execute(request = nil)
+ Doorkeeper::Application.create(@params)
+ end
+ end
+end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 5e151b0f044..2b77f6be72a 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -1,7 +1,5 @@
module Auth
class ContainerRegistryAuthenticationService < BaseService
- include Gitlab::CurrentSettings
-
AUDIENCE = 'container_registry'.freeze
def execute(authentication_abilities:)
@@ -32,7 +30,7 @@ module Auth
end
def self.token_expire_at
- Time.now + current_application_settings.container_registry_token_expire_delay.minutes
+ Time.now + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes
end
private
@@ -56,11 +54,22 @@ module Auth
def process_scope(scope)
type, name, actions = scope.split(':', 3)
actions = actions.split(',')
- path = ContainerRegistry::Path.new(name)
- return unless type == 'repository'
+ case type
+ when 'registry'
+ process_registry_access(type, name, actions)
+ when 'repository'
+ path = ContainerRegistry::Path.new(name)
+ process_repository_access(type, path, actions)
+ end
+ end
+
+ def process_registry_access(type, name, actions)
+ return unless current_user&.admin?
+ return unless name == 'catalog'
+ return unless actions == ['*']
- process_repository_access(type, path, actions)
+ { type: type, name: name, actions: ['*'] }
end
def process_repository_access(type, path, actions)
@@ -103,6 +112,8 @@ module Auth
build_can_pull?(requested_project) || user_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
+ when '*'
+ user_can_admin?(requested_project)
else
false
end
@@ -120,6 +131,11 @@ module Auth
(requested_project == project || can?(current_user, :build_read_container_image, requested_project))
end
+ def user_can_admin?(requested_project)
+ has_authentication_ability?(:admin_container_image) &&
+ can?(current_user, :admin_container_image, requested_project)
+ end
+
def user_can_pull?(requested_project)
has_authentication_ability?(:read_container_image) &&
can?(current_user, :read_container_image, requested_project)
diff --git a/app/services/badges/base_service.rb b/app/services/badges/base_service.rb
new file mode 100644
index 00000000000..4f87426bd38
--- /dev/null
+++ b/app/services/badges/base_service.rb
@@ -0,0 +1,11 @@
+module Badges
+ class BaseService
+ protected
+
+ attr_accessor :params
+
+ def initialize(params = {})
+ @params = params.dup
+ end
+ end
+end
diff --git a/app/services/badges/build_service.rb b/app/services/badges/build_service.rb
new file mode 100644
index 00000000000..6267e571838
--- /dev/null
+++ b/app/services/badges/build_service.rb
@@ -0,0 +1,12 @@
+module Badges
+ class BuildService < Badges::BaseService
+ # returns the created badge
+ def execute(source)
+ if source.is_a?(Group)
+ GroupBadge.new(params.merge(group: source))
+ else
+ ProjectBadge.new(params.merge(project: source))
+ end
+ end
+ end
+end
diff --git a/app/services/badges/create_service.rb b/app/services/badges/create_service.rb
new file mode 100644
index 00000000000..aafb87f7dcd
--- /dev/null
+++ b/app/services/badges/create_service.rb
@@ -0,0 +1,10 @@
+module Badges
+ class CreateService < Badges::BaseService
+ # returns the created badge
+ def execute(source)
+ badge = Badges::BuildService.new(params).execute(source)
+
+ badge.tap { |b| b.save }
+ end
+ end
+end
diff --git a/app/services/badges/update_service.rb b/app/services/badges/update_service.rb
new file mode 100644
index 00000000000..7ca84b5df31
--- /dev/null
+++ b/app/services/badges/update_service.rb
@@ -0,0 +1,12 @@
+module Badges
+ class UpdateService < Badges::BaseService
+ # returns the updated badge
+ def execute(badge)
+ if params.present?
+ badge.update_attributes(params)
+ end
+
+ badge
+ end
+ end
+end
diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb
new file mode 100644
index 00000000000..f2844854112
--- /dev/null
+++ b/app/services/base_count_service.rb
@@ -0,0 +1,44 @@
+# Base class for services that count a single resource such as the number of
+# issues for a project.
+class BaseCountService
+ def relation_for_count
+ raise(
+ NotImplementedError,
+ '"relation_for_count" must be implemented and return an ActiveRecord::Relation'
+ )
+ end
+
+ def count
+ Rails.cache.fetch(cache_key, cache_options) { uncached_count }.to_i
+ end
+
+ def count_stored?
+ Rails.cache.read(cache_key).present?
+ end
+
+ def refresh_cache(&block)
+ Rails.cache.write(cache_key, block_given? ? yield : uncached_count, raw: raw?)
+ end
+
+ def uncached_count
+ relation_for_count.count
+ end
+
+ def delete_cache
+ Rails.cache.delete(cache_key)
+ end
+
+ def raw?
+ false
+ end
+
+ def cache_key
+ raise NotImplementedError, 'cache_key must be implemented and return a String'
+ end
+
+ # subclasses can override to add any specific options, such as
+ # super.merge({ expires_in: 5.minutes })
+ def cache_options
+ { raw: raw? }
+ end
+end
diff --git a/app/services/base_renderer.rb b/app/services/base_renderer.rb
new file mode 100644
index 00000000000..d6e30bd7008
--- /dev/null
+++ b/app/services/base_renderer.rb
@@ -0,0 +1,7 @@
+class BaseRenderer
+ attr_reader :current_user
+
+ def initialize(current_user = nil)
+ @current_user = current_user
+ end
+end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index a0cb00dba58..6883ba36c71 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -1,6 +1,5 @@
class BaseService
include Gitlab::Allowable
- include Gitlab::CurrentSettings
attr_accessor :project, :current_user, :params
diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb
new file mode 100644
index 00000000000..72822ffffa1
--- /dev/null
+++ b/app/services/boards/base_service.rb
@@ -0,0 +1,10 @@
+module Boards
+ class BaseService < ::BaseService
+ # Parent can either a group or a project
+ attr_accessor :parent, :current_user, :params
+
+ def initialize(parent, user, params = {})
+ @parent, @current_user, @params = parent, user, params.dup
+ end
+ end
+end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index 68f6a8619e5..bd0bb387662 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -1,19 +1,22 @@
module Boards
- class CreateService < BaseService
+ class CreateService < Boards::BaseService
def execute
- if project.boards.empty?
- create_board!
- else
- project.boards.first
- end
+ create_board! if can_create_board?
end
private
+ def can_create_board?
+ parent.boards.size == 0
+ end
+
def create_board!
- board = project.boards.create
- board.lists.create(list_type: :backlog)
- board.lists.create(list_type: :closed)
+ board = parent.boards.create(params)
+
+ if board.persisted?
+ board.lists.create(list_type: :backlog)
+ board.lists.create(list_type: :closed)
+ end
board
end
diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb
index c0d7ff5b585..7c4a79f555e 100644
--- a/app/services/boards/issues/create_service.rb
+++ b/app/services/boards/issues/create_service.rb
@@ -1,6 +1,14 @@
module Boards
module Issues
- class CreateService < BaseService
+ class CreateService < Boards::BaseService
+ attr_accessor :project
+
+ def initialize(parent, project, user, params = {})
+ @project = project
+
+ super(parent, user, params)
+ end
+
def execute
create_issue(params.merge(label_ids: [list.label_id]))
end
@@ -8,7 +16,7 @@ module Boards
private
def board
- @board ||= project.boards.find(params.delete(:board_id))
+ @board ||= parent.boards.find(params.delete(:board_id))
end
def list
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 418fa9afd6e..ecd74b74f8a 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -1,9 +1,9 @@
module Boards
module Issues
- class ListService < BaseService
+ class ListService < Boards::BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
- issues = without_board_labels(issues) unless movable_list?
+ issues = without_board_labels(issues) unless movable_list? || closed_list?
issues = with_list_label(issues) if movable_list?
issues.order_by_position_and_priority
end
@@ -11,7 +11,7 @@ module Boards
private
def board
- @board ||= project.boards.find(params[:board_id])
+ @board ||= parent.boards.find(params[:board_id])
end
def list
@@ -21,23 +21,30 @@ module Boards
end
def movable_list?
- @movable_list ||= list.present? && list.movable?
+ return @movable_list if defined?(@movable_list)
+
+ @movable_list = list.present? && list.movable?
+ end
+
+ def closed_list?
+ return @closed_list if defined?(@closed_list)
+
+ @closed_list = list.present? && list.closed?
end
def filter_params
- set_default_scope
- set_project
+ set_parent
set_state
params
end
- def set_default_scope
- params[:scope] = 'all'
- end
-
- def set_project
- params[:project_id] = project.id
+ def set_parent
+ if parent.is_a?(Group)
+ params[:group_id] = parent.id
+ else
+ params[:project_id] = parent.id
+ end
end
def set_state
@@ -51,10 +58,11 @@ module Boards
def without_board_labels(issues)
return issues unless board_label_ids.any?
- issues.where.not(
- LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
- .where(label_id: board_label_ids).limit(1).arel.exists
- )
+ issues.where.not(issues_label_links.limit(1).arel.exists)
+ end
+
+ def issues_label_links
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id").where(label_id: board_label_ids)
end
def with_list_label(issues)
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index ecabb2a48e4..15fed7d17c1 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -1,17 +1,17 @@
module Boards
module Issues
- class MoveService < BaseService
+ class MoveService < Boards::BaseService
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty?
- update_service.execute(issue)
+ update(issue)
end
private
def board
- @board ||= project.boards.find(params[:board_id])
+ @board ||= parent.boards.find(params[:board_id])
end
def move_between_lists?
@@ -27,8 +27,8 @@ module Boards
@moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
end
- def update_service
- ::Issues::UpdateService.new(project, current_user, issue_params)
+ def update(issue)
+ ::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue)
end
def issue_params
@@ -42,7 +42,7 @@ module Boards
)
end
- attrs[:move_between_iids] = move_between_iids if move_between_iids
+ attrs[:move_between_ids] = move_between_ids if move_between_ids
attrs
end
@@ -60,17 +60,19 @@ module Boards
label_ids =
if moving_to_list.movable?
moving_from_list.label_id
+ elsif board.group_board?
+ ::Label.on_group_boards(parent.id).pluck(:label_id)
else
- Label.on_project_boards(project.id).pluck(:label_id)
+ ::Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
end
- def move_between_iids
- return unless params[:move_after_iid] || params[:move_before_iid]
+ def move_between_ids
+ return unless params[:move_after_id] || params[:move_before_id]
- [params[:move_after_iid], params[:move_before_iid]]
+ [params[:move_after_id], params[:move_before_id]]
end
end
end
diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb
index 84f1fc3a4e2..6d0dd0a9f99 100644
--- a/app/services/boards/list_service.rb
+++ b/app/services/boards/list_service.rb
@@ -1,14 +1,14 @@
module Boards
- class ListService < BaseService
+ class ListService < Boards::BaseService
def execute
- create_board! if project.boards.empty?
- project.boards
+ create_board! if parent.boards.empty?
+ parent.boards
end
private
def create_board!
- Boards::CreateService.new(project, current_user).execute
+ Boards::CreateService.new(parent, current_user).execute
end
end
end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
index fe0d762ccd2..bebc90c7a8d 100644
--- a/app/services/boards/lists/create_service.rb
+++ b/app/services/boards/lists/create_service.rb
@@ -1,19 +1,22 @@
module Boards
module Lists
- class CreateService < BaseService
+ class CreateService < Boards::BaseService
def execute(board)
List.transaction do
- label = available_labels.find(params[:label_id])
+ label = available_labels_for(board).find(params[:label_id])
position = next_position(board)
-
create_list(board, label, position)
end
end
private
- def available_labels
- LabelsFinder.new(current_user, project_id: project.id).execute
+ def available_labels_for(board)
+ if board.group_board?
+ parent.labels
+ else
+ LabelsFinder.new(current_user, project_id: parent.id).execute
+ end
end
def next_position(board)
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
index f986e05944c..d75c5fd3dc6 100644
--- a/app/services/boards/lists/destroy_service.rb
+++ b/app/services/boards/lists/destroy_service.rb
@@ -1,6 +1,6 @@
module Boards
module Lists
- class DestroyService < BaseService
+ class DestroyService < Boards::BaseService
def execute(list)
return false unless list.destroyable?
diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb
index 939f9bfd068..05d4ab5dbcc 100644
--- a/app/services/boards/lists/generate_service.rb
+++ b/app/services/boards/lists/generate_service.rb
@@ -1,6 +1,6 @@
module Boards
module Lists
- class GenerateService < BaseService
+ class GenerateService < Boards::BaseService
def execute(board)
return false unless board.lists.movable.empty?
@@ -15,11 +15,11 @@ module Boards
def create_list(board, params)
label = find_or_create_label(params)
- Lists::CreateService.new(project, current_user, label_id: label.id).execute(board)
+ Lists::CreateService.new(parent, current_user, label_id: label.id).execute(board)
end
def find_or_create_label(params)
- ::Labels::FindOrCreateService.new(current_user, project, params).execute
+ ::Labels::FindOrCreateService.new(current_user, parent, params).execute
end
def label_params
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index df2a01a69e5..e57c95294af 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -1,6 +1,6 @@
module Boards
module Lists
- class ListService < BaseService
+ class ListService < Boards::BaseService
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb
index f2a68865f7b..7d0730e8332 100644
--- a/app/services/boards/lists/move_service.rb
+++ b/app/services/boards/lists/move_service.rb
@@ -1,6 +1,6 @@
module Boards
module Lists
- class MoveService < BaseService
+ class MoveService < Boards::BaseService
def execute(list)
@board = list.board
@old_position = list.position
diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb
index 321bf3a9205..7256466c9e8 100644
--- a/app/services/chat_names/authorize_user_service.rb
+++ b/app/services/chat_names/authorize_user_service.rb
@@ -1,6 +1,6 @@
module ChatNames
class AuthorizeUserService
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
def initialize(service, params)
@service = service
diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb
index 4f5c5567b42..d458b814183 100644
--- a/app/services/chat_names/find_user_service.rb
+++ b/app/services/chat_names/find_user_service.rb
@@ -9,8 +9,8 @@ module ChatNames
chat_name = find_chat_name
return unless chat_name
- chat_name.touch(:last_used_at)
- chat_name.user
+ chat_name.update_last_used_at
+ chat_name
end
private
diff --git a/app/services/check_gcp_project_billing_service.rb b/app/services/check_gcp_project_billing_service.rb
new file mode 100644
index 00000000000..ea82b61b279
--- /dev/null
+++ b/app/services/check_gcp_project_billing_service.rb
@@ -0,0 +1,11 @@
+class CheckGcpProjectBillingService
+ def execute(token)
+ client = GoogleApi::CloudPlatform::Client.new(token, nil)
+ client.projects_list.select do |project|
+ begin
+ client.projects_get_billing_info(project.project_id).billing_enabled
+ rescue
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 942145c4a8c..ad27f320853 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -2,78 +2,55 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
- def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil)
- @pipeline = Ci::Pipeline.new(
+ SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
+ Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
+ Gitlab::Ci::Pipeline::Chain::Validate::Repository,
+ Gitlab::Ci::Pipeline::Chain::Validate::Config,
+ Gitlab::Ci::Pipeline::Chain::Skip,
+ Gitlab::Ci::Pipeline::Chain::Create].freeze
+
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block)
+ @pipeline = Ci::Pipeline.new
+
+ command = Gitlab::Ci::Pipeline::Chain::Command.new(
source: source,
+ origin_ref: params[:ref],
+ checkout_sha: params[:checkout_sha],
+ after_sha: params[:after],
+ before_sha: params[:before],
+ trigger_request: trigger_request,
+ schedule: schedule,
+ ignore_skip_ci: ignore_skip_ci,
+ save_incompleted: save_on_errors,
+ seeds_block: block,
project: project,
- ref: ref,
- sha: sha,
- before_sha: before_sha,
- tag: tag?,
- trigger_requests: Array(trigger_request),
- user: current_user,
- pipeline_schedule: schedule
- )
-
- unless project.builds_enabled?
- return error('Pipeline is disabled')
- end
+ current_user: current_user)
- unless trigger_request || can?(current_user, :create_pipeline, project)
- return error('Insufficient permissions to create a new pipeline')
- end
+ sequence = Gitlab::Ci::Pipeline::Chain::Sequence
+ .new(pipeline, command, SEQUENCE)
- unless branch? || tag?
- return error('Reference not found')
- end
+ sequence.build! do |pipeline, sequence|
+ schedule_head_pipeline_update
- unless commit
- return error('Commit not found')
- end
+ if sequence.complete?
+ cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+ pipeline_created_counter.increment(source: source)
- unless pipeline.config_processor
- unless pipeline.ci_yaml_file
- return error('Missing .gitlab-ci.yml file')
+ pipeline.process!
end
- return error(pipeline.yaml_errors, save: save_on_errors)
end
- if !ignore_skip_ci && skip_ci?
- pipeline.skip if save_on_errors
- return pipeline
- end
-
- unless pipeline.has_stage_seeds?
- return error('No stages / jobs for this pipeline.')
- end
-
- Ci::Pipeline.transaction do
- update_merge_requests_head_pipeline if pipeline.save
-
- Ci::CreatePipelineStagesService
- .new(project, current_user)
- .execute(pipeline)
- end
-
- cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
-
- pipeline_created_counter.increment(source: source)
-
- pipeline.tap(&:process!)
+ pipeline
end
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)
+ def commit
+ @commit ||= project.commit(origin_sha || origin_ref)
end
- def skip_ci?
- return false unless pipeline.git_commit_message
- pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i
+ def sha
+ commit.try(:id)
end
def cancel_pending_pipelines
@@ -88,54 +65,23 @@ module Ci
project.pipelines
.where(ref: pipeline.ref)
.where.not(id: pipeline.id)
- .where.not(sha: project.repository.sha_from_ref(pipeline.ref))
+ .where.not(sha: project.commit(pipeline.ref).try(:id))
.created_or_pending
end
- def commit
- @commit ||= project.commit(origin_sha || origin_ref)
- end
-
- def sha
- commit.try(:id)
- end
-
- def before_sha
- params[:checkout_sha] || params[:before] || Gitlab::Git::BLANK_SHA
- end
-
- def origin_sha
- params[:checkout_sha] || params[:after]
- end
-
- def origin_ref
- params[:ref]
- end
-
- def branch?
- project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref)
- end
-
- def tag?
- project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref)
- end
-
- def ref
- Gitlab::Git.ref_name(origin_ref)
- end
-
- def valid_sha?
- origin_sha && origin_sha != Gitlab::Git::BLANK_SHA
+ def pipeline_created_counter
+ @pipeline_created_counter ||= Gitlab::Metrics
+ .counter(:pipelines_created_total, "Counter of pipelines created")
end
- def error(message, save: false)
- pipeline.errors.add(:base, message)
- pipeline.drop if save
- pipeline
+ def schedule_head_pipeline_update
+ related_merge_requests.each do |merge_request|
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ end
end
- def pipeline_created_counter
- @pipeline_created_counter ||= Gitlab::Metrics.counter(:pipelines_created_count, "Pipelines created count")
+ def related_merge_requests
+ pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref)
end
end
end
diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb
deleted file mode 100644
index cf3d4aee2bc..00000000000
--- a/app/services/ci/create_trigger_request_service.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module Ci
- class CreateTriggerRequestService
- def execute(project, trigger, ref, variables = nil)
- trigger_request = trigger.trigger_requests.create(variables: variables)
-
- pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref)
- .execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request)
-
- trigger_request if pipeline.persisted?
- end
- end
-end
diff --git a/app/services/ci/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb
new file mode 100644
index 00000000000..87f19b333de
--- /dev/null
+++ b/app/services/ci/ensure_stage_service.rb
@@ -0,0 +1,49 @@
+module Ci
+ ##
+ # We call this service everytime we persist a CI/CD job.
+ #
+ # In most cases a job should already have a stage assigned, but in cases it
+ # doesn't have we need to either find existing one or create a brand new
+ # stage.
+ #
+ class EnsureStageService < BaseService
+ EnsureStageError = Class.new(StandardError)
+
+ def execute(build)
+ @build = build
+
+ return if build.stage_id.present?
+ return if build.invalid?
+
+ ensure_stage.tap do |stage|
+ build.stage_id = stage.id
+
+ yield stage if block_given?
+ end
+ end
+
+ private
+
+ def ensure_stage(attempts: 2)
+ find_stage || create_stage
+ rescue ActiveRecord::RecordNotUnique
+ retry if (attempts -= 1) > 0
+
+ raise EnsureStageError, <<~EOS
+ We failed to find or create a unique pipeline stage after 2 retries.
+ This should never happen and is most likely the result of a bug in
+ the database load balancing code.
+ EOS
+ end
+
+ def find_stage
+ @build.pipeline.stages.find_by(name: @build.stage)
+ end
+
+ def create_stage
+ Ci::Stage.create!(name: @build.stage,
+ pipeline: @build.pipeline,
+ project: @build.project)
+ end
+ end
+end
diff --git a/app/services/ci/extract_sections_from_build_trace_service.rb b/app/services/ci/extract_sections_from_build_trace_service.rb
new file mode 100644
index 00000000000..75f9e0f897d
--- /dev/null
+++ b/app/services/ci/extract_sections_from_build_trace_service.rb
@@ -0,0 +1,30 @@
+module Ci
+ class ExtractSectionsFromBuildTraceService < BaseService
+ def execute(build)
+ return false unless build.trace_sections.empty?
+
+ Gitlab::Database.bulk_insert(BuildTraceSection.table_name, extract_sections(build))
+ true
+ end
+
+ private
+
+ def find_or_create_name(name)
+ project.build_trace_section_names.find_or_create_by!(name: name)
+ rescue ActiveRecord::RecordInvalid
+ project.build_trace_section_names.find_by!(name: name)
+ end
+
+ def extract_sections(build)
+ build.trace.extract_sections.map do |attr|
+ name = attr.delete(:name)
+ name_record = find_or_create_name(name)
+
+ attr.merge(
+ build_id: build.id,
+ project_id: project.id,
+ section_name_id: name_record.id)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/fetch_kubernetes_token_service.rb b/app/services/ci/fetch_kubernetes_token_service.rb
new file mode 100644
index 00000000000..bca883ec0a0
--- /dev/null
+++ b/app/services/ci/fetch_kubernetes_token_service.rb
@@ -0,0 +1,73 @@
+##
+# TODO:
+# Almost components in this class were copied from app/models/project_services/kubernetes_service.rb
+# We should dry up those classes not to repeat the same code.
+# Maybe we should have a special facility (e.g. lib/kubernetes_api) to maintain all Kubernetes API caller.
+module Ci
+ class FetchKubernetesTokenService
+ attr_reader :api_url, :ca_pem, :username, :password
+
+ def initialize(api_url, ca_pem, username, password)
+ @api_url = api_url
+ @ca_pem = ca_pem
+ @username = username
+ @password = password
+ end
+
+ def execute
+ read_secrets.each do |secret|
+ name = secret.dig('metadata', 'name')
+ if /default-token/ =~ name
+ token_base64 = secret.dig('data', 'token')
+ return Base64.decode64(token_base64) if token_base64
+ end
+ end
+
+ nil
+ end
+
+ private
+
+ def read_secrets
+ kubeclient = build_kubeclient!
+
+ kubeclient.get_secrets.as_json
+ rescue Kubeclient::HttpError => err
+ raise err unless err.error_code == 404
+
+ []
+ end
+
+ def build_kubeclient!(api_path: 'api', api_version: 'v1')
+ raise "Incomplete settings" unless api_url && username && password
+
+ ::Kubeclient::Client.new(
+ join_api_url(api_path),
+ api_version,
+ auth_options: { username: username, password: password },
+ ssl_options: kubeclient_ssl_options,
+ http_proxy_uri: ENV['http_proxy']
+ )
+ end
+
+ def join_api_url(api_path)
+ url = URI.parse(api_url)
+ prefix = url.path.sub(%r{/+\z}, '')
+
+ url.path = [prefix, api_path].join("/")
+
+ url.to_s
+ end
+
+ def kubeclient_ssl_options
+ opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
+
+ if ca_pem.present?
+ opts[:cert_store] = OpenSSL::X509::Store.new
+ opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
+ end
+
+ opts
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
new file mode 100644
index 00000000000..a9813d774bb
--- /dev/null
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -0,0 +1,46 @@
+module Ci
+ class PipelineTriggerService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ if trigger_from_token
+ create_pipeline_from_trigger(trigger_from_token)
+ end
+ end
+
+ private
+
+ def create_pipeline_from_trigger(trigger)
+ # this check is to not leak the presence of the project if user cannot read it
+ return unless trigger.project == project
+
+ pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: params[:ref])
+ .execute(:trigger, ignore_skip_ci: true) do |pipeline|
+ pipeline.trigger_requests.create!(trigger: trigger)
+ create_pipeline_variables!(pipeline)
+ end
+
+ if pipeline.persisted?
+ success(pipeline: pipeline)
+ else
+ error(pipeline.errors.messages, 400)
+ end
+ end
+
+ def trigger_from_token
+ strong_memoize(:trigger) do
+ Ci::Trigger.find_by_token(params[:token].to_s)
+ end
+ end
+
+ def create_pipeline_variables!(pipeline)
+ return unless params[:variables]
+
+ variables = params[:variables].map do |key, value|
+ { key: key, value: value }
+ end
+
+ pipeline.variables.create!(variables)
+ end
+ end
+end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index af84d4c7427..e09b445636f 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -2,8 +2,6 @@ module Ci
# This class responsible for assigning
# proper pending build to runner on runner API request
class RegisterJobService
- include Gitlab::CurrentSettings
-
attr_reader :runner
Result = Struct.new(:build, :valid?)
@@ -22,16 +20,31 @@ module Ci
valid = true
+ if Feature.enabled?('ci_job_request_with_tags_matcher')
+ # pick builds that does not have other tags than runner's one
+ builds = builds.matches_tag_ids(runner.tags.ids)
+
+ # pick builds that have at least one tag
+ unless runner.run_untagged?
+ builds = builds.with_any_tags
+ end
+ end
+
builds.find do |build|
next unless runner.can_pick?(build)
begin
# In case when 2 runners try to assign the same build, second runner will be declined
# with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method.
- build.runner_id = runner.id
- build.run!
-
- return Result.new(build, true)
+ begin
+ build.runner_id = runner.id
+ build.run!
+ register_success(build)
+
+ return Result.new(build, true)
+ rescue Ci::Build::MissingDependenciesError
+ build.drop!(:missing_dependency_failure)
+ end
rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError
# We are looping to find another build that is not conflicting
# It also indicates that this build can be picked and passed to runner.
@@ -46,6 +59,7 @@ module Ci
end
end
+ register_failure
Result.new(nil, valid)
end
@@ -54,7 +68,7 @@ module Ci
def builds_for_shared_runner
new_builds.
# don't run projects which have not enabled shared runners and builds
- joins(:project).where(projects: { shared_runners_enabled: true })
+ joins(:project).where(projects: { shared_runners_enabled: true, pending_delete: false })
.joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id')
.where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0').
@@ -66,7 +80,7 @@ module Ci
end
def builds_for_specific_runner
- new_builds.where(project: runner.projects.with_builds_enabled).order('created_at ASC')
+ new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC')
end
def running_builds_for_shared_runners
@@ -75,11 +89,35 @@ module Ci
end
def new_builds
- Ci::Build.pending.unstarted
+ builds = Ci::Build.pending.unstarted
+ builds = builds.ref_protected if runner.ref_protected?
+ builds
end
def shared_runner_build_limits_feature_enabled?
ENV['DISABLE_SHARED_RUNNER_BUILD_MINUTES_LIMIT'].to_s != 'true'
end
+
+ def register_failure
+ failed_attempt_counter.increment
+ attempt_counter.increment
+ end
+
+ def register_success(job)
+ job_queue_duration_seconds.observe({ shared_runner: @runner.shared? }, Time.now - job.created_at)
+ attempt_counter.increment
+ end
+
+ def failed_attempt_counter
+ @failed_attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_failed_total, "Counts the times a runner tries to register a job")
+ end
+
+ def attempt_counter
+ @attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_total, "Counts the times a runner tries to register a job")
+ end
+
+ def job_queue_duration_seconds
+ @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time')
+ end
end
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 6372e5755db..6128b2a8fbb 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -1,9 +1,9 @@
module Ci
class RetryBuildService < ::BaseService
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
- allow_failure stage_id stage stage_idx trigger_request
+ allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
- description tag_list].freeze
+ description tag_list protected].freeze
def execute(build)
reprocess!(build).tap do |new_build|
@@ -23,11 +23,13 @@ module Ci
end
attributes = CLONE_ACCESSORS.map do |attribute|
- [attribute, build.send(attribute)]
+ [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
end
attributes.push([:user, current_user])
+ build.retried = true
+
Ci::Build.transaction do
# mark all other builds of that name as retried
build.pipeline.builds.latest
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
new file mode 100644
index 00000000000..cba1b920f7c
--- /dev/null
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -0,0 +1,29 @@
+module Clusters
+ module Applications
+ class BaseHelmService
+ attr_accessor :app
+
+ def initialize(app)
+ @app = app
+ end
+
+ protected
+
+ def cluster
+ app.cluster
+ end
+
+ def kubeclient
+ cluster.kubeclient
+ end
+
+ def helm_api
+ @helm_api ||= Gitlab::Kubernetes::Helm::Api.new(kubeclient)
+ end
+
+ def install_command
+ @install_command ||= app.install_command
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb
new file mode 100644
index 00000000000..e572b1e5d99
--- /dev/null
+++ b/app/services/clusters/applications/check_ingress_ip_address_service.rb
@@ -0,0 +1,36 @@
+module Clusters
+ module Applications
+ class CheckIngressIpAddressService < BaseHelmService
+ include Gitlab::Utils::StrongMemoize
+
+ Error = Class.new(StandardError)
+
+ LEASE_TIMEOUT = 15.seconds.to_i
+
+ def execute
+ return if app.external_ip
+ return unless try_obtain_lease
+
+ app.update!(external_ip: ingress_ip) if ingress_ip
+ end
+
+ private
+
+ def try_obtain_lease
+ Gitlab::ExclusiveLease
+ .new("check_ingress_ip_address_service:#{app.id}", timeout: LEASE_TIMEOUT)
+ .try_obtain
+ end
+
+ def ingress_ip
+ service.status.loadBalancer.ingress&.first&.ip
+ end
+
+ def service
+ strong_memoize(:ingress_service) do
+ kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
new file mode 100644
index 00000000000..90393e951a4
--- /dev/null
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -0,0 +1,65 @@
+module Clusters
+ module Applications
+ class CheckInstallationProgressService < BaseHelmService
+ def execute
+ return unless app.installing?
+
+ case installation_phase
+ when Gitlab::Kubernetes::Pod::SUCCEEDED
+ on_success
+ when Gitlab::Kubernetes::Pod::FAILED
+ on_failed
+ else
+ check_timeout
+ end
+ rescue Kubeclient::HttpError => ke
+ app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored?
+ end
+
+ private
+
+ def on_success
+ app.make_installed!
+ ensure
+ remove_installation_pod
+ end
+
+ def on_failed
+ app.make_errored!(installation_errors || 'Installation silently failed')
+ ensure
+ remove_installation_pod
+ end
+
+ def check_timeout
+ if timeouted?
+ begin
+ app.make_errored!('Installation timeouted')
+ ensure
+ remove_installation_pod
+ end
+ else
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ end
+ end
+
+ def timeouted?
+ Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
+ end
+
+ def remove_installation_pod
+ helm_api.delete_installation_pod!(install_command.pod_name)
+ rescue
+ # no-op
+ end
+
+ def installation_phase
+ helm_api.installation_status(install_command.pod_name)
+ end
+
+ def installation_errors
+ helm_api.installation_log(install_command.pod_name)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb
new file mode 100644
index 00000000000..4c25a09814b
--- /dev/null
+++ b/app/services/clusters/applications/install_service.rb
@@ -0,0 +1,21 @@
+module Clusters
+ module Applications
+ class InstallService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ begin
+ app.make_installing!
+ helm_api.install(install_command)
+
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => ke
+ app.make_errored!("Kubernetes error: #{ke.message}")
+ rescue StandardError
+ app.make_errored!("Can't start installation process")
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb
new file mode 100644
index 00000000000..eb8caa68ef7
--- /dev/null
+++ b/app/services/clusters/applications/schedule_installation_service.rb
@@ -0,0 +1,22 @@
+module Clusters
+ module Applications
+ class ScheduleInstallationService < ::BaseService
+ def execute
+ application_class.find_or_create_by!(cluster: cluster).try do |application|
+ application.make_scheduled!
+ ClusterInstallAppWorker.perform_async(application.name, application.id)
+ end
+ end
+
+ private
+
+ def application_class
+ params[:application_class]
+ end
+
+ def cluster
+ params[:cluster]
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
new file mode 100644
index 00000000000..418888e3293
--- /dev/null
+++ b/app/services/clusters/create_service.rb
@@ -0,0 +1,35 @@
+module Clusters
+ class CreateService < BaseService
+ attr_reader :access_token
+
+ def execute(access_token = nil)
+ @access_token = access_token
+
+ raise ArgumentError.new(_('Instance does not support multiple Kubernetes clusters')) unless can_create_cluster?
+
+ create_cluster.tap do |cluster|
+ ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted?
+ end
+ end
+
+ private
+
+ def create_cluster
+ Clusters::Cluster.create(cluster_params)
+ end
+
+ def cluster_params
+ return @cluster_params if defined?(@cluster_params)
+
+ params[:provider_gcp_attributes].try do |provider|
+ provider[:access_token] = access_token
+ end
+
+ @cluster_params = params.merge(user: current_user, projects: [project])
+ end
+
+ def can_create_cluster?
+ project.clusters.empty?
+ end
+ end
+end
diff --git a/app/services/clusters/gcp/fetch_operation_service.rb b/app/services/clusters/gcp/fetch_operation_service.rb
new file mode 100644
index 00000000000..a4cd3ca5c11
--- /dev/null
+++ b/app/services/clusters/gcp/fetch_operation_service.rb
@@ -0,0 +1,16 @@
+module Clusters
+ module Gcp
+ class FetchOperationService
+ def execute(provider)
+ operation = provider.api_client.projects_zones_operations(
+ provider.gcp_project_id,
+ provider.zone,
+ provider.operation_id)
+
+ yield(operation) if block_given?
+ rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
new file mode 100644
index 00000000000..15ab2d54404
--- /dev/null
+++ b/app/services/clusters/gcp/finalize_creation_service.rb
@@ -0,0 +1,56 @@
+module Clusters
+ module Gcp
+ class FinalizeCreationService
+ attr_reader :provider
+
+ def execute(provider)
+ @provider = provider
+
+ configure_provider
+ configure_kubernetes
+
+ cluster.save!
+ rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ rescue ActiveRecord::RecordInvalid => e
+ provider.make_errored!("Failed to configure GKE Cluster: #{e.message}")
+ end
+
+ private
+
+ def configure_provider
+ provider.endpoint = gke_cluster.endpoint
+ provider.status_event = :make_created
+ end
+
+ def configure_kubernetes
+ cluster.platform_type = :kubernetes
+ cluster.build_platform_kubernetes(
+ api_url: 'https://' + gke_cluster.endpoint,
+ ca_cert: Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate),
+ username: gke_cluster.master_auth.username,
+ password: gke_cluster.master_auth.password,
+ token: request_kubernetes_token)
+ end
+
+ def request_kubernetes_token
+ Ci::FetchKubernetesTokenService.new(
+ 'https://' + gke_cluster.endpoint,
+ Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate),
+ gke_cluster.master_auth.username,
+ gke_cluster.master_auth.password).execute
+ end
+
+ def gke_cluster
+ @gke_cluster ||= provider.api_client.projects_zones_clusters_get(
+ provider.gcp_project_id,
+ provider.zone,
+ cluster.name)
+ end
+
+ def cluster
+ @cluster ||= provider.cluster
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/gcp/provision_service.rb b/app/services/clusters/gcp/provision_service.rb
new file mode 100644
index 00000000000..8beea5a8cfb
--- /dev/null
+++ b/app/services/clusters/gcp/provision_service.rb
@@ -0,0 +1,47 @@
+module Clusters
+ module Gcp
+ class ProvisionService
+ attr_reader :provider
+
+ def execute(provider)
+ @provider = provider
+
+ get_operation_id do |operation_id|
+ if provider.make_creating(operation_id)
+ WaitForClusterCreationWorker.perform_in(
+ Clusters::Gcp::VerifyProvisionStatusService::INITIAL_INTERVAL,
+ provider.cluster_id)
+ else
+ provider.make_errored!("Failed to update provider record; #{provider.errors}")
+ end
+ end
+ end
+
+ private
+
+ def get_operation_id
+ operation = provider.api_client.projects_zones_clusters_create(
+ provider.gcp_project_id,
+ provider.zone,
+ provider.cluster.name,
+ provider.num_nodes,
+ machine_type: provider.machine_type)
+
+ unless operation.status == 'PENDING' || operation.status == 'RUNNING'
+ return provider.make_errored!("Operation status is unexpected; #{operation.status_message}")
+ end
+
+ operation_id = provider.api_client.parse_operation_id(operation.self_link)
+
+ unless operation_id
+ return provider.make_errored!('Can not find operation_id from self_link')
+ end
+
+ yield(operation_id)
+
+ rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb
new file mode 100644
index 00000000000..f994aacd086
--- /dev/null
+++ b/app/services/clusters/gcp/verify_provision_status_service.rb
@@ -0,0 +1,48 @@
+module Clusters
+ module Gcp
+ class VerifyProvisionStatusService
+ attr_reader :provider
+
+ INITIAL_INTERVAL = 2.minutes
+ EAGER_INTERVAL = 10.seconds
+ TIMEOUT = 20.minutes
+
+ def execute(provider)
+ @provider = provider
+
+ request_operation do |operation|
+ case operation.status
+ when 'PENDING', 'RUNNING'
+ continue_creation(operation)
+ when 'DONE'
+ finalize_creation
+ else
+ return provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}")
+ end
+ end
+ end
+
+ private
+
+ def continue_creation(operation)
+ if elapsed_time_from_creation(operation) < TIMEOUT
+ WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, provider.cluster_id)
+ else
+ provider.make_errored!(_('Kubernetes cluster creation time exceeds timeout; %{timeout}') % { timeout: TIMEOUT })
+ end
+ end
+
+ def elapsed_time_from_creation(operation)
+ Time.now.utc - operation.start_time.to_time.utc
+ end
+
+ def finalize_creation
+ Clusters::Gcp::FinalizeCreationService.new.execute(provider)
+ end
+
+ def request_operation(&blk)
+ Clusters::Gcp::FetchOperationService.new.execute(provider, &blk)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb
new file mode 100644
index 00000000000..989218e32a2
--- /dev/null
+++ b/app/services/clusters/update_service.rb
@@ -0,0 +1,7 @@
+module Clusters
+ class UpdateService < BaseService
+ def execute(cluster)
+ cluster.update(params)
+ end
+ end
+end
diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb
index a48d6a976f0..b9d0173a2d0 100644
--- a/app/services/commits/change_service.rb
+++ b/app/services/commits/change_service.rb
@@ -11,14 +11,19 @@ module Commits
def commit_change(action)
raise NotImplementedError unless repository.respond_to?(action)
+ # rubocop:disable GitlabSecurity/PublicSend
+ message = @commit.public_send(:"#{action}_message", current_user)
+
+ # rubocop:disable GitlabSecurity/PublicSend
repository.public_send(
action,
current_user,
@commit,
@branch_name,
+ message,
start_project: @start_project,
start_branch_name: @start_branch)
- rescue Repository::CreateTreeError
+ rescue Gitlab::Git::Repository::CreateTreeError
error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically.
This #{@commit.change_type_title(current_user)} may already have been #{action.to_s.dasherize}ed, or a more recent commit may have updated some of its content."
raise ChangeError, error_msg
diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb
index c58f04a252b..f96f2931508 100644
--- a/app/services/commits/create_service.rb
+++ b/app/services/commits/create_service.rb
@@ -17,7 +17,7 @@ module Commits
new_commit = create_commit!
success(result: new_commit)
- rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Repository::CommitError, GitHooksService::PreReceiveError => ex
+ rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::HooksService::PreReceiveError => ex
error(ex.message)
end
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index a5ae4927412..2a69a205629 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -1,36 +1,23 @@
require 'securerandom'
-# Compare 2 branches for one repo or between repositories
+# Compare 2 refs for one repo or between repositories
# and return Gitlab::Git::Compare object that responds to commits and diffs
class CompareService
- attr_reader :start_project, :start_branch_name
+ attr_reader :start_project, :start_ref_name
- def initialize(new_start_project, new_start_branch_name)
+ def initialize(new_start_project, new_start_ref_name)
@start_project = new_start_project
- @start_branch_name = new_start_branch_name
+ @start_ref_name = new_start_ref_name
end
- def execute(target_project, target_branch, straight: false)
- # If compare with other project we need to fetch ref first
- target_project.repository.with_repo_branch_commit(
- start_project.repository,
- start_branch_name) do |commit|
- break unless commit
+ def execute(target_project, target_ref, base_sha: nil, straight: false)
+ raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
- compare(commit.sha, target_project, target_branch, straight: straight)
- end
- end
-
- private
-
- 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
- )
+ return unless raw_compare
- Compare.new(raw_compare, target_project, straight: straight)
+ Compare.new(raw_compare,
+ target_project,
+ base_sha: base_sha,
+ straight: straight)
end
end
diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb
index 7d45b4aa26a..26eb274f4d5 100644
--- a/app/services/concerns/issues/resolve_discussions.rb
+++ b/app/services/concerns/issues/resolve_discussions.rb
@@ -1,24 +1,28 @@
module Issues
module ResolveDiscussions
+ include Gitlab::Utils::StrongMemoize
+
attr_reader :merge_request_to_resolve_discussions_of_iid, :discussion_to_resolve_id
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def filter_resolve_discussion_params
@merge_request_to_resolve_discussions_of_iid ||= params.delete(:merge_request_to_resolve_discussions_of)
@discussion_to_resolve_id ||= params.delete(:discussion_to_resolve)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def merge_request_to_resolve_discussions_of
- return @merge_request_to_resolve_discussions_of if defined?(@merge_request_to_resolve_discussions_of)
-
- @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id)
- .execute
- .find_by(iid: merge_request_to_resolve_discussions_of_iid)
+ strong_memoize(:merge_request_to_resolve_discussions_of) do
+ MergeRequestsFinder.new(current_user, project_id: project.id)
+ .execute
+ .find_by(iid: merge_request_to_resolve_discussions_of_iid)
+ end
end
def discussions_to_resolve
return [] unless merge_request_to_resolve_discussions_of
- @discussions_to_resolve ||=
+ @discussions_to_resolve ||= # rubocop:disable Gitlab/ModuleWithInstanceVariables
if discussion_to_resolve_id
discussion_or_nil = merge_request_to_resolve_discussions_of
.find_discussion(discussion_to_resolve_id)
diff --git a/app/services/concerns/update_visibility_level.rb b/app/services/concerns/update_visibility_level.rb
new file mode 100644
index 00000000000..536fcc6acce
--- /dev/null
+++ b/app/services/concerns/update_visibility_level.rb
@@ -0,0 +1,15 @@
+module UpdateVisibilityLevel
+ def valid_visibility_level_change?(target, new_visibility)
+ # check that user is allowed to set specified visibility_level
+ if new_visibility && new_visibility.to_i != target.visibility_level
+ unless can?(current_user, :change_visibility_level, target) &&
+ Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
+
+ deny_visibility_level(target, new_visibility)
+ return false
+ end
+ end
+
+ true
+ end
+end
diff --git a/app/services/concerns/users/new_user_notifier.rb b/app/services/concerns/users/new_user_notifier.rb
new file mode 100644
index 00000000000..231693ce7a9
--- /dev/null
+++ b/app/services/concerns/users/new_user_notifier.rb
@@ -0,0 +1,9 @@
+module Users
+ module NewUserNotifier
+ def notify_new_user(user, reset_token)
+ log_info("User \"#{user.name}\" (#{user.email}) was created")
+ notification_service.new_user(user, reset_token) if reset_token
+ system_hook_service.execute_hooks_for(user, :create)
+ end
+ end
+end
diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb
index 673ed02f952..0ba6a0ac6b5 100644
--- a/app/services/create_branch_service.rb
+++ b/app/services/create_branch_service.rb
@@ -14,7 +14,7 @@ class CreateBranchService < BaseService
else
error('Invalid reference name')
end
- rescue GitHooksService::PreReceiveError => ex
+ rescue Gitlab::Git::HooksService::PreReceiveError => ex
error(ex.message)
end
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 63b85c3de7d..88dfb7a4a90 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -16,6 +16,7 @@ class CreateDeploymentService
ActiveRecord::Base.transaction do
environment.external_url = expanded_environment_url if
expanded_environment_url
+
environment.fire_state_event(action)
return unless environment.save
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 64b3c0118fb..1f059c31944 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -16,7 +16,7 @@ class DeleteBranchService < BaseService
else
error('Failed to remove branch')
end
- rescue GitHooksService::PreReceiveError => ex
+ rescue Gitlab::Git::HooksService::PreReceiveError => ex
error(ex.message)
end
diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb
index 3b611588466..c98d1e3c540 100644
--- a/app/services/delete_merged_branches_service.rb
+++ b/app/services/delete_merged_branches_service.rb
@@ -6,10 +6,11 @@ class DeleteMergedBranchesService < BaseService
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project)
- branches = project.repository.branch_names
- branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) }
+ branches = project.repository.merged_branch_names
# Prevent deletion of branches relevant to open merge requests
branches -= merge_request_branch_names
+ # Prevent deletion of protected branches
+ branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) }
branches.each do |branch|
DeleteBranchService.new(project, current_user).execute(branch)
diff --git a/app/services/deploy_keys/create_service.rb b/app/services/deploy_keys/create_service.rb
new file mode 100644
index 00000000000..16de3d08df2
--- /dev/null
+++ b/app/services/deploy_keys/create_service.rb
@@ -0,0 +1,7 @@
+module DeployKeys
+ class CreateService < Keys::BaseService
+ def execute
+ DeployKey.create(params.merge(user: user))
+ end
+ end
+end
diff --git a/app/services/discussions/update_diff_position_service.rb b/app/services/discussions/update_diff_position_service.rb
index 1ef8d9edbe1..746f209e20f 100644
--- a/app/services/discussions/update_diff_position_service.rb
+++ b/app/services/discussions/update_diff_position_service.rb
@@ -10,6 +10,10 @@ module Discussions
discussion.notes.each do |note|
if outdated
note.change_position = position
+
+ if project.resolve_outdated_diff_discussions?
+ note.resolve_without_save(current_user, resolved_by_push: true)
+ end
else
note.position = position
note.change_position = nil
diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb
index ace49889097..5bbceeb3b3f 100644
--- a/app/services/emails/base_service.rb
+++ b/app/services/emails/base_service.rb
@@ -1,8 +1,8 @@
module Emails
class BaseService
- def initialize(user, opts)
- @user = user
- @email = opts[:email]
+ def initialize(current_user, params = {})
+ @current_user, @params = current_user, params.dup
+ @user = params.delete(:user)
end
end
end
diff --git a/app/services/emails/confirm_service.rb b/app/services/emails/confirm_service.rb
new file mode 100644
index 00000000000..b5301bf2b82
--- /dev/null
+++ b/app/services/emails/confirm_service.rb
@@ -0,0 +1,7 @@
+module Emails
+ class ConfirmService < ::Emails::BaseService
+ def execute(email)
+ email.resend_confirmation_instructions
+ end
+ end
+end
diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb
index b6491ee9804..94a841af7c3 100644
--- a/app/services/emails/create_service.rb
+++ b/app/services/emails/create_service.rb
@@ -1,7 +1,7 @@
module Emails
class CreateService < ::Emails::BaseService
- def execute
- @user.emails.create(email: @email)
+ def execute(extra_params = {})
+ @user.emails.create(@params.merge(extra_params))
end
end
end
diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb
index d586b9dfe0c..1ed131fe326 100644
--- a/app/services/emails/destroy_service.rb
+++ b/app/services/emails/destroy_service.rb
@@ -1,13 +1,13 @@
module Emails
class DestroyService < ::Emails::BaseService
- def execute
- Email.find_by_email!(@email).destroy && update_secondary_emails!
+ def execute(email)
+ email.destroy && update_secondary_emails!
end
private
def update_secondary_emails!
- result = ::Users::UpdateService.new(@user).execute do |user|
+ result = ::Users::UpdateService.new(@current_user, user: @user).execute do |user|
user.update_secondary_emails!
end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 0f3a485a3fd..44dc90b3462 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -71,7 +71,21 @@ class EventCreateService
end
def push(project, current_user, push_data)
- create_event(project, current_user, Event::PUSHED, data: push_data)
+ # We're using an explicit transaction here so that any errors that may occur
+ # when creating push payload data will result in the event creation being
+ # rolled back as well.
+ event = Event.transaction do
+ new_event = create_event(project, current_user, Event::PUSHED)
+
+ PushEventPayloadService
+ .new(new_event, push_data)
+ .execute
+
+ new_event
+ end
+
+ Users::LastPushEventService.new(current_user)
+ .cache_last_push_event(event)
Users::ActivityService.new(current_user, 'push').execute
end
@@ -89,6 +103,6 @@ class EventCreateService
author_id: current_user.id
)
- Event.create(attributes)
+ Event.create!(attributes)
end
end
diff --git a/app/services/events/render_service.rb b/app/services/events/render_service.rb
new file mode 100644
index 00000000000..0b62d8aedf1
--- /dev/null
+++ b/app/services/events/render_service.rb
@@ -0,0 +1,21 @@
+module Events
+ class RenderService < BaseRenderer
+ def execute(events, atom_request: false)
+ events.map(&:note).compact.group_by(&:project).each do |project, notes|
+ render_notes(notes, project, atom_request)
+ end
+ end
+
+ private
+
+ def render_notes(notes, project, atom_request)
+ Notes::RenderService.new(current_user).execute(notes, project, render_options(atom_request))
+ end
+
+ def render_options(atom_request)
+ return {} unless atom_request
+
+ { only_path: false, xhtml: true }
+ end
+ end
+end
diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb
index 38231f66009..8d4b9f14780 100644
--- a/app/services/files/base_service.rb
+++ b/app/services/files/base_service.rb
@@ -1,11 +1,14 @@
module Files
class BaseService < Commits::CreateService
+ FileChangedError = Class.new(StandardError)
+
def initialize(*args)
super
@author_email = params[:author_email]
@author_name = params[:author_name]
@commit_message = params[:commit_message]
+ @last_commit_sha = params[:last_commit_sha]
@file_path = params[:file_path]
@previous_path = params[:previous_path]
@@ -13,5 +16,16 @@ module Files
@file_content = params[:file_content]
@file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64'
end
+
+ def file_has_changed?(path, commit_id)
+ return false unless commit_id
+
+ last_commit = Gitlab::Git::Commit
+ .last_for_path(@start_project.repository, @start_branch, path)
+
+ return false unless last_commit
+
+ last_commit.sha != commit_id
+ end
end
end
diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb
index 00a8dcf0934..a954564946b 100644
--- a/app/services/files/create_service.rb
+++ b/app/services/files/create_service.rb
@@ -1,10 +1,20 @@
module Files
class CreateService < Files::BaseService
def create_commit!
+ transformer = Lfs::FileTransformer.new(project, @branch_name)
+
+ result = transformer.new_file(@file_path, @file_content)
+
+ create_transformed_commit(result.content)
+ end
+
+ private
+
+ def create_transformed_commit(content_or_lfs_pointer)
repository.create_file(
current_user,
@file_path,
- @file_content,
+ content_or_lfs_pointer,
message: @commit_message,
branch_name: @branch_name,
author_email: @author_email,
diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb
index 7952e5c95d4..32a57484d4e 100644
--- a/app/services/files/delete_service.rb
+++ b/app/services/files/delete_service.rb
@@ -11,5 +11,15 @@ module Files
start_project: @start_project,
start_branch_name: @start_branch)
end
+
+ private
+
+ def validate!
+ super
+
+ if file_has_changed?(@file_path, @last_commit_sha)
+ raise FileChangedError, "You are attempting to delete a file that has been previously updated."
+ end
+ end
end
end
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index bfacc462847..13a1dee4173 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -1,31 +1,57 @@
module Files
class MultiService < Files::BaseService
+ UPDATE_FILE_ACTIONS = %w(update move delete).freeze
+
def create_commit!
+ transformer = Lfs::FileTransformer.new(project, @branch_name)
+
+ actions = actions_after_lfs_transformation(transformer, params[:actions])
+
+ commit_actions!(actions)
+ end
+
+ private
+
+ def actions_after_lfs_transformation(transformer, actions)
+ actions.map do |action|
+ if action[:action] == 'create'
+ result = transformer.new_file(action[:file_path], action[:content], encoding: action[:encoding])
+ action[:content] = result.content
+ action[:encoding] = result.encoding
+ end
+
+ action
+ end
+ end
+
+ def commit_actions!(actions)
repository.multi_action(
- user: current_user,
+ current_user,
message: @commit_message,
branch_name: @branch_name,
- actions: params[:actions],
+ actions: actions,
author_email: @author_email,
author_name: @author_name,
start_project: @start_project,
start_branch_name: @start_branch
)
+ rescue ArgumentError => e
+ raise_error(e)
end
- private
-
def validate!
super
- params[:actions].each do |action|
- validate_action!(action)
- end
+ params[:actions].each { |action| validate_file_status!(action) }
end
- def validate_action!(action)
- unless Gitlab::Git::Index::ACTIONS.include?(action[:action].to_s)
- raise_error("Unknown action '#{action[:action]}'")
+ def validate_file_status!(action)
+ return unless UPDATE_FILE_ACTIONS.include?(action[:action])
+
+ file_path = action[:previous_path] || action[:file_path]
+
+ if file_has_changed?(file_path, action[:last_commit_id])
+ raise_error("The file has changed since you started editing it: #{file_path}")
end
end
end
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index bcca1386bed..1902d1cea72 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -1,13 +1,5 @@
module Files
class UpdateService < Files::BaseService
- FileChangedError = Class.new(StandardError)
-
- def initialize(*args)
- super
-
- @last_commit_sha = params[:last_commit_sha]
- end
-
def create_commit!
repository.update_file(current_user, @file_path, @file_content,
message: @commit_message,
@@ -21,21 +13,10 @@ module Files
private
- def file_has_changed?
- return false unless @last_commit_sha && last_commit
-
- @last_commit_sha != last_commit.sha
- end
-
- def last_commit
- @last_commit ||= Gitlab::Git::Commit
- .last_for_path(@start_project.repository, @start_branch, @file_path)
- end
-
def validate!
super
- if file_has_changed?
+ if file_has_changed?(@file_path, @last_commit_sha)
raise FileChangedError, "You are attempting to update a file that has changed since you started editing it."
end
end
diff --git a/app/services/git_hooks_service.rb b/app/services/git_hooks_service.rb
deleted file mode 100644
index d222d1e63aa..00000000000
--- a/app/services/git_hooks_service.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-class GitHooksService
- PreReceiveError = Class.new(StandardError)
-
- attr_accessor :oldrev, :newrev, :ref
-
- def execute(user, repo_path, oldrev, newrev, ref)
- @repo_path = repo_path
- @user = Gitlab::GlId.gl_id(user)
- @oldrev = oldrev
- @newrev = newrev
- @ref = ref
-
- %w(pre-receive update).each do |hook_name|
- status, message = run_hook(hook_name)
-
- unless status
- raise PreReceiveError, message
- end
- end
-
- yield(self).tap do
- run_hook('post-receive')
- end
- end
-
- private
-
- def run_hook(name)
- hook = Gitlab::Git::Hook.new(name, @repo_path)
- hook.trigger(@user, oldrev, newrev, ref)
- end
-end
diff --git a/app/services/git_operation_service.rb b/app/services/git_operation_service.rb
deleted file mode 100644
index ed6ea638235..00000000000
--- a/app/services/git_operation_service.rb
+++ /dev/null
@@ -1,156 +0,0 @@
-class GitOperationService
- attr_reader :user, :repository
-
- def initialize(new_user, new_repository)
- @user = new_user
- @repository = new_repository
- end
-
- def add_branch(branch_name, newrev)
- ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name
- oldrev = Gitlab::Git::BLANK_SHA
-
- update_ref_in_hooks(ref, newrev, oldrev)
- end
-
- def rm_branch(branch)
- ref = Gitlab::Git::BRANCH_REF_PREFIX + branch.name
- oldrev = branch.target
- newrev = Gitlab::Git::BLANK_SHA
-
- update_ref_in_hooks(ref, newrev, oldrev)
- end
-
- def add_tag(tag_name, newrev, options = {})
- ref = Gitlab::Git::TAG_REF_PREFIX + tag_name
- oldrev = Gitlab::Git::BLANK_SHA
-
- with_hooks(ref, newrev, oldrev) do |service|
- # We want to pass the OID of the tag object to the hooks. For an
- # annotated tag we don't know that OID until after the tag object
- # (raw_tag) is created in the repository. That is why we have to
- # update the value after creating the tag object. Only the
- # "post-receive" hook will receive the correct value in this case.
- raw_tag = repository.rugged.tags.create(tag_name, newrev, options)
- service.newrev = raw_tag.target_id
- end
- end
-
- def rm_tag(tag)
- ref = Gitlab::Git::TAG_REF_PREFIX + tag.name
- oldrev = tag.target
- newrev = Gitlab::Git::BLANK_SHA
-
- update_ref_in_hooks(ref, newrev, oldrev) do
- repository.rugged.tags.delete(tag_name)
- end
- end
-
- # Whenever `start_branch_name` is passed, if `branch_name` doesn't exist,
- # it would be created from `start_branch_name`.
- # If `start_project` is passed, and the branch doesn't exist,
- # it would try to find the commits from it instead of current repository.
- def with_branch(
- branch_name,
- start_branch_name: nil,
- start_project: repository.project,
- &block)
-
- start_repository = start_project.repository
- start_branch_name = nil if start_repository.empty_repo?
-
- if start_branch_name && !start_repository.branch_exists?(start_branch_name)
- raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.path_with_namespace}"
- end
-
- update_branch_with_hooks(branch_name) do
- repository.with_repo_branch_commit(
- start_repository,
- start_branch_name || branch_name,
- &block)
- end
- end
-
- private
-
- def update_branch_with_hooks(branch_name)
- update_autocrlf_option
-
- was_empty = repository.empty?
-
- # Make commit
- newrev = yield
-
- unless newrev
- raise Repository::CommitError.new('Failed to create commit')
- end
-
- branch = repository.find_branch(branch_name)
- oldrev = find_oldrev_from_branch(newrev, branch)
-
- ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name
- update_ref_in_hooks(ref, newrev, oldrev)
-
- # If repo was empty expire cache
- repository.after_create if was_empty
- repository.after_create_branch if
- was_empty || Gitlab::Git.blank_ref?(oldrev)
-
- newrev
- end
-
- def find_oldrev_from_branch(newrev, branch)
- return Gitlab::Git::BLANK_SHA unless branch
-
- oldrev = branch.target
-
- if oldrev == repository.rugged.merge_base(newrev, branch.target)
- oldrev
- else
- raise Repository::CommitError.new('Branch diverged')
- end
- end
-
- def update_ref_in_hooks(ref, newrev, oldrev)
- with_hooks(ref, newrev, oldrev) do
- update_ref(ref, newrev, oldrev)
- end
- end
-
- def with_hooks(ref, newrev, oldrev)
- GitHooksService.new.execute(
- user,
- repository.path_to_repo,
- oldrev,
- newrev,
- ref) do |service|
-
- yield(service)
- end
- end
-
- def update_ref(ref, newrev, oldrev)
- # We use 'git update-ref' because libgit2/rugged currently does not
- # offer 'compare and swap' ref updates. Without compare-and-swap we can
- # (and have!) accidentally reset the ref to an earlier state, clobbering
- # commits. See also https://github.com/libgit2/libgit2/issues/1534.
- command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z]
- _, status = Gitlab::Popen.popen(
- command,
- repository.path_to_repo) do |stdin|
- stdin.write("update #{ref}\x00#{newrev}\x00#{oldrev}\x00")
- end
-
- unless status.zero?
- raise Repository::CommitError.new(
- "Could not update branch #{Gitlab::Git.branch_name(ref)}." \
- " Please refresh and try again.")
- end
- end
-
- def update_autocrlf_option
- if repository.raw_repository.autocrlf != :input
- repository.raw_repository.autocrlf = :input
- end
- end
-end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 20d1fb29289..c037141fcde 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -1,6 +1,5 @@
class GitPushService < BaseService
attr_accessor :push_data, :push_commits
- include Gitlab::CurrentSettings
include Gitlab::Access
# The N most recent commits to process in a single push payload.
@@ -30,7 +29,7 @@ class GitPushService < BaseService
@project.repository.after_create_branch
# Re-find the pushed commits.
- if is_default_branch?
+ if default_branch?
# Initial push to the default branch. Take the full history of that branch as "newly pushed".
process_default_branch
else
@@ -45,17 +44,20 @@ class GitPushService < BaseService
elsif push_to_existing_branch?
# Collect data for this git push
@push_commits = @project.repository.commits_between(params[:oldrev], params[:newrev])
+
process_commit_messages
# Update the bare repositories info/attributes file using the contents of the default branches
# .gitattributes file
- update_gitattributes if is_default_branch?
+ update_gitattributes if default_branch?
end
execute_related_hooks
perform_housekeeping
update_caches
+
+ update_signatures
end
def update_gitattributes
@@ -63,16 +65,22 @@ class GitPushService < BaseService
end
def update_caches
- if is_default_branch?
- paths = Set.new
+ if default_branch?
+ if push_to_new_branch?
+ # If this is the initial push into the default branch, the file type caches
+ # will already be reset as a result of `Project#change_head`.
+ types = []
+ else
+ paths = Set.new
- @push_commits.each do |commit|
- commit.raw_deltas.each do |diff|
- paths << diff.new_path
+ @push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
+ commit.raw_deltas.each do |diff|
+ paths << diff.new_path
+ end
end
- end
- types = Gitlab::FileDetector.types_in_paths(paths.to_a)
+ types = Gitlab::FileDetector.types_in_paths(paths.to_a)
+ end
else
types = []
end
@@ -80,11 +88,28 @@ class GitPushService < BaseService
ProjectCacheWorker.perform_async(@project.id, types, [:commit_count, :repository_size])
end
+ def update_signatures
+ commit_shas = @push_commits.last(PROCESS_COMMIT_LIMIT).map(&:sha)
+
+ return if commit_shas.empty?
+
+ shas_with_cached_signatures = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
+ commit_shas -= shas_with_cached_signatures
+
+ return if commit_shas.empty?
+
+ commit_shas = Gitlab::Git::Commit.shas_with_signatures(project.repository, commit_shas)
+
+ commit_shas.each do |sha|
+ CreateGpgSignatureWorker.perform_async(sha, project.id)
+ end
+ end
+
# Schedules processing of commit messages.
def process_commit_messages
- default = is_default_branch?
+ default = default_branch?
- push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
+ @push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
if commit.matches_cross_reference_regex?
ProcessCommitWorker
.perform_async(project.id, current_user.id, commit.to_hash, default)
@@ -103,7 +128,7 @@ class GitPushService < BaseService
EventCreateService.new.push(@project, current_user, build_push_data)
Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute(:push)
-
+
SystemHookPushWorker.perform_async(build_push_data.dup, :push_hooks)
@project.execute_hooks(build_push_data.dup, :push_hooks)
@project.execute_services(build_push_data.dup, :push_hooks)
@@ -123,26 +148,12 @@ class GitPushService < BaseService
end
def process_default_branch
- @push_commits = project.repository.commits(params[:newrev])
-
- # Ensure HEAD points to the default branch in case it is not master
- project.change_head(branch_name)
+ @push_commits_count = project.repository.commit_count_for_ref(params[:ref])
- # Set protection on the default branch if configured
- if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch)
+ offset = [@push_commits_count - PROCESS_COMMIT_LIMIT, 0].max
+ @push_commits = project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
- params = {
- name: @project.default_branch,
- push_access_levels_attributes: [{
- access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }],
- merge_access_levels_attributes: [{
- access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }]
- }
-
- ProtectedBranches::CreateService.new(@project, current_user, params).execute
- end
+ @project.after_create_default_branch
end
def build_push_data
@@ -152,7 +163,8 @@ class GitPushService < BaseService
params[:oldrev],
params[:newrev],
params[:ref],
- push_commits)
+ @push_commits,
+ commits_count: @push_commits_count)
end
def push_to_existing_branch?
@@ -172,7 +184,7 @@ class GitPushService < BaseService
Gitlab::Git.branch_ref?(params[:ref])
end
- def is_default_branch?
+ def default_branch?
Gitlab::Git.branch_ref?(params[:ref]) &&
(Gitlab::Git.ref_name(params[:ref]) == project.default_branch || project.default_branch.nil?)
end
diff --git a/app/services/gpg_keys/create_service.rb b/app/services/gpg_keys/create_service.rb
new file mode 100644
index 00000000000..e822a89c4d3
--- /dev/null
+++ b/app/services/gpg_keys/create_service.rb
@@ -0,0 +1,9 @@
+module GpgKeys
+ class CreateService < Keys::BaseService
+ def execute
+ key = user.gpg_keys.create(params)
+ notification_service.new_gpg_key(key) if key.persisted?
+ key
+ end
+ end
+end
diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb
index e77e08aa380..c6e52c3bb91 100644
--- a/app/services/gravatar_service.rb
+++ b/app/services/gravatar_service.rb
@@ -1,8 +1,6 @@
class GravatarService
- include Gitlab::CurrentSettings
-
def execute(email, size = nil, scale = 2, username: nil)
- return unless current_application_settings.gravatar_enabled?
+ return unless Gitlab::CurrentSettings.gravatar_enabled?
identifier = email.presence || username.presence
return unless identifier
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index c4e9b8fd8e0..70e50aa0f12 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -8,15 +8,7 @@ module Groups
def execute
@group = Group.new(params)
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level])
- deny_visibility_level(@group)
- return @group
- end
-
- if @group.parent && !can?(current_user, :admin_group, @group.parent)
- @group.parent = nil
- @group.errors.add(:parent_id, 'manage access required to create subgroup')
-
+ unless can_use_visibility_level? && can_create_group?
return @group
end
@@ -39,5 +31,33 @@ module Groups
def create_chat_team?
Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil?
end
+
+ def can_create_group?
+ if @group.subgroup?
+ unless can?(current_user, :create_subgroup, @group.parent)
+ @group.parent = nil
+ @group.errors.add(:parent_id, 'You don’t have permission to create a subgroup in this group.')
+
+ return false
+ end
+ else
+ unless can?(current_user, :create_group)
+ @group.errors.add(:base, 'You don’t have permission to create groups.')
+
+ return false
+ end
+ end
+
+ true
+ end
+
+ def can_use_visibility_level?
+ unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level])
+ deny_visibility_level(@group)
+ return false
+ end
+
+ true
+ end
end
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index 497fdb09cdc..58e88688dfa 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -1,8 +1,6 @@
module Groups
class DestroyService < Groups::BaseService
def async_execute
- # Soft delete via paranoia gem
- group.destroy
job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
end
@@ -10,11 +8,11 @@ module Groups
def execute
group.prepare_for_destroy
- group.projects.with_deleted.each do |project|
+ group.projects.each do |project|
# Execute the destruction of the models immediately to ensure atomic cleanup.
# Skip repository removal because we remove directory with namespace
# that contain all these repositories
- ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
+ ::Projects::DestroyService.new(project, current_user, skip_repo: project.legacy_storage?).execute
end
group.children.each do |group|
@@ -22,7 +20,9 @@ module Groups
DestroyService.new(group, current_user).execute
end
- group.really_destroy!
+ group.chat_team&.remove_mattermost_team(current_user)
+
+ group.destroy
end
end
end
diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb
new file mode 100644
index 00000000000..5c337a9faa5
--- /dev/null
+++ b/app/services/groups/nested_create_service.rb
@@ -0,0 +1,53 @@
+module Groups
+ class NestedCreateService < Groups::BaseService
+ attr_reader :group_path
+
+ def initialize(user, params)
+ @current_user, @params = user, params.dup
+
+ @group_path = @params.delete(:group_path)
+ end
+
+ def execute
+ return nil unless group_path
+
+ if namespace = namespace_or_group(group_path)
+ return namespace
+ end
+
+ if group_path.include?('/') && !Group.supports_nested_groups?
+ raise 'Nested groups are not supported on MySQL'
+ end
+
+ create_group_path
+ end
+
+ private
+
+ def create_group_path
+ group_path_segments = group_path.split('/')
+
+ last_group = nil
+ partial_path_segments = []
+ while subgroup_name = group_path_segments.shift
+ partial_path_segments << subgroup_name
+ partial_path = partial_path_segments.join('/')
+
+ new_params = params.reverse_merge(
+ path: subgroup_name,
+ name: subgroup_name,
+ parent: last_group
+ )
+ new_params[:visibility_level] ||= Gitlab::CurrentSettings.current_application_settings.default_group_visibility
+
+ last_group = namespace_or_group(partial_path) || Groups::CreateService.new(current_user, new_params).execute
+ end
+
+ last_group
+ end
+
+ def namespace_or_group(group_path)
+ Namespace.find_by_full_path(group_path)
+ end
+ end
+end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
new file mode 100644
index 00000000000..e591c820cff
--- /dev/null
+++ b/app/services/groups/transfer_service.rb
@@ -0,0 +1,96 @@
+module Groups
+ class TransferService < Groups::BaseService
+ ERROR_MESSAGES = {
+ database_not_supported: 'Database is not supported.',
+ namespace_with_same_path: 'The parent group already has a subgroup with the same path.',
+ group_is_already_root: 'Group is already a root group.',
+ same_parent_as_current: 'Group is already associated to the parent group.',
+ invalid_policies: "You don't have enough permissions."
+ }.freeze
+
+ TransferError = Class.new(StandardError)
+
+ attr_reader :error
+
+ def initialize(group, user, params = {})
+ super
+ @error = nil
+ end
+
+ def execute(new_parent_group)
+ @new_parent_group = new_parent_group
+ ensure_allowed_transfer
+ proceed_to_transfer
+
+ rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e
+ @group.errors.clear
+ @error = "Transfer failed: " + e.message
+ false
+ end
+
+ private
+
+ def proceed_to_transfer
+ Group.transaction do
+ update_group_attributes
+ end
+ end
+
+ def ensure_allowed_transfer
+ raise_transfer_error(:group_is_already_root) if group_is_already_root?
+ raise_transfer_error(:database_not_supported) unless Group.supports_nested_groups?
+ raise_transfer_error(:same_parent_as_current) if same_parent?
+ raise_transfer_error(:invalid_policies) unless valid_policies?
+ raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
+ end
+
+ def group_is_already_root?
+ !@new_parent_group && !@group.has_parent?
+ end
+
+ def same_parent?
+ @new_parent_group && @new_parent_group.id == @group.parent_id
+ end
+
+ def valid_policies?
+ return false unless can?(current_user, :admin_group, @group)
+
+ if @new_parent_group
+ can?(current_user, :create_subgroup, @new_parent_group)
+ else
+ can?(current_user, :create_group)
+ end
+ end
+
+ def namespace_with_same_path?
+ Namespace.exists?(path: @group.path, parent: @new_parent_group)
+ end
+
+ def update_group_attributes
+ if @new_parent_group && @new_parent_group.visibility_level < @group.visibility_level
+ update_children_and_projects_visibility
+ @group.visibility_level = @new_parent_group.visibility_level
+ end
+
+ @group.parent = @new_parent_group
+ @group.save!
+ end
+
+ def update_children_and_projects_visibility
+ descendants = @group.descendants.where("visibility_level > ?", @new_parent_group.visibility_level)
+
+ Group
+ .where(id: descendants.select(:id))
+ .update_all(visibility_level: @new_parent_group.visibility_level)
+
+ @group
+ .all_projects
+ .where("visibility_level > ?", @new_parent_group.visibility_level)
+ .update_all(visibility_level: @new_parent_group.visibility_level)
+ end
+
+ def raise_transfer_error(message)
+ raise TransferError, ERROR_MESSAGES[message]
+ end
+ end
+end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 1d65c76d282..08e3efb96e3 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -1,18 +1,13 @@
module Groups
class UpdateService < Groups::BaseService
+ include UpdateVisibilityLevel
+
def execute
reject_parent_id!
- # check that user is allowed to set specified visibility_level
- new_visibility = params[:visibility_level]
- if new_visibility && new_visibility.to_i != group.visibility_level
- unless can?(current_user, :change_visibility_level, group) &&
- Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
+ return false unless valid_visibility_level_change?(group, params[:visibility_level])
- deny_visibility_level(group, new_visibility)
- return group
- end
- end
+ return false unless valid_share_with_group_lock_change?
group.assign_attributes(params)
@@ -30,5 +25,19 @@ module Groups
def reject_parent_id!
params.except!(:parent_id)
end
+
+ def valid_share_with_group_lock_change?
+ return true unless changing_share_with_group_lock?
+ return true if can?(current_user, :change_share_with_group_lock, group)
+
+ group.errors.add(:share_with_group_lock, s_('GroupSettings|cannot be disabled when the parent group "Share with group lock" is enabled, except by the owner of the parent group'))
+ false
+ end
+
+ def changing_share_with_group_lock?
+ return false if params[:share_with_group_lock].nil?
+
+ params[:share_with_group_lock] != group.share_with_group_lock
+ end
end
end
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
new file mode 100644
index 00000000000..3da21bd8b8f
--- /dev/null
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -0,0 +1,93 @@
+module Issuable
+ class CommonSystemNotesService < ::BaseService
+ attr_reader :issuable
+
+ def execute(issuable, old_labels)
+ @issuable = issuable
+
+ if issuable.previous_changes.include?('title')
+ create_title_change_note(issuable.previous_changes['title'].first)
+ end
+
+ handle_description_change_note
+
+ handle_time_tracking_note if issuable.is_a?(TimeTrackable)
+ create_labels_note(old_labels) if issuable.labels != old_labels
+ create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
+ create_milestone_note if issuable.previous_changes.include?('milestone_id')
+ end
+
+ private
+
+ def handle_time_tracking_note
+ if issuable.previous_changes.include?('time_estimate')
+ create_time_estimate_note
+ end
+
+ if issuable.time_spent?
+ create_time_spent_note
+ end
+ end
+
+ def handle_description_change_note
+ if issuable.previous_changes.include?('description')
+ if issuable.tasks? && issuable.updated_tasks.any?
+ create_task_status_note
+ else
+ # TODO: Show this note if non-task content was modified.
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/33577
+ create_description_change_note
+ end
+ end
+ end
+
+ def create_wip_note(old_title)
+ return unless issuable.is_a?(MergeRequest)
+
+ if MergeRequest.work_in_progress?(old_title) != issuable.work_in_progress?
+ SystemNoteService.handle_merge_request_wip(issuable, issuable.project, current_user)
+ end
+ end
+
+ def create_labels_note(old_labels)
+ added_labels = issuable.labels - old_labels
+ removed_labels = old_labels - issuable.labels
+
+ SystemNoteService.change_label(issuable, issuable.project, current_user, added_labels, removed_labels)
+ end
+
+ def create_title_change_note(old_title)
+ create_wip_note(old_title)
+
+ if issuable.wipless_title_changed(old_title)
+ SystemNoteService.change_title(issuable, issuable.project, current_user, old_title)
+ end
+ end
+
+ def create_description_change_note
+ SystemNoteService.change_description(issuable, issuable.project, current_user)
+ end
+
+ def create_task_status_note
+ issuable.updated_tasks.each do |task|
+ SystemNoteService.change_task_status(issuable, issuable.project, current_user, task)
+ end
+ end
+
+ def create_time_estimate_note
+ SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
+ end
+
+ def create_time_spent_note
+ SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user)
+ end
+
+ def create_milestone_note
+ SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone)
+ end
+
+ def create_discussion_lock_note
+ SystemNoteService.discussion_lock(issuable, current_user)
+ end
+ end
+end
diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb
new file mode 100644
index 00000000000..7197a426a72
--- /dev/null
+++ b/app/services/issuable/destroy_service.rb
@@ -0,0 +1,11 @@
+module Issuable
+ class DestroyService < IssuableBaseService
+ def execute(issuable)
+ TodoService.new.destroy_target(issuable) do |issuable|
+ if issuable.destroy
+ issuable.update_project_counter_caches
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 8dd0846f3bc..02fb48108fb 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -1,52 +1,10 @@
class IssuableBaseService < BaseService
private
- def create_milestone_note(issuable)
- SystemNoteService.change_milestone(
- issuable, issuable.project, current_user, issuable.milestone)
- end
-
- def create_labels_note(issuable, old_labels)
- added_labels = issuable.labels - old_labels
- removed_labels = old_labels - issuable.labels
-
- SystemNoteService.change_label(
- issuable, issuable.project, current_user, added_labels, removed_labels)
- end
-
- def create_title_change_note(issuable, old_title)
- SystemNoteService.change_title(
- issuable, issuable.project, current_user, old_title)
- end
-
- def create_description_change_note(issuable)
- SystemNoteService.change_description(issuable, issuable.project, current_user)
- end
-
- def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
- SystemNoteService.change_branch(
- issuable, issuable.project, current_user, branch_type,
- old_branch, new_branch)
- end
-
- def create_task_status_note(issuable)
- issuable.updated_tasks.each do |task|
- SystemNoteService.change_task_status(issuable, issuable.project, current_user, task)
- end
- end
-
- def create_time_estimate_note(issuable)
- SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
- end
-
- def create_time_spent_note(issuable)
- SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
- end
-
def filter_params(issuable)
ability_name = :"admin_#{issuable.to_ability_name}"
- unless can?(current_user, ability_name, project)
+ unless can?(current_user, ability_name, issuable)
params.delete(:milestone_id)
params.delete(:labels)
params.delete(:add_label_ids)
@@ -55,6 +13,9 @@ class IssuableBaseService < BaseService
params.delete(:assignee_ids)
params.delete(:assignee_id)
params.delete(:due_date)
+ params.delete(:canonical_issue_id)
+ params.delete(:project)
+ params.delete(:discussion_locked)
end
filter_assignee(issuable)
@@ -89,10 +50,12 @@ class IssuableBaseService < BaseService
milestone_id = params[:milestone_id]
return unless milestone_id
- if milestone_id == IssuableFinder::NONE ||
- project.milestones.find_by(id: milestone_id).nil?
- params[:milestone_id] = ''
- end
+ params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
+
+ milestone =
+ Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id)
+
+ params[:milestone_id] = '' unless milestone
end
def filter_labels
@@ -114,8 +77,12 @@ class IssuableBaseService < BaseService
return unless labels
params[:label_ids] = labels.split(",").map do |label_name|
- service = Labels::FindOrCreateService.new(current_user, project, title: label_name.strip)
- label = service.execute
+ label = Labels::FindOrCreateService.new(
+ current_user,
+ parent,
+ title: label_name.strip,
+ available_labels: available_labels
+ ).execute
label.try(:id)
end.compact
@@ -139,16 +106,22 @@ class IssuableBaseService < BaseService
end
def available_labels
- LabelsFinder.new(current_user, project_id: @project.id).execute
+ @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
+ end
+
+ def handle_quick_actions_on_create(issuable)
+ merge_quick_actions_into_params!(issuable)
end
def merge_quick_actions_into_params!(issuable)
+ original_description = params.fetch(:description, issuable.description)
+
description, command_params =
QuickActions::InterpretService.new(project, current_user)
- .execute(params[:description], issuable)
+ .execute(original_description, issuable)
# Avoid a description already set on an issuable to be overwritten by a nil
- params[:description] = description if params.key?(:description)
+ params[:description] = description if description
params.merge!(command_params)
end
@@ -162,7 +135,7 @@ class IssuableBaseService < BaseService
end
def create(issuable)
- merge_quick_actions_into_params!(issuable)
+ handle_quick_actions_on_create(issuable)
filter_params(issuable)
params.delete(:state_event)
@@ -176,9 +149,9 @@ class IssuableBaseService < BaseService
if params.present? && create_issuable(issuable, params, label_ids: label_ids)
after_create(issuable)
- issuable.create_cross_references!(current_user)
execute_hooks(issuable)
- invalidate_cache_counts(issuable.assignees, issuable)
+ invalidate_cache_counts(issuable, users: issuable.assignees)
+ issuable.update_project_counter_caches
end
issuable
@@ -206,9 +179,7 @@ class IssuableBaseService < BaseService
change_todo(issuable)
toggle_award(issuable)
filter_params(issuable)
- old_labels = issuable.labels.to_a
- old_mentioned_users = issuable.mentioned_users.to_a
- old_assignees = issuable.assignees.to_a
+ old_associations = associations_before_update(issuable)
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
@@ -222,28 +193,31 @@ class IssuableBaseService < BaseService
before_update(issuable)
+ # We have to perform this check before saving the issuable as Rails resets
+ # the changed fields upon calling #save.
+ update_project_counters = issuable.project && update_project_counter_caches?(issuable)
+
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
- handle_common_system_notes(issuable, old_labels: old_labels)
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_associations[:labels])
end
- handle_changes(
- issuable,
- old_labels: old_labels,
- old_mentioned_users: old_mentioned_users,
- old_assignees: old_assignees
- )
+ handle_changes(issuable, old_associations: old_associations)
- if old_assignees != issuable.assignees
- new_assignees = issuable.assignees.to_a
- affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees)
- invalidate_cache_counts(affected_assignees.compact, issuable)
- end
+ new_assignees = issuable.assignees.to_a
+ affected_assignees = (old_associations[:assignees] + new_assignees) - (old_associations[:assignees] & new_assignees)
+ invalidate_cache_counts(issuable, users: affected_assignees.compact)
after_update(issuable)
issuable.create_new_cross_references!(current_user)
- execute_hooks(issuable, 'update')
+ execute_hooks(
+ issuable,
+ 'update',
+ old_associations: old_associations
+ )
+
+ issuable.update_project_counter_caches if update_project_counters
end
end
@@ -281,8 +255,8 @@ class IssuableBaseService < BaseService
when 'add'
todo_service.mark_todo(issuable, current_user)
when 'done'
- todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
- todo_service.mark_todos_as_done([todo], current_user) if todo
+ todo = TodosFinder.new(current_user).find_by(target: issuable)
+ todo_service.mark_todos_as_done_by_ids(todo, current_user) if todo
end
end
@@ -294,6 +268,18 @@ class IssuableBaseService < BaseService
end
end
+ def associations_before_update(issuable)
+ associations =
+ {
+ labels: issuable.labels.to_a,
+ mentioned_users: issuable.mentioned_users.to_a,
+ assignees: issuable.assignees.to_a
+ }
+ associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent)
+
+ associations
+ end
+
def has_changes?(issuable, old_labels: [], old_assignees: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
@@ -308,35 +294,25 @@ class IssuableBaseService < BaseService
attrs_changed || labels_changed || assignees_changed
end
- def handle_common_system_notes(issuable, old_labels: [])
- if issuable.previous_changes.include?('title')
- create_title_change_note(issuable, issuable.previous_changes['title'].first)
- end
-
- if issuable.previous_changes.include?('description')
- if issuable.tasks? && issuable.updated_tasks.any?
- create_task_status_note(issuable)
- else
- # TODO: Show this note if non-task content was modified.
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/33577
- create_description_change_note(issuable)
- end
+ def invalidate_cache_counts(issuable, users: [])
+ users.each do |user|
+ user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end
+ end
- if issuable.previous_changes.include?('time_estimate')
- create_time_estimate_note(issuable)
- end
+ # override if needed
+ def handle_changes(issuable, options)
+ end
- if issuable.time_spent?
- create_time_spent_note(issuable)
- end
+ # override if needed
+ def execute_hooks(issuable, action = 'open', params = {})
+ end
- create_labels_note(issuable, old_labels) if issuable.labels != old_labels
+ def update_project_counter_caches?(issuable)
+ issuable.state_changed?
end
- def invalidate_cache_counts(users, issuable)
- users.each do |user|
- user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts")
- end
+ def parent
+ project
end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 34199eb5d13..9f6cfc0f6d3 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -1,10 +1,18 @@
module Issues
class BaseService < ::IssuableBaseService
- def hook_data(issue, action)
- issue_data = issue.to_hook_data(current_user)
- issue_url = Gitlab::UrlBuilder.build(issue)
- issue_data[:object_attributes].merge!(url: issue_url, action: action)
- issue_data
+ def hook_data(issue, action, old_associations: {})
+ hook_data = issue.to_hook_data(current_user, old_associations: old_associations)
+ hook_data[:object_attributes][:action] = action
+
+ hook_data
+ end
+
+ def reopen_service
+ Issues::ReopenService
+ end
+
+ def close_service
+ Issues::CloseService
end
private
@@ -14,8 +22,8 @@ module Issues
issue, issue.project, current_user, old_assignees)
end
- def execute_hooks(issue, action = 'open')
- issue_data = hook_data(issue, action)
+ def execute_hooks(issue, action = 'open', old_associations: {})
+ issue_data = hook_data(issue, action, old_associations: old_associations)
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_services(issue_data, hooks_scope)
@@ -37,5 +45,9 @@ module Issues
params.delete(:assignee_ids)
end
end
+
+ def update_project_counter_caches?(issue)
+ super || issue.confidential_changed?
+ end
end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 85c616ca576..0c5cf2c62ad 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -16,19 +16,20 @@ module Issues
# The code calling this method is responsible for ensuring that a user is
# allowed to close the given issue.
def close_issue(issue, commit: nil, notifications: true, system_note: true)
- if project.jira_tracker? && project.jira_service.active
+ if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue)
project.jira_service.close_issue(commit, issue)
todo_service.close_issue(issue, current_user)
return issue
end
- if project.default_issues_tracker? && issue.close
+ if project.issues_enabled? && issue.close
event_service.close_issue(issue, current_user)
create_note(issue, commit) if system_note
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)
+ invalidate_cache_counts(issue, users: issue.assignees)
+ issue.update_project_counter_caches
end
issue
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 718a7ac1f22..0307634c0b6 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -15,14 +15,20 @@ module Issues
def before_create(issue)
spam_check(issue, current_user)
issue.move_to_end
+
+ # current_user (defined in BaseService) is not available within run_after_commit block
+ user = current_user
+ issue.run_after_commit do
+ NewIssueWorker.perform_async(issue.id, user.id)
+ end
end
def after_create(issuable)
- event_service.open_issue(issuable, current_user)
- notification_service.new_issue(issuable, current_user)
todo_service.new_issue(issuable, current_user)
user_agent_detail_service.create
resolve_discussions_with_issue(issuable)
+
+ super
end
def resolve_discussions_with_issue(issue)
diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb
new file mode 100644
index 00000000000..5c0854e664d
--- /dev/null
+++ b/app/services/issues/duplicate_service.rb
@@ -0,0 +1,24 @@
+module Issues
+ class DuplicateService < Issues::BaseService
+ def execute(duplicate_issue, canonical_issue)
+ return if canonical_issue == duplicate_issue
+ return unless can?(current_user, :update_issue, duplicate_issue)
+ return unless can?(current_user, :create_note, canonical_issue)
+
+ create_issue_duplicate_note(duplicate_issue, canonical_issue)
+ create_issue_canonical_note(canonical_issue, duplicate_issue)
+
+ close_service.new(project, current_user, {}).execute(duplicate_issue)
+ end
+
+ private
+
+ def create_issue_duplicate_note(duplicate_issue, canonical_issue)
+ SystemNoteService.mark_duplicate_issue(duplicate_issue, duplicate_issue.project, current_user, canonical_issue)
+ end
+
+ def create_issue_canonical_note(canonical_issue, duplicate_issue)
+ SystemNoteService.mark_canonical_issue_of_duplicate(canonical_issue, canonical_issue.project, current_user, duplicate_issue)
+ end
+ end
+end
diff --git a/app/services/issues/fetch_referenced_merge_requests_service.rb b/app/services/issues/fetch_referenced_merge_requests_service.rb
new file mode 100644
index 00000000000..39c8ded9df4
--- /dev/null
+++ b/app/services/issues/fetch_referenced_merge_requests_service.rb
@@ -0,0 +1,12 @@
+module Issues
+ class FetchReferencedMergeRequestsService < Issues::BaseService
+ def execute(issue)
+ referenced_merge_requests = issue.referenced_merge_requests(current_user)
+ referenced_merge_requests = Gitlab::IssuableSorter.sort(project, referenced_merge_requests) { |i| i.iid.to_s }
+ closed_by_merge_requests = issue.closed_by_merge_requests(current_user)
+ closed_by_merge_requests = Gitlab::IssuableSorter.sort(project, closed_by_merge_requests) { |i| i.iid.to_s }
+
+ [referenced_merge_requests, closed_by_merge_requests]
+ end
+ end
+end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 711f4035c55..7140890d201 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -19,19 +19,10 @@ module Issues
# on rewriting notes (unfolding references)
#
ActiveRecord::Base.transaction do
- # New issue tasks
- #
@new_issue = create_new_issue
- rewrite_notes
- rewrite_award_emoji
- add_note_moved_from
-
- # Old issue tasks
- #
- add_note_moved_to
- close_issue
- mark_as_moved
+ update_new_issue
+ update_old_issue
end
notify_participants
@@ -41,11 +32,24 @@ module Issues
private
+ def update_new_issue
+ rewrite_notes
+ rewrite_issue_award_emoji
+ add_note_moved_from
+ end
+
+ def update_old_issue
+ add_note_moved_to
+ close_issue
+ mark_as_moved
+ end
+
def create_new_issue
new_params = { id: nil, iid: nil, label_ids: cloneable_label_ids,
milestone_id: cloneable_milestone_id,
project: @new_project, author: @old_issue.author,
- description: rewrite_content(@old_issue.description) }
+ description: rewrite_content(@old_issue.description),
+ assignee_ids: @old_issue.assignee_ids }
new_params = @old_issue.serializable_hash.symbolize_keys.merge(new_params)
CreateService.new(@new_project, @current_user, new_params).execute
@@ -61,12 +65,22 @@ module Issues
end
def cloneable_milestone_id
- @new_project.milestones
- .find_by(title: @old_issue.milestone.try(:title)).try(:id)
+ title = @old_issue.milestone&.title
+ return unless title
+
+ if @new_project.group && can?(current_user, :read_group, @new_project.group)
+ group_id = @new_project.group.id
+ end
+
+ params =
+ { title: title, project_ids: @new_project.id, group_ids: group_id }
+
+ milestones = MilestonesFinder.new(params).execute
+ milestones.first&.id
end
def rewrite_notes
- @old_issue.notes.find_each do |note|
+ @old_issue.notes_with_associations.find_each do |note|
new_note = note.dup
new_params = { project: @new_project, noteable: @new_issue,
note: rewrite_content(new_note.note),
@@ -74,13 +88,19 @@ module Issues
updated_at: note.updated_at }
new_note.update(new_params)
+
+ rewrite_award_emoji(note, new_note)
end
end
- def rewrite_award_emoji
- @old_issue.award_emoji.each do |award|
+ def rewrite_issue_award_emoji
+ rewrite_award_emoji(@old_issue, @new_issue)
+ end
+
+ def rewrite_award_emoji(old_awardable, new_awardable)
+ old_awardable.award_emoji.each do |award|
new_award = award.dup
- new_award.awardable = @new_issue
+ new_award.awardable = new_awardable
new_award.save
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 80ea6312768..62b4b4b6a1e 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -5,10 +5,11 @@ module Issues
if issue.reopen
event_service.reopen_issue(issue, current_user)
- create_note(issue)
+ create_note(issue, 'reopened')
notification_service.reopen_issue(issue, current_user)
execute_hooks(issue, 'reopen')
- invalidate_cache_counts(issue.assignees, issue)
+ invalidate_cache_counts(issue, users: issue.assignees)
+ issue.update_project_counter_caches
end
issue
@@ -16,8 +17,8 @@ module Issues
private
- def create_note(issue)
- SystemNoteService.change_status(issue, issue.project, current_user, issue.state, nil)
+ def create_note(issue, state = issue.state)
+ SystemNoteService.change_status(issue, issue.project, current_user, state, nil)
end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index cd9f9a4a16e..d7aa7e2347e 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -3,9 +3,10 @@ module Issues
include SpamCheckService
def execute(issue)
- handle_move_between_iids(issue)
+ handle_move_between_ids(issue)
filter_spam_check_params
- update(issue)
+ change_issue_duplicate(issue)
+ move_issue_to_new_project(issue) || update(issue)
end
def before_update(issue)
@@ -13,9 +14,10 @@ module Issues
end
def handle_changes(issue, options)
- old_labels = options[:old_labels] || []
- old_mentioned_users = options[:old_mentioned_users] || []
- old_assignees = options[:old_assignees] || []
+ old_associations = options.fetch(:old_associations, {})
+ old_labels = old_associations.fetch(:labels, [])
+ old_mentioned_users = old_associations.fetch(:mentioned_users, [])
+ old_assignees = old_associations.fetch(:assignees, [])
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(issue, current_user)
@@ -26,14 +28,10 @@ module Issues
todo_service.update_issue(issue, current_user, old_mentioned_users)
end
- if issue.previous_changes.include?('milestone_id')
- create_milestone_note(issue)
- end
-
if issue.assignees != old_assignees
create_assignee_note(issue, old_assignees)
notification_service.reassigned_issue(issue, current_user, old_assignees)
- todo_service.reassigned_issue(issue, current_user)
+ todo_service.reassigned_issue(issue, current_user, old_assignees)
end
if issue.previous_changes.include?('confidential')
@@ -53,29 +51,41 @@ module Issues
end
end
- def reopen_service
- Issues::ReopenService
- end
+ def handle_move_between_ids(issue)
+ return unless params[:move_between_ids]
+
+ after_id, before_id = params.delete(:move_between_ids)
+
+ issue_before = get_issue_if_allowed(issue.project, before_id) if before_id
+ issue_after = get_issue_if_allowed(issue.project, after_id) if after_id
- def close_service
- Issues::CloseService
+ issue.move_between(issue_before, issue_after)
end
- def handle_move_between_iids(issue)
- return unless params[:move_between_iids]
+ def change_issue_duplicate(issue)
+ canonical_issue_id = params.delete(:canonical_issue_id)
+ canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id)
+
+ if canonical_issue
+ Issues::DuplicateService.new(project, current_user).execute(issue, canonical_issue)
+ end
+ end
- after_iid, before_iid = params.delete(:move_between_iids)
+ def move_issue_to_new_project(issue)
+ target_project = params.delete(:target_project)
- issue_before = get_issue_if_allowed(issue.project, before_iid) if before_iid
- issue_after = get_issue_if_allowed(issue.project, after_iid) if after_iid
+ return unless target_project &&
+ issue.can_move?(current_user, target_project) &&
+ target_project != issue.project
- issue.move_between(issue_before, issue_after)
+ update(issue)
+ Issues::MoveService.new(project, current_user).execute(issue, target_project)
end
private
- def get_issue_if_allowed(project, iid)
- issue = project.issues.find_by(iid: iid)
+ def get_issue_if_allowed(project, id)
+ issue = project.issues.find(id)
issue if can?(current_user, :update_issue, issue)
end
diff --git a/app/services/keys/base_service.rb b/app/services/keys/base_service.rb
new file mode 100644
index 00000000000..f78791932a7
--- /dev/null
+++ b/app/services/keys/base_service.rb
@@ -0,0 +1,14 @@
+module Keys
+ class BaseService
+ attr_accessor :user, :params
+
+ def initialize(user, params)
+ @user, @params = user, params
+ @ip_address = @params.delete(:ip_address)
+ end
+
+ def notification_service
+ NotificationService.new
+ end
+ end
+end
diff --git a/app/services/keys/create_service.rb b/app/services/keys/create_service.rb
new file mode 100644
index 00000000000..e2e5a6c46c5
--- /dev/null
+++ b/app/services/keys/create_service.rb
@@ -0,0 +1,9 @@
+module Keys
+ class CreateService < ::Keys::BaseService
+ def execute
+ key = user.keys.create(params)
+ notification_service.new_key(key) if key.persisted?
+ key
+ end
+ end
+end
diff --git a/app/services/keys/last_used_service.rb b/app/services/keys/last_used_service.rb
new file mode 100644
index 00000000000..dbd79f7da55
--- /dev/null
+++ b/app/services/keys/last_used_service.rb
@@ -0,0 +1,35 @@
+module Keys
+ class LastUsedService
+ TIMEOUT = 1.day.to_i
+
+ attr_reader :key
+
+ # key - The Key for which to update the last used timestamp.
+ def initialize(key)
+ @key = key
+ end
+
+ def execute
+ # We _only_ want to update last_used_at and not also updated_at (which
+ # would be updated when using #touch).
+ key.update_column(:last_used_at, Time.zone.now) if update?
+ end
+
+ def update?
+ return false if ::Gitlab::Database.read_only?
+
+ last_used = key.last_used_at
+
+ return false if last_used && (Time.zone.now - last_used) <= TIMEOUT
+
+ !!redis_lease.try_obtain
+ end
+
+ private
+
+ def redis_lease
+ Gitlab::ExclusiveLease
+ .new("key_update_last_used_at:#{key.id}", timeout: TIMEOUT)
+ end
+ end
+end
diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb
index 940c8b333d3..079f611b3f3 100644
--- a/app/services/labels/find_or_create_service.rb
+++ b/app/services/labels/find_or_create_service.rb
@@ -1,8 +1,9 @@
module Labels
class FindOrCreateService
- def initialize(current_user, project, params = {})
+ def initialize(current_user, parent, params = {})
@current_user = current_user
- @project = project
+ @parent = parent
+ @available_labels = params.delete(:available_labels)
@params = params.dup.with_indifferent_access
end
@@ -13,12 +14,13 @@ module Labels
private
- attr_reader :current_user, :project, :params, :skip_authorization
+ attr_reader :current_user, :parent, :params, :skip_authorization
def available_labels
@available_labels ||= LabelsFinder.new(
current_user,
- project_id: project.id
+ "#{parent_type}_id".to_sym => parent.id,
+ only_group_labels: parent_is_group?
).execute(skip_authorization: skip_authorization)
end
@@ -27,8 +29,8 @@ module Labels
def find_or_create_label
new_label = available_labels.find_by(title: title)
- if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, project))
- new_label = Labels::CreateService.new(params).execute(project: project)
+ if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, parent))
+ new_label = Labels::CreateService.new(params).execute(parent_type.to_sym => parent)
end
new_label
@@ -37,5 +39,13 @@ module Labels
def title
params[:title] || params[:name]
end
+
+ def parent_type
+ parent.model_name.param_key
+ end
+
+ def parent_is_group?
+ parent_type == "group"
+ end
end
end
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index 43b539ded53..74a85e5c9f0 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -13,18 +13,29 @@ module Labels
update_issuables(new_label, batched_ids)
update_issue_board_lists(new_label, batched_ids)
update_priorities(new_label, batched_ids)
+ subscribe_users(new_label, batched_ids)
# Order is important, project labels need to be last
update_project_labels(batched_ids)
end
# We skipped validations during creation. Let's run them now, after deleting conflicting labels
raise ActiveRecord::RecordInvalid.new(new_label) unless new_label.valid?
+
new_label
end
end
private
+ def subscribe_users(new_label, label_ids)
+ # users can be subscribed to multiple labels that will be merged into the group one
+ # we want to keep only one subscription / user
+ ids_to_update = Subscription.where(subscribable_id: label_ids, subscribable_type: 'Label')
+ .group(:user_id)
+ .pluck('MAX(id)')
+ Subscription.where(id: ids_to_update).update_all(subscribable_id: new_label.id)
+ end
+
def label_ids_for_merge(new_label)
LabelsFinder
.new(current_user, title: new_label.title, group_id: project.group.id)
@@ -52,7 +63,7 @@ module Labels
end
def update_project_labels(label_ids)
- Label.where(id: label_ids).delete_all
+ Label.where(id: label_ids).destroy_all
end
def clone_label_to_group_label(label)
diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb
index d2ece354efc..775efed48eb 100644
--- a/app/services/labels/transfer_service.rb
+++ b/app/services/labels/transfer_service.rb
@@ -37,7 +37,7 @@ module Labels
union = Gitlab::SQL::Union.new(label_ids)
- Label.where("labels.id IN (#{union.to_sql})").reorder(nil).uniq
+ Label.where("labels.id IN (#{union.to_sql})").reorder(nil).uniq # rubocop:disable GitlabSecurity/SqlInjection
end
def group_labels_applied_to_issues
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
new file mode 100644
index 00000000000..69281ee3137
--- /dev/null
+++ b/app/services/lfs/file_transformer.rb
@@ -0,0 +1,66 @@
+module Lfs
+ # Usage: Calling `new_file` check to see if a file should be in LFS and
+ # return a transformed result with `content` and `encoding` to commit.
+ #
+ # For LFS an LfsObject linked to the project is stored and an LFS
+ # pointer returned. If the file isn't in LFS the untransformed content
+ # is returned to save in the commit.
+ #
+ # transformer = Lfs::FileTransformer.new(project, @branch_name)
+ # content_or_lfs_pointer = transformer.new_file(file_path, content).content
+ # create_transformed_commit(content_or_lfs_pointer)
+ #
+ class FileTransformer
+ attr_reader :project, :branch_name
+
+ delegate :repository, to: :project
+
+ def initialize(project, branch_name)
+ @project = project
+ @branch_name = branch_name
+ end
+
+ def new_file(file_path, file_content, encoding: nil)
+ if project.lfs_enabled? && lfs_file?(file_path)
+ file_content = Base64.decode64(file_content) if encoding == 'base64'
+ lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content)
+ lfs_object = create_lfs_object!(lfs_pointer_file, file_content)
+
+ link_lfs_object!(lfs_object)
+
+ Result.new(content: lfs_pointer_file.pointer, encoding: 'text')
+ else
+ Result.new(content: file_content, encoding: encoding)
+ end
+ end
+
+ class Result
+ attr_reader :content, :encoding
+
+ def initialize(content:, encoding:)
+ @content = content
+ @encoding = encoding
+ end
+ end
+
+ private
+
+ def lfs_file?(file_path)
+ cached_attributes.attributes(file_path)['filter'] == 'lfs'
+ end
+
+ def cached_attributes
+ @cached_attributes ||= Gitlab::Git::AttributesAtRefParser.new(repository, branch_name)
+ end
+
+ def create_lfs_object!(lfs_pointer_file, file_content)
+ LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object|
+ lfs_object.file = CarrierWaveStringFile.new(file_content)
+ end
+ end
+
+ def link_lfs_object!(lfs_object)
+ project.lfs_objects << lfs_object
+ end
+ end
+end
diff --git a/app/services/lfs/lock_file_service.rb b/app/services/lfs/lock_file_service.rb
new file mode 100644
index 00000000000..bbe10f84ef4
--- /dev/null
+++ b/app/services/lfs/lock_file_service.rb
@@ -0,0 +1,39 @@
+module Lfs
+ class LockFileService < BaseService
+ def execute
+ unless can?(current_user, :push_code, project)
+ raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions'
+ end
+
+ create_lock!
+ rescue ActiveRecord::RecordNotUnique
+ error('already locked', 409, current_lock)
+ rescue Gitlab::GitAccess::UnauthorizedError => ex
+ error(ex.message, 403)
+ rescue => ex
+ error(ex.message, 500)
+ end
+
+ private
+
+ def current_lock
+ project.lfs_file_locks.find_by(path: params[:path])
+ end
+
+ def create_lock!
+ lock = project.lfs_file_locks.create!(user: current_user,
+ path: params[:path])
+
+ success(http_status: 201, lock: lock)
+ end
+
+ def error(message, http_status, lock = nil)
+ {
+ status: :error,
+ message: message,
+ http_status: http_status,
+ lock: lock
+ }
+ end
+ end
+end
diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb
new file mode 100644
index 00000000000..13c6cc6f81c
--- /dev/null
+++ b/app/services/lfs/locks_finder_service.rb
@@ -0,0 +1,17 @@
+module Lfs
+ class LocksFinderService < BaseService
+ def execute
+ success(locks: find_locks)
+ rescue => ex
+ error(ex.message, 500)
+ end
+
+ private
+
+ def find_locks
+ options = params.slice(:id, :path).compact.symbolize_keys
+
+ project.lfs_file_locks.where(options)
+ end
+ end
+end
diff --git a/app/services/lfs/unlock_file_service.rb b/app/services/lfs/unlock_file_service.rb
new file mode 100644
index 00000000000..6c93dc69bb0
--- /dev/null
+++ b/app/services/lfs/unlock_file_service.rb
@@ -0,0 +1,43 @@
+module Lfs
+ class UnlockFileService < BaseService
+ def execute
+ unless can?(current_user, :push_code, project)
+ raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions'
+ end
+
+ unlock_file
+ rescue Gitlab::GitAccess::UnauthorizedError => ex
+ error(ex.message, 403)
+ rescue ActiveRecord::RecordNotFound
+ error('Lock not found', 404)
+ rescue => ex
+ error(ex.message, 500)
+ end
+
+ private
+
+ def unlock_file
+ forced = params[:force] == true
+
+ if lock.can_be_unlocked_by?(current_user, forced)
+ lock.destroy!
+
+ success(lock: lock, http_status: :ok)
+ elsif forced
+ error('You must have master access to force delete a lock', 403)
+ else
+ error("#{lock.path} is locked by GitLab User #{lock.user_id}", 403)
+ end
+ end
+
+ def lock
+ return @lock if defined?(@lock)
+
+ @lock = if params[:id].present?
+ project.lfs_file_locks.find(params[:id])
+ elsif params[:path].present?
+ project.lfs_file_locks.find_by!(path: params[:path])
+ end
+ end
+ end
+end
diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb
index c13f289f61e..6be08b590bc 100644
--- a/app/services/members/approve_access_request_service.rb
+++ b/app/services/members/approve_access_request_service.rb
@@ -1,42 +1,20 @@
module Members
- class ApproveAccessRequestService < BaseService
- include MembersHelper
-
- attr_accessor :source
-
- # source - The source object that respond to `#requesters` (i.g. project or group)
- # current_user - The user that performs the access request approval
- # params - A hash of parameters
- # :user_id - User ID used to retrieve the access requester
- # :id - Member ID used to retrieve the access requester
- # :access_level - Optional access level set when the request is accepted
- def initialize(source, current_user, params = {})
- @source = source
- @current_user = current_user
- @params = params.slice(:user_id, :id, :access_level)
- end
-
- # opts - A hash of options
- # :force - Bypass permission check: current_user can be nil in that case
- def execute(opts = {})
- condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] }
- access_requester = source.requesters.find_by!(condition)
-
- raise Gitlab::Access::AccessDeniedError unless can_update_access_requester?(access_requester, opts)
+ class ApproveAccessRequestService < Members::BaseService
+ def execute(access_requester, skip_authorization: false, skip_log_audit_event: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_update_access_requester?(access_requester)
access_requester.access_level = params[:access_level] if params[:access_level]
access_requester.accept_request
+ after_execute(member: access_requester, skip_log_audit_event: skip_log_audit_event)
+
access_requester
end
private
- def can_update_access_requester?(access_requester, opts = {})
- access_requester && (
- opts[:force] ||
- can?(current_user, action_member_permission(:update, access_requester), access_requester)
- )
+ def can_update_access_requester?(access_requester)
+ can?(current_user, update_member_permission(access_requester), access_requester)
end
end
end
diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb
deleted file mode 100644
index de3a252d6c6..00000000000
--- a/app/services/members/authorized_destroy_service.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-module Members
- class AuthorizedDestroyService < BaseService
- attr_accessor :member, :user
-
- def initialize(member, user = nil)
- @member, @user = member, user
- end
-
- def execute
- return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
-
- Member.transaction do
- unassign_issues_and_merge_requests(member) unless member.invite?
-
- member.destroy
- end
-
- if member.request? && member.user != user
- notification_service.decline_access_request(member)
- end
-
- member
- end
-
- private
-
- def unassign_issues_and_merge_requests(member)
- if member.is_a?(GroupMember)
- issues = Issue.unscoped.select(1)
- .joins(:project)
- .where('issues.id = issue_assignees.issue_id AND projects.namespace_id = ?', member.source_id)
-
- # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...)
- IssueAssignee.unscoped
- .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues)
- .delete_all
-
- MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id)
- .execute
- .update_all(assignee_id: nil)
- else
- project = member.source
-
- # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X
- issues = Issue.unscoped.select(1)
- .where('issues.id = issue_assignees.issue_id')
- .where(project_id: project.id)
-
- # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...)
- IssueAssignee.unscoped
- .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues)
- .delete_all
-
- project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
- end
-
- member.user.invalidate_cache_counts
- end
- end
-end
diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb
new file mode 100644
index 00000000000..74556fb20cf
--- /dev/null
+++ b/app/services/members/base_service.rb
@@ -0,0 +1,49 @@
+module Members
+ class BaseService < ::BaseService
+ # current_user - The user that performs the action
+ # params - A hash of parameters
+ def initialize(current_user = nil, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def after_execute(args)
+ # overriden in EE::Members modules
+ end
+
+ private
+
+ def update_member_permission(member)
+ case member
+ when GroupMember
+ :update_group_member
+ when ProjectMember
+ :update_project_member
+ else
+ raise "Unknown member type: #{member}!"
+ end
+ end
+
+ def override_member_permission(member)
+ case member
+ when GroupMember
+ :override_group_member
+ when ProjectMember
+ :override_project_member
+ else
+ raise "Unknown member type: #{member}!"
+ end
+ end
+
+ def action_member_permission(action, member)
+ case action
+ when :update
+ update_member_permission(member)
+ when :override
+ override_member_permission(member)
+ else
+ raise "Unknown action '#{action}' on #{member}!"
+ end
+ end
+ end
+end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 26906ae7167..bc6a9405aac 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -1,15 +1,8 @@
module Members
- class CreateService < BaseService
+ class CreateService < Members::BaseService
DEFAULT_LIMIT = 100
- def initialize(source, current_user, params = {})
- @source = source
- @current_user = current_user
- @params = params
- @error = nil
- end
-
- def execute
+ def execute(source)
return error('No users specified.') if params[:user_ids].blank?
user_ids = params[:user_ids].split(',').uniq
@@ -17,13 +10,15 @@ module Members
return error("Too many users specified (limit is #{user_limit})") if
user_limit && user_ids.size > user_limit
- @source.add_users(
+ members = source.add_users(
user_ids,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
)
+ members.each { |member| after_execute(member: member) }
+
success
end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 2e089149ca8..5b51e1982f1 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -1,42 +1,38 @@
module Members
- class DestroyService < BaseService
- include MembersHelper
+ class DestroyService < Members::BaseService
+ def execute(member, skip_authorization: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member)
- attr_accessor :source
+ return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
- ALLOWED_SCOPES = %i[members requesters all].freeze
+ member.destroy
- def initialize(source, current_user, params = {})
- @source = source
- @current_user = current_user
- @params = params
- end
-
- def execute(scope = :members)
- raise "scope :#{scope} is not allowed!" unless ALLOWED_SCOPES.include?(scope)
+ member.user&.invalidate_cache_counts
- member = find_member!(scope)
+ if member.request? && member.user != current_user
+ notification_service.decline_access_request(member)
+ end
- raise Gitlab::Access::AccessDeniedError unless can_destroy_member?(member)
+ after_execute(member: member)
- AuthorizedDestroyService.new(member, current_user).execute
+ member
end
private
- def find_member!(scope)
- condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] }
- case scope
- when :all
- source.members.find_by(condition) ||
- source.requesters.find_by!(condition)
- else
- source.public_send(scope).find_by!(condition)
- end
+ def can_destroy_member?(member)
+ can?(current_user, destroy_member_permission(member), member)
end
- def can_destroy_member?(member)
- member && can?(current_user, action_member_permission(:destroy, member), member)
+ def destroy_member_permission(member)
+ case member
+ when GroupMember
+ :destroy_group_member
+ when ProjectMember
+ :destroy_project_member
+ else
+ raise "Unknown member type: #{member}!"
+ end
end
end
end
diff --git a/app/services/members/request_access_service.rb b/app/services/members/request_access_service.rb
index 2614153d900..24293b30005 100644
--- a/app/services/members/request_access_service.rb
+++ b/app/services/members/request_access_service.rb
@@ -1,13 +1,6 @@
module Members
- class RequestAccessService < BaseService
- attr_accessor :source
-
- def initialize(source, current_user)
- @source = source
- @current_user = current_user
- end
-
- def execute
+ class RequestAccessService < Members::BaseService
+ def execute(source)
raise Gitlab::Access::AccessDeniedError unless can_request_access?(source)
source.members.create(
@@ -19,7 +12,7 @@ module Members
private
def can_request_access?(source)
- source && can?(current_user, :request_access, source)
+ can?(current_user, :request_access, source)
end
end
end
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
new file mode 100644
index 00000000000..48b3d59f7bd
--- /dev/null
+++ b/app/services/members/update_service.rb
@@ -0,0 +1,16 @@
+module Members
+ class UpdateService < Members::BaseService
+ # returns the updated member
+ def execute(member, permission: :update)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member)
+
+ old_access_level = member.human_access
+
+ if member.update_attributes(params)
+ after_execute(action: permission, old_access_level: old_access_level, member: member)
+ end
+
+ member
+ end
+ end
+end
diff --git a/app/services/merge_request_metrics_service.rb b/app/services/merge_request_metrics_service.rb
new file mode 100644
index 00000000000..9248de14a53
--- /dev/null
+++ b/app/services/merge_request_metrics_service.rb
@@ -0,0 +1,19 @@
+class MergeRequestMetricsService
+ delegate :update!, to: :@merge_request_metrics
+
+ def initialize(merge_request_metrics)
+ @merge_request_metrics = merge_request_metrics
+ end
+
+ def merge(event)
+ update!(merged_by_id: event.author_id, merged_at: event.created_at)
+ end
+
+ def close(event)
+ update!(latest_closed_by_id: event.author_id, latest_closed_at: event.created_at)
+ end
+
+ def reopen
+ update!(latest_closed_by_id: nil, latest_closed_at: nil)
+ end
+end
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index 727768b1a39..6805b2f7d1c 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -3,7 +3,7 @@ module MergeRequests
# Adds a todo to the parent merge_request when a CI build fails
#
def execute(commit_status)
- return if commit_status.allow_failure?
+ return if commit_status.allow_failure? || commit_status.retried?
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 3542a41ac83..231ab76fde4 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -1,36 +1,22 @@
module MergeRequests
class BaseService < ::IssuableBaseService
- def create_note(merge_request)
- SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, merge_request.state, nil)
+ def create_note(merge_request, state = merge_request.state)
+ SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, state, nil)
end
- def create_title_change_note(issuable, old_title)
- removed_wip = MergeRequest.work_in_progress?(old_title) && !issuable.work_in_progress?
- added_wip = !MergeRequest.work_in_progress?(old_title) && issuable.work_in_progress?
- changed_title = MergeRequest.wipless_title(old_title) != issuable.wipless_title
-
- if removed_wip
- SystemNoteService.remove_merge_request_wip(issuable, issuable.project, current_user)
- elsif added_wip
- SystemNoteService.add_merge_request_wip(issuable, issuable.project, current_user)
- end
-
- super if changed_title
- end
-
- def hook_data(merge_request, action, oldrev = nil)
- hook_data = merge_request.to_hook_data(current_user)
- hook_data[:object_attributes][:url] = Gitlab::UrlBuilder.build(merge_request)
+ def hook_data(merge_request, action, old_rev: nil, old_associations: {})
+ hook_data = merge_request.to_hook_data(current_user, old_associations: old_associations)
hook_data[:object_attributes][:action] = action
- if oldrev && !Gitlab::Git.blank_ref?(oldrev)
- hook_data[:object_attributes][:oldrev] = oldrev
+ if old_rev && !Gitlab::Git.blank_ref?(old_rev)
+ hook_data[:object_attributes][:oldrev] = old_rev
end
+
hook_data
end
- def execute_hooks(merge_request, action = 'open', oldrev = nil)
+ def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
if merge_request.project
- merge_data = hook_data(merge_request, action, oldrev)
+ merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations)
merge_request.project.execute_hooks(merge_data, :merge_request_hooks)
merge_request.project.execute_services(merge_data, :merge_request_hooks)
end
@@ -38,13 +24,36 @@ module MergeRequests
private
+ def handle_wip_event(merge_request)
+ if wip_event = params.delete(:wip_event)
+ # We update the title that is provided in the params or we use the mr title
+ title = params[:title] || merge_request.title
+ params[:title] = case wip_event
+ when 'wip' then MergeRequest.wip_title(title)
+ when 'unwip' then MergeRequest.wipless_title(title)
+ end
+ end
+ end
+
+ def filter_params(merge_request)
+ super
+
+ unless merge_request.can_allow_maintainer_to_push?(current_user)
+ params.delete(:allow_maintainer_to_push)
+ end
+ end
+
+ def merge_request_metrics_service(merge_request)
+ MergeRequestMetricsService.new(merge_request.metrics)
+ end
+
def create_assignee_note(merge_request)
SystemNoteService.change_assignee(
merge_request, merge_request.project, current_user, merge_request.assignee)
end
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
- def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
+ def merge_requests_for(source_branch, mr_states: [:opened])
MergeRequest
.with_state(mr_states)
.where(source_branch: source_branch, source_project_id: @project.id)
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index bc0e7ad4e39..a98bbdf74dd 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -1,15 +1,24 @@
module MergeRequests
class BuildService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
def execute
+ @params_issue_iid = params.delete(:issue_iid)
+
self.merge_request = MergeRequest.new(params)
+ merge_request.author = current_user
merge_request.compare_commits = []
merge_request.source_project = find_source_project
merge_request.target_project = find_target_project
merge_request.target_branch = find_target_branch
merge_request.can_be_created = branches_valid?
- compare_branches if branches_present?
- assign_title_and_description if merge_request.can_be_created
+ # compare branches only if branches are valid, otherwise
+ # compare_branches may raise an error
+ if merge_request.can_be_created
+ compare_branches
+ assign_title_and_description
+ end
merge_request
end
@@ -18,7 +27,17 @@ module MergeRequests
attr_accessor :merge_request
- delegate :target_branch, :source_branch, :source_project, :target_project, :compare_commits, :wip_title, :description, :errors, to: :merge_request
+ delegate :target_branch,
+ :target_branch_ref,
+ :target_project,
+ :source_branch,
+ :source_branch_ref,
+ :source_project,
+ :compare_commits,
+ :wip_title,
+ :description,
+ :errors,
+ to: :merge_request
def find_source_project
return source_project if source_project.present? && can?(current_user, :read_project, source_project)
@@ -28,6 +47,7 @@ module MergeRequests
def find_target_project
return target_project if target_project.present? && can?(current_user, :read_project, target_project)
+
project.default_merge_request_target
end
@@ -53,10 +73,10 @@ module MergeRequests
def compare_branches
compare = CompareService.new(
source_project,
- source_branch
+ source_branch_ref
).execute(
target_project,
- target_branch
+ target_branch_ref
)
if compare
@@ -105,37 +125,65 @@ module MergeRequests
# more than one commit in the MR
#
def assign_title_and_description
- if match = source_branch.match(/\A(\d+)-/)
- iid = match[1]
+ assign_title_and_description_from_single_commit
+ assign_title_from_issue if target_project.issues_enabled? || target_project.external_issue_tracker
+
+ merge_request.title ||= source_branch.titleize.humanize
+ merge_request.title = wip_title if compare_commits.empty?
+
+ append_closes_description
+ end
+
+ def append_closes_description
+ return unless issue&.to_reference.present?
+
+ closes_issue = "Closes #{issue.to_reference}"
+
+ if description.present?
+ merge_request.description += closes_issue.prepend("\n\n")
+ else
+ merge_request.description = closes_issue
end
+ end
+ def assign_title_and_description_from_single_commit
commits = compare_commits
- if commits && commits.count == 1
- commit = commits.first
- merge_request.title = commit.title
- merge_request.description ||= commit.description.try(:strip)
- elsif iid && issue = target_project.get_issue(iid, current_user)
- case issue
- when Issue
- merge_request.title = "Resolve \"#{issue.title}\""
- when ExternalIssue
- merge_request.title = "Resolve #{issue.title}"
- end
- else
- merge_request.title = source_branch.titleize.humanize
+
+ return unless commits&.count == 1
+
+ commit = commits.first
+ merge_request.title ||= commit.title
+ merge_request.description ||= commit.description.try(:strip)
+ end
+
+ def assign_title_from_issue
+ return unless issue
+
+ merge_request.title = "Resolve \"#{issue.title}\"" if issue.is_a?(Issue)
+
+ return if merge_request.title.present?
+
+ if issue_iid.present?
+ merge_request.title = "Resolve #{issue.to_reference}"
+ branch_title = source_branch.downcase.remove(issue_iid.downcase).titleize.humanize
+ merge_request.title += " \"#{branch_title}\"" if branch_title.present?
end
+ end
- if iid
- closes_issue = "Closes ##{iid}"
+ def issue_iid
+ strong_memoize(:issue_iid) do
+ @params_issue_iid || begin
+ id = if target_project.external_issue_tracker
+ source_branch.match(target_project.external_issue_reference_pattern).try(:[], 0)
+ end
- if description.present?
- merge_request.description += closes_issue.prepend("\n\n")
- else
- merge_request.description = closes_issue
+ id || source_branch.match(/\A(\d+)-/).try(:[], 1)
end
end
+ end
- merge_request.title = wip_title if commits.empty?
+ def issue
+ @issue ||= target_project.get_issue(issue_iid, current_user)
end
end
end
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 2ffc989ed71..f727ec002e7 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -8,15 +8,27 @@ module MergeRequests
merge_request.allow_broken = true
if merge_request.close
- event_service.close_mr(merge_request, current_user)
+ create_event(merge_request)
create_note(merge_request)
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)
+ invalidate_cache_counts(merge_request, users: merge_request.assignees)
+ merge_request.update_project_counter_caches
end
merge_request
end
+
+ private
+
+ def create_event(merge_request)
+ # Making sure MergeRequest::Metrics updates are in sync with
+ # Event creation.
+ Event.transaction do
+ close_event = event_service.close_mr(merge_request, current_user)
+ merge_request_metrics_service(merge_request).close(close_event)
+ end
+ end
end
end
diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb
index 9835606812c..72cbc49adb2 100644
--- a/app/services/merge_requests/conflicts/list_service.rb
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -17,19 +17,11 @@ module MergeRequests
return @conflicts_can_be_resolved_in_ui = false unless merge_request.has_complete_diff_refs?
return @conflicts_can_be_resolved_in_ui = false if merge_request.branch_missing?
- begin
- # Try to parse each conflict. If the MR's mergeable status hasn't been
- # updated, ensure that we don't say there are conflicts to resolve
- # when there are no conflict files.
- conflicts.files.each(&:lines)
- @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0
- rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing
- @conflicts_can_be_resolved_in_ui = false
- end
+ @conflicts_can_be_resolved_in_ui = conflicts.can_be_resolved_in_ui?
end
def conflicts
- @conflicts ||= Gitlab::Conflict::FileCollection.read_only(merge_request)
+ @conflicts ||= Gitlab::Conflict::FileCollection.new(merge_request)
end
end
end
diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb
index 6b6e231f4f9..27cafd2d7d9 100644
--- a/app/services/merge_requests/conflicts/resolve_service.rb
+++ b/app/services/merge_requests/conflicts/resolve_service.rb
@@ -1,54 +1,10 @@
module MergeRequests
module Conflicts
class ResolveService < MergeRequests::Conflicts::BaseService
- MissingFiles = Class.new(Gitlab::Conflict::ResolutionError)
-
def execute(current_user, params)
- rugged = merge_request.source_project.repository.rugged
-
- Gitlab::Conflict::FileCollection.for_resolution(merge_request) do |conflicts_for_resolution|
- merge_index = conflicts_for_resolution.merge_index
-
- params[:files].each do |file_params|
- conflict_file = conflicts_for_resolution.file_for_path(file_params[:old_path], file_params[:new_path])
-
- write_resolved_file_to_index(merge_index, rugged, conflict_file, file_params)
- end
-
- unless merge_index.conflicts.empty?
- missing_files = merge_index.conflicts.map { |file| file[:ours][:path] }
-
- raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}"
- end
-
- commit_params = {
- message: params[:commit_message] || conflicts_for_resolution.default_commit_message,
- parents: [conflicts_for_resolution.our_commit, conflicts_for_resolution.their_commit].map(&:oid),
- tree: merge_index.write_tree(rugged)
- }
-
- conflicts_for_resolution
- .project
- .repository
- .resolve_conflicts(current_user, merge_request.source_branch, commit_params)
- end
- end
-
- private
-
- def write_resolved_file_to_index(merge_index, rugged, file, params)
- 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
+ conflicts = Gitlab::Conflict::FileCollection.new(merge_request)
- merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
- merge_index.conflict_remove(our_path)
+ conflicts.resolve(current_user, params[:commit_message], params[:files])
end
end
end
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
index 738cedbaed7..cf687b71d16 100644
--- a/app/services/merge_requests/create_from_issue_service.rb
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -1,7 +1,20 @@
module MergeRequests
class CreateFromIssueService < MergeRequests::CreateService
+ def initialize(project, user, params)
+ # branch - the name of new branch
+ # ref - the source of new branch.
+
+ @branch_name = params[:branch_name]
+ @issue_iid = params[:issue_iid]
+ @ref = params[:ref]
+
+ super(project, user)
+ end
+
def execute
- return error('Invalid issue iid') unless issue_iid.present? && issue.present?
+ return error('Invalid issue iid') unless @issue_iid.present? && issue.present?
+
+ params[:label_ids] = issue.label_ids if issue.label_ids.any?
result = CreateBranchService.new(project, current_user).execute(branch_name, ref)
return result if result[:status] == :error
@@ -19,20 +32,16 @@ module MergeRequests
private
- def issue_iid
- @isssue_iid ||= params.delete(:issue_iid)
- end
-
def issue
- @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid)
+ @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid)
end
def branch_name
- @branch_name ||= issue.to_branch_name
+ @branch ||= @branch_name || issue.to_branch_name
end
def ref
- project.default_branch || 'master'
+ @ref || project.default_branch || 'master'
end
def merge_request
@@ -41,9 +50,12 @@ module MergeRequests
def merge_request_params
{
+ issue_iid: @issue_iid,
source_project_id: project.id,
source_branch: branch_name,
- target_project_id: project.id
+ target_project_id: project.id,
+ target_branch: ref,
+ milestone_id: issue.milestone_id
}
end
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 71d37797bb4..c57a2445341 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -1,32 +1,52 @@
module MergeRequests
class CreateService < MergeRequests::BaseService
def execute
- # @project is used to determine whether the user can set the merge request's
- # assignee, milestone and labels. Whether they can depends on their
- # permissions on the target project.
- source_project = @project
- @project = Project.find(params[:target_project_id]) if params[:target_project_id]
-
- params[:target_project_id] ||= source_project.id
+ set_projects!
merge_request = MergeRequest.new
- merge_request.source_project = source_project
+ merge_request.target_project = @project
+ 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
+ def before_create(merge_request)
+ # current_user (defined in BaseService) is not available within run_after_commit block
+ user = current_user
+ merge_request.run_after_commit do
+ NewMergeRequestWorker.perform_async(merge_request.id, user.id)
+ end
+ end
+
def after_create(issuable)
- event_service.open_mr(issuable, current_user)
- notification_service.new_merge_request(issuable, current_user)
todo_service.new_merge_request(issuable, current_user)
issuable.cache_merge_request_closes_issues!(current_user)
+ update_merge_requests_head_pipeline(issuable)
+
+ super
+ end
+
+ # expose issuable create method so it can be called from email
+ # handler CreateMergeRequestHandler
+ def create(merge_request)
+ super
+ end
+
+ # Override from IssuableBaseService
+ def handle_quick_actions_on_create(merge_request)
+ super
+ handle_wip_event(merge_request)
end
private
+ def update_merge_requests_head_pipeline(merge_request)
+ pipeline = head_pipeline_for(merge_request)
+ merge_request.update(head_pipeline_id: pipeline.id) if pipeline
+ end
+
def head_pipeline_for(merge_request)
return unless merge_request.source_project
@@ -37,5 +57,25 @@ module MergeRequests
pipelines.order(id: :desc).first
end
+
+ def set_projects!
+ # @project is used to determine whether the user can set the merge request's
+ # assignee, milestone and labels. Whether they can depends on their
+ # permissions on the target project.
+ @source_project = @project
+ @project = Project.find(params[:target_project_id]) if params[:target_project_id]
+
+ # make sure that source/target project ids are not in
+ # params so it can't be overridden later when updating attributes
+ # from params when applying quick actions
+ params.delete(:source_project_id)
+ params.delete(:target_project_id)
+
+ unless can?(current_user, :read_project, @source_project) &&
+ can?(current_user, :read_project, @project)
+
+ raise Gitlab::Access::AccessDeniedError
+ end
+ end
end
end
diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb
new file mode 100644
index 00000000000..ba6853b835a
--- /dev/null
+++ b/app/services/merge_requests/ff_merge_service.rb
@@ -0,0 +1,24 @@
+module MergeRequests
+ # MergeService class
+ #
+ # Do git fast-forward merge and in case of success
+ # mark merge request as merged and execute all hooks and notifications
+ # Executed when you do fast-forward merge via GitLab UI
+ #
+ class FfMergeService < MergeRequests::MergeService
+ private
+
+ def commit
+ repository.ff_merge(current_user,
+ source,
+ merge_request.target_branch,
+ merge_request: merge_request)
+ rescue Gitlab::Git::HooksService::PreReceiveError => e
+ raise MergeError, e.message
+ rescue StandardError => e
+ raise MergeError, "Something went wrong during merge: #{e.message}"
+ ensure
+ merge_request.update(in_progress_merge_commit_sha: nil)
+ end
+ end
+end
diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb
index f00a33969a8..668a1741736 100644
--- a/app/services/merge_requests/get_urls_service.rb
+++ b/app/services/merge_requests/get_urls_service.rb
@@ -49,13 +49,13 @@ module MergeRequests
def url_for_new_merge_request(branch_name)
merge_request_params = { source_branch: branch_name }
- url = Gitlab::Routing.url_helpers.new_namespace_project_merge_request_url(project.namespace, project, merge_request: merge_request_params)
+ url = Gitlab::Routing.url_helpers.project_new_merge_request_url(project, merge_request: merge_request_params)
{ branch_name: branch_name, url: url, new_merge_request: true }
end
def url_for_existing_merge_request(merge_request)
target_project = merge_request.target_project
- url = Gitlab::Routing.url_helpers.namespace_project_merge_request_url(target_project.namespace, target_project, merge_request)
+ url = Gitlab::Routing.url_helpers.project_merge_request_url(target_project, merge_request)
{ branch_name: merge_request.source_branch, url: url, new_merge_request: false }
end
end
diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb
index 2945a7fd4e4..10aa9ae609c 100644
--- a/app/services/merge_requests/merge_request_diff_cache_service.rb
+++ b/app/services/merge_requests/merge_request_diff_cache_service.rb
@@ -1,8 +1,17 @@
module MergeRequests
class MergeRequestDiffCacheService
- def execute(merge_request)
+ def execute(merge_request, new_diff)
# Executing the iteration we cache all the highlighted diff information
merge_request.diffs.diff_files.to_a
+
+ # Remove cache for all diffs on this MR. Do not use the association on the
+ # model, as that will interfere with other actions happening when
+ # reloading the diff.
+ MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff|
+ next if merge_request_diff == new_diff
+
+ merge_request_diff.diffs.clear_cache!
+ end
end
end
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index b247cb89e5e..cedfcb50e09 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -10,46 +10,56 @@ module MergeRequests
attr_reader :merge_request, :source
- def execute(merge_request)
- @merge_request = merge_request
+ delegate :merge_jid, :state, to: :@merge_request
- unless @merge_request.mergeable?
- return log_merge_error('Merge request is not mergeable', save_message_on_model: true)
+ def execute(merge_request)
+ if project.merge_requests_ff_only_enabled && !self.is_a?(FfMergeService)
+ FfMergeService.new(project, current_user, params).execute(merge_request)
+ return
end
- @source = find_merge_source
+ @merge_request = merge_request
- unless @source
- return log_merge_error('No source for merge', save_message_on_model: true)
- end
+ error_check!
merge_request.in_locked_state do
if commit
after_merge
+ clean_merge_jid
success
end
end
+ log_info("Merge process finished on JID #{merge_jid} with state #{state}")
rescue MergeError => e
- log_merge_error(e.message, save_message_on_model: true)
+ handle_merge_error(log_message: e.message, save_message_on_model: true)
end
private
- def commit
- committer = repository.user_to_committer(current_user)
+ def error_check!
+ error =
+ if @merge_request.should_be_rebased?
+ 'Only fast-forward merge is allowed for your project. Please update your source branch'
+ elsif !@merge_request.mergeable?
+ 'Merge request is not mergeable'
+ elsif !source
+ 'No source for merge'
+ end
- options = {
- message: params[:commit_message] || merge_request.merge_commit_message,
- author: committer,
- committer: committer
- }
+ raise MergeError, error if error
+ end
- commit_id = repository.merge(current_user, source, merge_request, options)
+ def commit
+ message = params[:commit_message] || merge_request.merge_commit_message
+
+ log_info("Git merge started on JID #{merge_jid}")
+ commit_id = repository.merge(current_user, source, merge_request, message)
+ log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}")
raise MergeError, 'Conflicts detected during merge' unless commit_id
merge_request.update(merge_commit_sha: commit_id)
- rescue GitHooksService::PreReceiveError => e
+ rescue Gitlab::Git::HooksService::PreReceiveError => e
raise MergeError, e.message
rescue StandardError => e
raise MergeError, "Something went wrong during merge: #{e.message}"
@@ -58,30 +68,48 @@ module MergeRequests
end
def after_merge
+ log_info("Post merge started on JID #{merge_jid} with state #{state}")
MergeRequests::PostMergeService.new(project, current_user).execute(merge_request)
+ log_info("Post merge finished on JID #{merge_jid} with state #{state}")
- if params[:should_remove_source_branch].present? || @merge_request.force_remove_source_branch?
+ if delete_source_branch?
DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
.execute(merge_request.source_branch)
end
end
+ def clean_merge_jid
+ merge_request.update_column(:merge_jid, nil)
+ end
+
def branch_deletion_user
@merge_request.force_remove_source_branch? ? @merge_request.author : current_user
end
- def log_merge_error(message, save_message_on_model: false)
- Rails.logger.error("MergeService ERROR: #{merge_request_info} - #{message}")
+ # Verify again that the source branch can be removed, since branch may be protected,
+ # or the source branch may have been updated, or the user may not have permission
+ #
+ def delete_source_branch?
+ params.fetch('should_remove_source_branch', @merge_request.force_remove_source_branch?) &&
+ @merge_request.can_remove_source_branch?(branch_deletion_user)
+ end
+
+ def handle_merge_error(log_message:, save_message_on_model: false)
+ Rails.logger.error("MergeService ERROR: #{merge_request_info} - #{log_message}")
+ @merge_request.update(merge_error: log_message) if save_message_on_model
+ end
- @merge_request.update(merge_error: message) if save_message_on_model
+ def log_info(message)
+ @logger ||= Rails.logger
+ @logger.info("#{merge_request_info} - #{message}")
end
def merge_request_info
merge_request.to_reference(full: true)
end
- def find_merge_source
- merge_request.diff_head_sha
+ def source
+ @source ||= @merge_request.diff_head_sha
end
end
end
diff --git a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb
index aed5287940e..850deb0ac7a 100644
--- a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb
@@ -30,7 +30,7 @@ module MergeRequests
next
end
- MergeWorker.perform_async(merge_request.id, merge_request.merge_user_id, merge_request.merge_params)
+ merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params)
end
end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index f0d998731d7..c78e78afcd1 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -9,11 +9,12 @@ module MergeRequests
close_issues(merge_request)
todo_service.merge_merge_request(merge_request, current_user)
merge_request.mark_as_merged
- create_merge_event(merge_request, current_user)
+ create_event(merge_request)
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)
+ invalidate_cache_counts(merge_request, users: merge_request.assignees)
+ merge_request.update_project_counter_caches
end
private
@@ -33,5 +34,14 @@ module MergeRequests
def create_merge_event(merge_request, current_user)
EventCreateService.new.merge_mr(merge_request, current_user)
end
+
+ def create_event(merge_request)
+ # Making sure MergeRequest::Metrics updates are in sync with
+ # Event creation.
+ Event.transaction do
+ merge_event = create_merge_event(merge_request, current_user)
+ merge_request_metrics_service(merge_request).merge(merge_event)
+ end
+ end
end
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
new file mode 100644
index 00000000000..c0083cd6afd
--- /dev/null
+++ b/app/services/merge_requests/rebase_service.rb
@@ -0,0 +1,32 @@
+module MergeRequests
+ class RebaseService < MergeRequests::WorkingCopyBaseService
+ REBASE_ERROR = 'Rebase failed. Please rebase locally'.freeze
+
+ def execute(merge_request)
+ @merge_request = merge_request
+
+ if rebase
+ success
+ else
+ error(REBASE_ERROR)
+ end
+ end
+
+ def rebase
+ if merge_request.rebase_in_progress?
+ log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
+ return false
+ end
+
+ rebase_sha = repository.rebase(current_user, merge_request)
+
+ merge_request.update_attributes(rebase_commit_sha: rebase_sha)
+
+ true
+ rescue => e
+ log_error(REBASE_ERROR, save_message_on_model: true)
+ log_error(e.message)
+ false
+ end
+ end
+end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index e0e7c43f802..18c40ce8992 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -6,10 +6,11 @@ module MergeRequests
@oldrev, @newrev = oldrev, newrev
@branch_name = Gitlab::Git.ref_name(ref)
- find_new_commits
+ Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits))
# Be sure to close outstanding MRs before reloading them to avoid generating an
# empty diff during a manual merge
- close_merge_requests
+ close_upon_missing_source_branch_ref
+ post_merge_manually_merged
reload_merge_requests
reset_merge_when_pipeline_succeeds
mark_pending_todos_done
@@ -29,17 +30,29 @@ module MergeRequests
private
+ def close_upon_missing_source_branch_ref
+ # MergeRequest#reload_diff ignores not opened MRs. This means it won't
+ # create an `empty` diff for `closed` MRs without a source branch, keeping
+ # the latest diff state as the last _valid_ one.
+ merge_requests_for_source_branch.reject(&:source_branch_exists?).each do |mr|
+ MergeRequests::CloseService
+ .new(mr.target_project, @current_user)
+ .execute(mr)
+ end
+ end
+
# Collect open merge requests that target same branch we push into
# and close if push to master include last commit from merge request
# We need this to close(as merged) merge requests that were merged into
# target branch manually
- def close_merge_requests
+ def post_merge_manually_merged
commit_ids = @commits.map(&:id)
- merge_requests = @project.merge_requests.opened.where(target_branch: @branch_name).to_a
+ merge_requests = @project.merge_requests.preload(:latest_merge_request_diff).opened.where(target_branch: @branch_name).to_a
merge_requests = merge_requests.select(&:diff_head_commit)
merge_requests = merge_requests.select do |merge_request|
- commit_ids.include?(merge_request.diff_head_sha)
+ commit_ids.include?(merge_request.diff_head_sha) &&
+ merge_request.merge_request_diff.state != 'empty'
end
filter_merge_requests(merge_requests).each do |merge_request|
@@ -68,14 +81,19 @@ module MergeRequests
if merge_request.source_branch == @branch_name || force_push?
merge_request.reload_diff(current_user)
else
- mr_commit_ids = merge_request.commits_sha
+ mr_commit_ids = merge_request.commit_shas
push_commit_ids = @commits.map(&:id)
matches = mr_commit_ids & push_commit_ids
merge_request.reload_diff(current_user) if matches.any?
end
merge_request.mark_as_unchecked
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
+
+ # Upcoming method calls need the refreshed version of
+ # @source_merge_requests diffs (for MergeRequest#commit_shas for instance).
+ merge_requests_for_source_branch(reload: true)
end
def reset_merge_when_pipeline_succeeds
@@ -128,7 +146,7 @@ module MergeRequests
return unless @commits.present?
merge_requests_for_source_branch.each do |merge_request|
- mr_commit_ids = Set.new(merge_request.commits_sha)
+ mr_commit_ids = Set.new(merge_request.commit_shas)
new_commits, existing_commits = @commits.partition do |commit|
mr_commit_ids.include?(commit.id)
@@ -144,7 +162,7 @@ module MergeRequests
return unless @commits.present?
merge_requests_for_source_branch.each do |merge_request|
- commit_shas = merge_request.commits_sha
+ commit_shas = merge_request.commit_shas
wip_commit = @commits.detect do |commit|
commit.work_in_progress? && commit_shas.include?(commit.sha)
@@ -165,7 +183,7 @@ module MergeRequests
# Call merge request webhook with update branches
def execute_mr_web_hooks
merge_requests_for_source_branch.each do |merge_request|
- execute_hooks(merge_request, 'update', @oldrev)
+ execute_hooks(merge_request, 'update', old_rev: @oldrev)
end
end
@@ -181,7 +199,8 @@ module MergeRequests
merge_requests.uniq.select(&:source_project)
end
- def merge_requests_for_source_branch
+ def merge_requests_for_source_branch(reload: false)
+ @source_merge_requests = nil if reload
@source_merge_requests ||= merge_requests_for(@branch_name)
end
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index f2fddf7f345..120677a7149 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -4,16 +4,28 @@ module MergeRequests
return merge_request unless can?(current_user, :update_merge_request, merge_request)
if merge_request.reopen
- event_service.reopen_mr(merge_request, current_user)
- create_note(merge_request)
+ create_event(merge_request)
+ create_note(merge_request, 'reopened')
notification_service.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen')
merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked
- invalidate_cache_counts(merge_request.assignees, merge_request)
+ invalidate_cache_counts(merge_request, users: merge_request.assignees)
+ merge_request.update_project_counter_caches
end
merge_request
end
+
+ private
+
+ def create_event(merge_request)
+ # Making sure MergeRequest::Metrics updates are in sync with
+ # Event creation.
+ Event.transaction do
+ event_service.reopen_mr(merge_request, current_user)
+ merge_request_metrics_service(merge_request).reopen
+ end
+ end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 75a65aecd1a..8a40ad88182 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -22,8 +22,9 @@ module MergeRequests
end
def handle_changes(merge_request, options)
- old_labels = options[:old_labels] || []
- old_mentioned_users = options[:old_mentioned_users] || []
+ old_associations = options.fetch(:old_associations, {})
+ old_labels = old_associations.fetch(:labels, [])
+ old_mentioned_users = old_associations.fetch(:mentioned_users, [])
if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
@@ -40,10 +41,6 @@ module MergeRequests
merge_request.target_branch)
end
- if merge_request.previous_changes.include?('milestone_id')
- create_milestone_note(merge_request)
- end
-
if merge_request.previous_changes.include?('assignee_id')
create_assignee_note(merge_request)
notification_service.reassigned_merge_request(merge_request, current_user)
@@ -83,7 +80,7 @@ module MergeRequests
if merge_request.head_pipeline && merge_request.head_pipeline.active?
MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request)
else
- MergeWorker.perform_async(merge_request.id, current_user.id, {})
+ merge_request.merge_async(current_user.id, {})
end
end
@@ -101,15 +98,10 @@ module MergeRequests
private
- def handle_wip_event(merge_request)
- if wip_event = params.delete(:wip_event)
- # We update the title that is provided in the params or we use the mr title
- title = params[:title] || merge_request.title
- params[:title] = case wip_event
- when 'wip' then MergeRequest.wip_title(title)
- when 'unwip' then MergeRequest.wipless_title(title)
- end
- end
+ def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
+ SystemNoteService.change_branch(
+ issuable, issuable.project, current_user, branch_type,
+ old_branch, new_branch)
end
end
end
diff --git a/app/services/merge_requests/working_copy_base_service.rb b/app/services/merge_requests/working_copy_base_service.rb
new file mode 100644
index 00000000000..186e05bf966
--- /dev/null
+++ b/app/services/merge_requests/working_copy_base_service.rb
@@ -0,0 +1,24 @@
+module MergeRequests
+ class WorkingCopyBaseService < MergeRequests::BaseService
+ attr_reader :merge_request
+
+ def source_project
+ @source_project ||= merge_request.source_project
+ end
+
+ def target_project
+ @target_project ||= merge_request.target_project
+ end
+
+ def log_error(message, save_message_on_model: false)
+ Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}")
+
+ merge_request.update(merge_error: message) if save_message_on_model
+ end
+
+ # Don't try to print expensive instance variables.
+ def inspect
+ "#<#{self.class} #{merge_request.to_reference(full: true)}>"
+ end
+ end
+end
diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb
index d726db4e99b..236e9fe8c44 100644
--- a/app/services/metrics_service.rb
+++ b/app/services/metrics_service.rb
@@ -3,8 +3,10 @@ require 'prometheus/client/formats/text'
class MetricsService
CHECKS = [
Gitlab::HealthChecks::DbCheck,
- Gitlab::HealthChecks::RedisCheck,
- Gitlab::HealthChecks::FsShardsCheck
+ Gitlab::HealthChecks::Redis::RedisCheck,
+ Gitlab::HealthChecks::Redis::CacheCheck,
+ Gitlab::HealthChecks::Redis::QueuesCheck,
+ Gitlab::HealthChecks::Redis::SharedStateCheck
].freeze
def prometheus_metrics_text
@@ -18,7 +20,7 @@ class MetricsService
end
def metrics_text
- "#{health_metrics_text}#{prometheus_metrics_text}"
+ prometheus_metrics_text.concat(health_metrics_text)
end
private
@@ -28,6 +30,6 @@ class MetricsService
end
def multiprocess_metrics_path
- @multiprocess_metrics_path ||= Rails.root.join(ENV['prometheus_multiproc_dir']).freeze
+ ::Prometheus::Client.configuration.multiprocess_files_dir
end
end
diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb
index 176ab9f1ab5..4963601ea8b 100644
--- a/app/services/milestones/base_service.rb
+++ b/app/services/milestones/base_service.rb
@@ -1,4 +1,10 @@
module Milestones
class BaseService < ::BaseService
+ # Parent can either a group or a project
+ attr_accessor :parent, :current_user, :params
+
+ def initialize(parent, user, params = {})
+ @parent, @current_user, @params = parent, user, params.dup
+ end
end
end
diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb
index 608fc49d766..5b06c4b601d 100644
--- a/app/services/milestones/close_service.rb
+++ b/app/services/milestones/close_service.rb
@@ -1,7 +1,7 @@
module Milestones
class CloseService < Milestones::BaseService
def execute(milestone)
- if milestone.close
+ if milestone.close && milestone.project_milestone?
event_service.close_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb
index b8e08c9f1eb..ed2e833d833 100644
--- a/app/services/milestones/create_service.rb
+++ b/app/services/milestones/create_service.rb
@@ -1,9 +1,9 @@
module Milestones
class CreateService < Milestones::BaseService
def execute
- milestone = project.milestones.new(params)
+ milestone = parent.milestones.new(params)
- if milestone.save
+ if milestone.save && milestone.project_milestone?
event_service.open_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb
index e457212508f..b18651476a8 100644
--- a/app/services/milestones/destroy_service.rb
+++ b/app/services/milestones/destroy_service.rb
@@ -1,15 +1,17 @@
module Milestones
class DestroyService < Milestones::BaseService
def execute(milestone)
+ return unless milestone.project_milestone?
+
Milestone.transaction do
update_params = { milestone: nil }
milestone.issues.each do |issue|
- Issues::UpdateService.new(project, current_user, update_params).execute(issue)
+ Issues::UpdateService.new(parent, current_user, update_params).execute(issue)
end
milestone.merge_requests.each do |merge_request|
- MergeRequests::UpdateService.new(project, current_user, update_params).execute(merge_request)
+ MergeRequests::UpdateService.new(parent, current_user, update_params).execute(merge_request)
end
event_service.destroy_milestone(milestone, current_user)
diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb
new file mode 100644
index 00000000000..2187f26d1ed
--- /dev/null
+++ b/app/services/milestones/promote_service.rb
@@ -0,0 +1,85 @@
+module Milestones
+ class PromoteService < Milestones::BaseService
+ PromoteMilestoneError = Class.new(StandardError)
+
+ def execute(milestone)
+ check_project_milestone!(milestone)
+
+ Milestone.transaction do
+ group_milestone = clone_project_milestone(milestone)
+
+ move_children_to_group_milestone(group_milestone)
+
+ # Destroy all milestones with same title across projects
+ destroy_old_milestones(milestone)
+
+ # Rollback if milestone is not valid
+ unless group_milestone.valid?
+ raise_error(group_milestone.errors.full_messages.to_sentence)
+ end
+
+ group_milestone
+ end
+ end
+
+ private
+
+ def milestone_ids_for_merge(group_milestone)
+ # Pluck need to be used here instead of select so the array of ids
+ # is persistent after old milestones gets deleted.
+ @milestone_ids_for_merge ||= begin
+ search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' }
+ milestones = MilestonesFinder.new(search_params).execute
+ milestones.pluck(:id)
+ end
+ end
+
+ def move_children_to_group_milestone(group_milestone)
+ milestone_ids_for_merge(group_milestone).in_groups_of(100, false) do |milestone_ids|
+ update_children(group_milestone, milestone_ids)
+ end
+ end
+
+ def check_project_milestone!(milestone)
+ raise_error('Only project milestones can be promoted.') unless milestone.project_milestone?
+ end
+
+ def clone_project_milestone(milestone)
+ params = milestone.slice(:title, :description, :start_date, :due_date, :state_event)
+
+ create_service = CreateService.new(group, current_user, params)
+
+ milestone = create_service.execute
+
+ # milestone won't be valid here because of duplicated title
+ milestone.save(validate: false)
+
+ milestone
+ end
+
+ def update_children(group_milestone, milestone_ids)
+ issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids)
+ merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids)
+
+ [issues, merge_requests].each do |issuable_collection|
+ issuable_collection.update_all(milestone_id: group_milestone.id)
+ end
+ end
+
+ def group
+ @group ||= parent.group || raise_error('Project does not belong to a group.')
+ end
+
+ def destroy_old_milestones(milestone)
+ Milestone.where(id: milestone_ids_for_merge(milestone)).destroy_all
+ end
+
+ def group_project_ids
+ @group_project_ids ||= group.projects.pluck(:id)
+ end
+
+ def raise_error(message)
+ raise PromoteMilestoneError, "Promotion failed - #{message}"
+ end
+ end
+end
diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb
index 573f9ee5c21..3efb33157c5 100644
--- a/app/services/milestones/reopen_service.rb
+++ b/app/services/milestones/reopen_service.rb
@@ -1,7 +1,7 @@
module Milestones
class ReopenService < Milestones::BaseService
def execute(milestone)
- if milestone.activate
+ if milestone.activate && milestone.project_milestone?
event_service.reopen_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/update_service.rb b/app/services/milestones/update_service.rb
index ed64847f429..31b441ed476 100644
--- a/app/services/milestones/update_service.rb
+++ b/app/services/milestones/update_service.rb
@@ -5,9 +5,9 @@ module Milestones
case state
when 'activate'
- Milestones::ReopenService.new(project, current_user, {}).execute(milestone)
+ Milestones::ReopenService.new(parent, current_user, {}).execute(milestone)
when 'close'
- Milestones::CloseService.new(project, current_user, {}).execute(milestone)
+ Milestones::CloseService.new(parent, current_user, {}).execute(milestone)
end
if params.present?
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index abf25bb778b..77e7b8a5ea7 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -26,14 +26,19 @@ module Notes
if project
project.notes.find_discussion(discussion_id)
else
- # only PersonalSnippets can have discussions without project association
discussion = Note.find_discussion(discussion_id)
noteable = discussion.noteable
- return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
+ return nil unless noteable_without_project?(noteable)
discussion
end
end
+
+ def noteable_without_project?(noteable)
+ return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
+
+ false
+ end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 06971483992..9ea28733f5f 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -4,7 +4,13 @@ module Notes
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
note = Notes::BuildService.new(project, current_user, params).execute
- return note unless note.valid?
+
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37440
+ note_valid = Gitlab::GitalyClient.allow_n_plus_1_calls do
+ note.valid?
+ end
+
+ return note unless note_valid
# We execute commands (extracted from `params[:note]`) on the noteable
# **before** we save the note because if the note consists of commands
diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb
index b819bd17039..fb78420d324 100644
--- a/app/services/notes/destroy_service.rb
+++ b/app/services/notes/destroy_service.rb
@@ -1,7 +1,9 @@
module Notes
class DestroyService < BaseService
def execute(note)
- note.destroy
+ TodoService.new.destroy_target(note) do |note|
+ note.destroy
+ end
end
end
end
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 6a10e172483..ad3dcc5010b 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -11,7 +11,7 @@ module Notes
unless @note.system?
EventCreateService.new.leave_note(@note, @note.author)
- return if @note.for_personal_snippet?
+ return unless @note.for_project_noteable?
@note.create_cross_references!
execute_note_hooks
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index a8d0cc15527..0a33d5f3f3d 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -9,14 +9,12 @@ module Notes
UPDATE_SERVICES[note.noteable_type]
end
- def self.supported?(note, current_user)
- noteable_update_service(note) &&
- current_user &&
- current_user.can?(:"update_#{note.to_ability_name}", note.noteable)
+ def self.supported?(note)
+ !!noteable_update_service(note)
end
def supported?(note)
- self.class.supported?(note, current_user)
+ self.class.supported?(note)
end
def extract_commands(note, options = {})
diff --git a/app/services/notes/render_service.rb b/app/services/notes/render_service.rb
new file mode 100644
index 00000000000..a77e98c2b07
--- /dev/null
+++ b/app/services/notes/render_service.rb
@@ -0,0 +1,21 @@
+module Notes
+ class RenderService < BaseRenderer
+ # Renders a collection of Note instances.
+ #
+ # notes - The notes to render.
+ # project - The project to use for redacting.
+ # user - The user viewing the notes.
+
+ # Possible options:
+ # requested_path - The request path.
+ # project_wiki - The project's wiki.
+ # ref - The current Git reference.
+ # only_path - flag to turn relative paths into absolute ones.
+ # xhtml - flag to save the html in XHTML
+ def execute(notes, project, **opts)
+ renderer = Banzai::ObjectRenderer.new(project, current_user, **opts)
+
+ renderer.render(notes, :note)
+ end
+ end
+end
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 8d1820bc504..e4be953e810 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -1,328 +1,303 @@
#
# Used by NotificationService to determine who should receive notification
#
-class NotificationRecipientService
- attr_reader :project
-
- def initialize(project)
- @project = project
+module NotificationRecipientService
+ def self.notifiable_users(users, *args)
+ users.compact.map { |u| NotificationRecipient.new(u, *args) }.select(&:notifiable?).map(&:user)
end
- def build_recipients(target, current_user, action:, previous_assignee: nil, skip_current_user: true)
- custom_action = build_custom_key(action, target)
-
- recipients = target.participants(current_user)
- recipients = add_project_watchers(recipients)
- recipients = add_custom_notifications(recipients, custom_action)
- recipients = reject_mention_users(recipients)
-
- # Re-assign is considered as a mention of the new assignee so we add the
- # new assignee to the list of recipients after we rejected users with
- # the "on mention" notification level
- case custom_action
- when :reassign_merge_request
- recipients << previous_assignee if previous_assignee
- recipients << target.assignee
- when :reassign_issue
- previous_assignees = Array(previous_assignee)
- recipients.concat(previous_assignees)
- recipients.concat(target.assignees)
- end
-
- recipients = reject_muted_users(recipients)
- recipients = add_subscribed_users(recipients, target)
-
- if [:new_issue, :new_merge_request].include?(custom_action)
- recipients = add_labels_subscribers(recipients, target)
- end
-
- recipients = reject_unsubscribed_users(recipients, target)
- recipients = reject_users_without_access(recipients, target)
-
- recipients.delete(current_user) if skip_current_user && !current_user.notified_of_own_activity?
-
- recipients.uniq
+ def self.notifiable?(user, *args)
+ NotificationRecipient.new(user, *args).notifiable?
end
- def build_pipeline_recipients(target, current_user, action:)
- return [] unless current_user
-
- custom_action =
- case action.to_s
- when 'failed'
- :failed_pipeline
- when 'success'
- :success_pipeline
- end
-
- notification_setting = notification_setting_for_user_project(current_user, target.project)
-
- return [] if notification_setting.mention? || notification_setting.disabled?
-
- return [] if notification_setting.custom? && !notification_setting.event_enabled?(custom_action)
-
- return [] if (notification_setting.watch? || notification_setting.participating?) && NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action)
-
- reject_users_without_access([current_user], target)
+ def self.build_recipients(*a)
+ Builder::Default.new(*a).notification_recipients
end
- def build_relabeled_recipients(target, current_user, labels:)
- recipients = add_labels_subscribers([], target, labels: labels)
- recipients = reject_unsubscribed_users(recipients, target)
- recipients = reject_users_without_access(recipients, target)
- recipients.delete(current_user) unless current_user.notified_of_own_activity?
- recipients.uniq
+ def self.build_new_note_recipients(*a)
+ Builder::NewNote.new(*a).notification_recipients
end
- def build_new_note_recipients(note)
- target = note.noteable
+ module Builder
+ class Base
+ def initialize(*)
+ raise 'abstract'
+ end
- ability, subject = if note.for_personal_snippet?
- [:read_personal_snippet, note.noteable]
- else
- [:read_project, note.project]
- end
+ def build!
+ raise 'abstract'
+ end
- mentioned_users = note.mentioned_users.select { |user| user.can?(ability, subject) }
+ def filter!
+ recipients.select!(&:notifiable?)
+ end
- # Add all users participating in the thread (author, assignee, comment authors)
- recipients =
- if target.respond_to?(:participants)
- target.participants(note.author)
- else
- mentioned_users
+ def acting_user
+ current_user
end
- unless note.for_personal_snippet?
- # Merge project watchers
- recipients = add_project_watchers(recipients)
+ def target
+ raise 'abstract'
+ end
- # Merge project with custom notification
- recipients = add_custom_notifications(recipients, :new_note)
- end
+ # rubocop:disable Rails/Delegate
+ def project
+ target.project
+ end
- # Reject users with Mention notification level, except those mentioned in _this_ note.
- recipients = reject_mention_users(recipients - mentioned_users)
- recipients = recipients + mentioned_users
+ def recipients
+ @recipients ||= []
+ end
- recipients = reject_muted_users(recipients)
+ def add_recipients(users, type, reason)
+ if users.is_a?(ActiveRecord::Relation)
+ users = users.includes(:notification_settings)
+ end
- recipients = add_subscribed_users(recipients, note.noteable)
- recipients = reject_unsubscribed_users(recipients, note.noteable)
- recipients = reject_users_without_access(recipients, note.noteable)
+ users = Array(users)
+ users.compact!
+ recipients.concat(users.map { |u| make_recipient(u, type, reason) })
+ end
- recipients.delete(note.author) unless note.author.notified_of_own_activity?
- recipients.uniq
- end
+ def user_scope
+ User.includes(:notification_settings)
+ end
- # Remove users with disabled notifications from array
- # Also remove duplications and nil recipients
- def reject_muted_users(users)
- reject_users(users, :disabled)
- end
+ def make_recipient(user, type, reason)
+ NotificationRecipient.new(
+ user, type,
+ reason: reason,
+ project: project,
+ custom_action: custom_action,
+ target: target,
+ acting_user: acting_user
+ )
+ end
- protected
+ def notification_recipients
+ @notification_recipients ||=
+ begin
+ build!
+ filter!
+ recipients = self.recipients.sort_by { |r| NotificationReason.priority(r.reason) }.uniq(&:user)
+ recipients.freeze
+ end
+ end
- # Get project/group users with CUSTOM notification level
- def add_custom_notifications(recipients, action)
- user_ids = []
+ def custom_action
+ nil
+ end
- # Users with a notification setting on group or project
- user_ids += user_ids_notifiable_on(project, :custom, action)
- user_ids += user_ids_notifiable_on(project.group, :custom, action)
+ protected
- # Users with global level custom
- user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
- user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global)
+ def add_participants(user)
+ return unless target.respond_to?(:participants)
- global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
- user_ids += user_ids_with_global_level_custom(global_users_ids, action)
+ add_recipients(target.participants(user), :participating, nil)
+ end
- recipients.concat(User.find(user_ids))
- end
+ def add_mentions(user, target:)
+ return unless target.respond_to?(:mentioned_users)
- def add_project_watchers(recipients)
- recipients.concat(project_watchers).compact
- end
+ add_recipients(target.mentioned_users(user), :mention, NotificationReason::MENTIONED)
+ end
- # Get project users with WATCH notification level
- def project_watchers
- project_members_ids = user_ids_notifiable_on(project)
+ # Get project/group users with CUSTOM notification level
+ def add_custom_notifications
+ user_ids = []
- user_ids_with_project_global = user_ids_notifiable_on(project, :global)
- user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
+ # Users with a notification setting on group or project
+ user_ids += user_ids_notifiable_on(project, :custom)
+ user_ids += user_ids_notifiable_on(project.group, :custom)
- user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
+ # Users with global level custom
+ user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
+ user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global)
- user_ids_with_project_setting = select_project_members_ids(project, user_ids_with_project_global, user_ids)
- user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
+ global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
+ user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
- User.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq).to_a
- end
+ add_recipients(user_scope.where(id: user_ids), :watch, nil)
+ end
- # Remove users with notification level 'Mentioned'
- def reject_mention_users(users)
- reject_users(users, :mention)
- end
+ def add_project_watchers
+ add_recipients(project_watchers, :watch, nil)
+ end
- def add_subscribed_users(recipients, target)
- return recipients unless target.respond_to? :subscribers
+ # Get project users with WATCH notification level
+ def project_watchers
+ project_members_ids = user_ids_notifiable_on(project)
- recipients + target.subscribers(project)
- end
+ user_ids_with_project_global = user_ids_notifiable_on(project, :global)
+ user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
- def user_ids_notifiable_on(resource, notification_level = nil, action = nil)
- return [] unless resource
+ user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
- if notification_level
- settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level])
- settings = settings.select { |setting| setting.event_enabled?(action) } if action.present?
- settings.map(&:user_id)
- else
- resource.notification_settings.pluck(:user_id)
- end
- end
+ user_ids_with_project_setting = select_project_members_ids(user_ids_with_project_global, user_ids)
+ user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
- # Build a list of user_ids based on project notification settings
- def select_project_members_ids(project, global_setting, user_ids_global_level_watch)
- user_ids = user_ids_notifiable_on(project, :watch)
-
- # If project setting is global, add to watch list if global setting is watch
- global_setting.each do |user_id|
- if user_ids_global_level_watch.include?(user_id)
- user_ids << user_id
+ user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq)
end
- end
-
- user_ids
- end
-
- # Build a list of user_ids based on group notification settings
- def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
- uids = user_ids_notifiable_on(group, :watch)
- # Group setting is watch, add to user_ids list if user is not project member
- user_ids = []
- uids.each do |user_id|
- if project_members.exclude?(user_id)
- user_ids << user_id
- end
- end
+ def add_subscribed_users
+ return unless target.respond_to? :subscribers
- # Group setting is global, add to user_ids list if global setting is watch
- global_setting.each do |user_id|
- if project_members.exclude?(user_id) && user_ids_global_level_watch.include?(user_id)
- user_ids << user_id
+ add_recipients(target.subscribers(project), :subscription, nil)
end
- end
-
- user_ids
- end
- def user_ids_with_global_level_watch(ids)
- settings_with_global_level_of(:watch, ids).pluck(:user_id)
- end
+ def user_ids_notifiable_on(resource, notification_level = nil)
+ return [] unless resource
- def user_ids_with_global_level_custom(ids, action)
- settings = settings_with_global_level_of(:custom, ids)
- settings = settings.select { |setting| setting.event_enabled?(action) }
- settings.map(&:user_id)
- end
+ scope = resource.notification_settings
- def settings_with_global_level_of(level, ids)
- NotificationSetting.where(
- user_id: ids,
- source_type: nil,
- level: NotificationSetting.levels[level]
- )
- end
+ if notification_level
+ scope = scope.where(level: NotificationSetting.levels[notification_level])
+ end
- # Reject users which has certain notification level
- #
- # Example:
- # reject_users(users, :watch, project)
- #
- def reject_users(users, level)
- level = level.to_s
+ scope.pluck(:user_id)
+ end
- unless NotificationSetting.levels.keys.include?(level)
- raise 'Invalid notification level'
- end
+ # Build a list of user_ids based on project notification settings
+ def select_project_members_ids(global_setting, user_ids_global_level_watch)
+ user_ids = user_ids_notifiable_on(project, :watch)
- users = users.to_a.compact.uniq
- users = users.select { |u| u.can?(:receive_notifications) }
+ # If project setting is global, add to watch list if global setting is watch
+ user_ids + (global_setting & user_ids_global_level_watch)
+ end
- users.reject do |user|
- global_notification_setting = user.global_notification_setting
+ # Build a list of user_ids based on group notification settings
+ def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
+ uids = user_ids_notifiable_on(group, :watch)
- next global_notification_setting.level == level unless project
+ # Group setting is global, add to user_ids list if global setting is watch
+ uids + (global_setting & user_ids_global_level_watch) - project_members
+ end
- setting = user.notification_settings_for(project)
+ def user_ids_with_global_level_watch(ids)
+ settings_with_global_level_of(:watch, ids).pluck(:user_id)
+ end
- if project.group && (setting.nil? || setting.global?)
- setting = user.notification_settings_for(project.group)
+ def user_ids_with_global_level_custom(ids, action)
+ settings_with_global_level_of(:custom, ids).pluck(:user_id)
end
- # reject users who globally set mention notification and has no setting per project/group
- next global_notification_setting.level == level unless setting
+ def settings_with_global_level_of(level, ids)
+ NotificationSetting.where(
+ user_id: ids,
+ source_type: nil,
+ level: NotificationSetting.levels[level]
+ )
+ end
- # reject users who set mention notification in project
- next true if setting.level == level
+ def add_labels_subscribers(labels: nil)
+ return unless target.respond_to? :labels
- # reject users who have mention level in project and disabled in global settings
- setting.global? && global_notification_setting.level == level
+ (labels || target.labels).each do |label|
+ add_recipients(label.subscribers(project), :subscription, nil)
+ end
+ end
end
- end
-
- def reject_unsubscribed_users(recipients, target)
- return recipients unless target.respond_to? :subscriptions
- recipients.reject do |user|
- subscription = target.subscriptions.find_by_user_id(user.id)
- subscription && !subscription.subscribed
- end
- end
+ class Default < Base
+ attr_reader :target
+ attr_reader :current_user
+ attr_reader :action
+ attr_reader :previous_assignee
+ attr_reader :skip_current_user
+ def initialize(target, current_user, action:, previous_assignee: nil, skip_current_user: true)
+ @target = target
+ @current_user = current_user
+ @action = action
+ @previous_assignee = previous_assignee
+ @skip_current_user = skip_current_user
+ end
- def reject_users_without_access(recipients, target)
- ability = case target
- when Issuable
- :"read_#{target.to_ability_name}"
- when Ci::Pipeline
- :read_build # We have build trace in pipeline emails
- end
+ def build!
+ add_participants(current_user)
+ add_project_watchers
+ add_custom_notifications
+
+ # Re-assign is considered as a mention of the new assignee
+ case custom_action
+ when :reassign_merge_request
+ add_recipients(previous_assignee, :mention, nil)
+ add_recipients(target.assignee, :mention, NotificationReason::ASSIGNED)
+ when :reassign_issue
+ previous_assignees = Array(previous_assignee)
+ add_recipients(previous_assignees, :mention, nil)
+ add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
+ end
+
+ add_subscribed_users
+
+ if [:new_issue, :new_merge_request].include?(custom_action)
+ # These will all be participants as well, but adding with the :mention
+ # type ensures that users with the mention notification level will
+ # receive them, too.
+ add_mentions(current_user, target: target)
+
+ # Add the assigned users, if any
+ assignees = custom_action == :new_issue ? target.assignees : target.assignee
+ # We use the `:participating` notification level in order to match existing legacy behavior as captured
+ # in existing specs (notification_service_spec.rb ~ line 507)
+ add_recipients(assignees, :participating, NotificationReason::ASSIGNED) if assignees
+
+ add_labels_subscribers
+ end
+ end
- return recipients unless ability
+ def acting_user
+ current_user if skip_current_user
+ end
- recipients.select do |user|
- user.can?(ability, target)
+ # Build event key to search on custom notification level
+ # Check NotificationSetting::EMAIL_EVENTS
+ def custom_action
+ @custom_action ||= "#{action}_#{target.class.model_name.name.underscore}".to_sym
+ end
end
- end
- def add_labels_subscribers(recipients, target, labels: nil)
- return recipients unless target.respond_to? :labels
+ class NewNote < Base
+ attr_reader :note
+ def initialize(note)
+ @note = note
+ end
- (labels || target.labels).each do |label|
- recipients += label.subscribers(project)
- end
+ def target
+ note.noteable
+ end
- recipients
- end
+ # NOTE: may be nil, in the case of a PersonalSnippet
+ #
+ # (this is okay because NotificationRecipient is written
+ # to handle nil projects)
+ def project
+ note.project
+ end
- # Build event key to search on custom notification level
- # Check NotificationSetting::EMAIL_EVENTS
- def build_custom_key(action, object)
- "#{action}_#{object.class.model_name.name.underscore}".to_sym
- end
+ def build!
+ # Add all users participating in the thread (author, assignee, comment authors)
+ add_participants(note.author)
+ add_mentions(note.author, target: note)
- def notification_setting_for_user_project(user, project)
- project_setting = user.notification_settings_for(project)
+ if note.for_project_noteable?
+ # Merge project watchers
+ add_project_watchers
- return project_setting unless project_setting.global?
+ # Merge project with custom notification
+ add_custom_notifications
+ end
- group_setting = user.notification_settings_for(project.group)
+ add_subscribed_users
+ end
- return group_setting unless group_setting.global?
+ def custom_action
+ :new_note
+ end
- user.global_notification_setting
+ def acting_user
+ note.author
+ end
+ end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 3a98a5f6b64..d7d2cde1004 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -1,3 +1,5 @@
+# rubocop:disable GitlabSecurity/PublicSend
+
# NotificationService class
#
# Used for notifying users with emails about different events
@@ -10,17 +12,22 @@ class NotificationService
# only if ssh key is not deploy key
#
# This is security email so it will be sent
- # even if user disabled notifications
+ # even if user disabled notifications. However,
+ # it won't be sent to internal users like the
+ # ghost user or the EE support bot.
def new_key(key)
- if key.user
+ if key.user&.can?(:receive_notifications)
mailer.new_ssh_key_email(key.id).deliver_later
end
end
- # Always notify user about email added to profile
- def new_email(email)
- if email.user
- mailer.new_email_email(email.id).deliver_later
+ # Always notify the user about gpg key added
+ #
+ # This is a security email so it will be sent even if the user user disabled
+ # notifications
+ def new_gpg_key(gpg_key)
+ if gpg_key.user&.can?(:receive_notifications)
+ mailer.new_gpg_key_email(gpg_key.id).deliver_later
end
end
@@ -32,7 +39,7 @@ class NotificationService
# * users with custom level checked with "new issue"
#
def new_issue(issue, current_user)
- new_resource_email(issue, issue.project, :new_issue_email)
+ new_resource_email(issue, :new_issue_email)
end
# When issue text is updated, we should send an email to:
@@ -42,7 +49,6 @@ class NotificationService
def new_mentions_in_issue(issue, new_mentioned_users, current_user)
new_mentions_in_resource_email(
issue,
- issue.project,
new_mentioned_users,
current_user,
:new_mention_in_issue_email
@@ -57,7 +63,7 @@ class NotificationService
# * users with custom level checked with "close issue"
#
def close_issue(issue, current_user)
- close_resource_email(issue, issue.project, current_user, :closed_issue_email)
+ close_resource_email(issue, current_user, :closed_issue_email)
end
# When we reassign an issue we should send an email to:
@@ -67,7 +73,7 @@ class NotificationService
# * users with custom level checked with "reassign issue"
#
def reassigned_issue(issue, current_user, previous_assignees = [])
- recipients = NotificationRecipientService.new(issue.project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
issue,
current_user,
action: "reassign",
@@ -79,10 +85,11 @@ class NotificationService
recipients.each do |recipient|
mailer.send(
:reassigned_issue_email,
- recipient.id,
+ recipient.user.id,
issue.id,
previous_assignee_ids,
- current_user.id
+ current_user.id,
+ recipient.reason
).deliver_later
end
end
@@ -92,7 +99,7 @@ class NotificationService
# * watchers of the issue's labels
#
def relabeled_issue(issue, added_labels, current_user)
- relabeled_resource_email(issue, issue.project, added_labels, current_user, :relabeled_issue_email)
+ relabeled_resource_email(issue, added_labels, current_user, :relabeled_issue_email)
end
# When create a merge request we should send an email to:
@@ -103,7 +110,7 @@ class NotificationService
# * users with custom level checked with "new merge request"
#
def new_merge_request(merge_request, current_user)
- new_resource_email(merge_request, merge_request.target_project, :new_merge_request_email)
+ new_resource_email(merge_request, :new_merge_request_email)
end
# When merge request text is updated, we should send an email to:
@@ -113,7 +120,6 @@ class NotificationService
def new_mentions_in_merge_request(merge_request, new_mentioned_users, current_user)
new_mentions_in_resource_email(
merge_request,
- merge_request.target_project,
new_mentioned_users,
current_user,
:new_mention_in_merge_request_email
@@ -127,7 +133,7 @@ class NotificationService
# * users with custom level checked with "reassign merge request"
#
def reassigned_merge_request(merge_request, current_user)
- reassign_resource_email(merge_request, merge_request.target_project, current_user, :reassigned_merge_request_email)
+ reassign_resource_email(merge_request, current_user, :reassigned_merge_request_email)
end
# When we add labels to a merge request we should send an email to:
@@ -135,21 +141,20 @@ class NotificationService
# * watchers of the mr's labels
#
def relabeled_merge_request(merge_request, added_labels, current_user)
- relabeled_resource_email(merge_request, merge_request.target_project, added_labels, current_user, :relabeled_merge_request_email)
+ relabeled_resource_email(merge_request, added_labels, current_user, :relabeled_merge_request_email)
end
def close_mr(merge_request, current_user)
- close_resource_email(merge_request, merge_request.target_project, current_user, :closed_merge_request_email)
+ close_resource_email(merge_request, current_user, :closed_merge_request_email)
end
def reopen_issue(issue, current_user)
- reopen_resource_email(issue, issue.project, current_user, :issue_status_changed_email, 'reopened')
+ reopen_resource_email(issue, current_user, :issue_status_changed_email, 'reopened')
end
def merge_mr(merge_request, current_user)
close_resource_email(
merge_request,
- merge_request.target_project,
current_user,
:merged_merge_request_email,
skip_current_user: !merge_request.merge_when_pipeline_succeeds?
@@ -159,7 +164,6 @@ class NotificationService
def reopen_mr(merge_request, current_user)
reopen_resource_email(
merge_request,
- merge_request.target_project,
current_user,
:merge_request_status_email,
'reopened'
@@ -167,18 +171,20 @@ class NotificationService
end
def resolve_all_discussions(merge_request, current_user)
- recipients = NotificationRecipientService.new(merge_request.target_project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
merge_request,
current_user,
action: "resolve_all_discussions")
recipients.each do |recipient|
- mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
+ mailer.resolved_all_discussions_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later
end
end
# Notify new user with email after creation
def new_user(user, token = nil)
+ return true unless notifiable?(user, :mention)
+
# Don't email omniauth created users
mailer.new_user_email(user.id, token).deliver_later unless user.identities.any?
end
@@ -192,27 +198,40 @@ class NotificationService
notify_method = "note_#{note.to_ability_name}_email".to_sym
- recipients = NotificationRecipientService.new(note.project).build_new_note_recipients(note)
+ recipients = NotificationRecipientService.build_new_note_recipients(note)
recipients.each do |recipient|
- mailer.send(notify_method, recipient.id, note.id).deliver_later
+ mailer.send(notify_method, recipient.user.id, note.id).deliver_later
end
end
# Members
def new_access_request(member)
- mailer.member_access_requested_email(member.real_source_type, member.id).deliver_later
+ return true unless member.notifiable?(:subscription)
+
+ recipients = member.source.members.active_without_invites_and_requests.owners_and_masters
+ if fallback_to_group_owners_masters?(recipients, member)
+ recipients = member.source.group.members.active_without_invites_and_requests.owners_and_masters
+ end
+
+ recipients.each { |recipient| deliver_access_request_email(recipient, member) }
end
def decline_access_request(member)
+ return true unless member.notifiable?(:subscription)
+
mailer.member_access_denied_email(member.real_source_type, member.source_id, member.user_id).deliver_later
end
# Project invite
def invite_project_member(project_member, token)
+ return true unless project_member.notifiable?(:subscription)
+
mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later
end
def accept_project_invite(project_member)
+ return true unless project_member.notifiable?(:subscription)
+
mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later
end
@@ -226,10 +245,14 @@ class NotificationService
end
def new_project_member(project_member)
+ return true unless project_member.notifiable?(:mention, skip_read_ability: true)
+
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
def update_project_member(project_member)
+ return true unless project_member.notifiable?(:mention)
+
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
@@ -243,6 +266,9 @@ class NotificationService
end
def decline_group_invite(group_member)
+ # always send this one, since it's a response to the user's own
+ # action
+
mailer.member_invite_declined_email(
group_member.real_source_type,
group_member.group.id,
@@ -252,16 +278,19 @@ class NotificationService
end
def new_group_member(group_member)
+ return true unless group_member.notifiable?(:mention)
+
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def update_group_member(group_member)
+ return true unless group_member.notifiable?(:mention)
+
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def project_was_moved(project, old_path_with_namespace)
- recipients = project.team.members
- recipients = NotificationRecipientService.new(project).reject_muted_users(recipients)
+ recipients = notifiable_users(project.team.members, :mention, project: project)
recipients.each do |recipient|
mailer.project_was_moved_email(
@@ -273,20 +302,24 @@ class NotificationService
end
def issue_moved(issue, new_issue, current_user)
- recipients = NotificationRecipientService.new(issue.project).build_recipients(issue, current_user, action: 'moved')
+ recipients = NotificationRecipientService.build_recipients(issue, current_user, action: 'moved')
recipients.map do |recipient|
- email = mailer.issue_moved_email(recipient, issue, new_issue, current_user)
+ email = mailer.issue_moved_email(recipient.user, issue, new_issue, current_user, recipient.reason)
email.deliver_later
email
end
end
def project_exported(project, current_user)
+ return true unless notifiable?(current_user, :mention, project: project)
+
mailer.project_was_exported_email(current_user, project).deliver_later
end
def project_not_exported(project, current_user, errors)
+ return true unless notifiable?(current_user, :mention, project: project)
+
mailer.project_was_not_exported_email(current_user, project, errors).deliver_later
end
@@ -295,10 +328,10 @@ class NotificationService
return unless mailer.respond_to?(email_template)
- recipients ||= NotificationRecipientService.new(pipeline.project).build_pipeline_recipients(
- pipeline,
- pipeline.user,
- action: pipeline.status
+ recipients ||= notifiable_users(
+ [pipeline.user], :watch,
+ custom_action: :"#{pipeline.status}_pipeline",
+ target: pipeline
).map(&:notification_email)
if recipients.any?
@@ -306,29 +339,53 @@ class NotificationService
end
end
+ def pages_domain_verification_succeeded(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_verification_succeeded_email(domain, user).deliver_later
+ end
+ end
+
+ def pages_domain_verification_failed(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_verification_failed_email(domain, user).deliver_later
+ end
+ end
+
+ def pages_domain_enabled(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_enabled_email(domain, user).deliver_later
+ end
+ end
+
+ def pages_domain_disabled(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_disabled_email(domain, user).deliver_later
+ end
+ end
+
protected
- def new_resource_email(target, project, method)
- recipients = NotificationRecipientService.new(project).build_recipients(target, target.author, action: "new")
+ def new_resource_email(target, method)
+ recipients = NotificationRecipientService.build_recipients(target, target.author, action: "new")
recipients.each do |recipient|
- mailer.send(method, recipient.id, target.id).deliver_later
+ mailer.send(method, recipient.user.id, target.id, recipient.reason).deliver_later
end
end
- def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method)
- recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "new")
- recipients = recipients & new_mentioned_users
+ def new_mentions_in_resource_email(target, new_mentioned_users, current_user, method)
+ recipients = NotificationRecipientService.build_recipients(target, current_user, action: "new")
+ recipients = recipients.select {|r| new_mentioned_users.include?(r.user) }
recipients.each do |recipient|
- mailer.send(method, recipient.id, target.id, current_user.id).deliver_later
+ mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
end
end
- def close_resource_email(target, project, current_user, method, skip_current_user: true)
+ def close_resource_email(target, current_user, method, skip_current_user: true)
action = method == :merged_merge_request_email ? "merge" : "close"
- recipients = NotificationRecipientService.new(project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
target,
current_user,
action: action,
@@ -336,15 +393,15 @@ class NotificationService
)
recipients.each do |recipient|
- mailer.send(method, recipient.id, target.id, current_user.id).deliver_later
+ mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
end
end
- def reassign_resource_email(target, project, current_user, method)
+ def reassign_resource_email(target, current_user, method)
previous_assignee_id = previous_record(target, 'assignee_id')
previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
- recipients = NotificationRecipientService.new(project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
target,
current_user,
action: "reassign",
@@ -354,16 +411,23 @@ class NotificationService
recipients.each do |recipient|
mailer.send(
method,
- recipient.id,
+ recipient.user.id,
target.id,
previous_assignee_id,
- current_user.id
+ current_user.id,
+ recipient.reason
).deliver_later
end
end
- def relabeled_resource_email(target, project, labels, current_user, method)
- recipients = NotificationRecipientService.new(project).build_relabeled_recipients(target, current_user, labels: labels)
+ def relabeled_resource_email(target, labels, current_user, method)
+ recipients = labels.flat_map { |l| l.subscribers(target.project) }.uniq
+ recipients = notifiable_users(
+ recipients, :subscription,
+ target: target,
+ acting_user: current_user
+ )
+
label_names = labels.map(&:name)
recipients.each do |recipient|
@@ -371,11 +435,11 @@ class NotificationService
end
end
- def reopen_resource_email(target, project, current_user, method, status)
- recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "reopen")
+ def reopen_resource_email(target, current_user, method, status)
+ recipients = NotificationRecipientService.build_recipients(target, current_user, action: "reopen")
recipients.each do |recipient|
- mailer.send(method, recipient.id, target.id, status, current_user.id).deliver_later
+ mailer.send(method, recipient.user.id, target.id, status, current_user.id, recipient.reason).deliver_later
end
end
@@ -390,4 +454,32 @@ class NotificationService
object.previous_changes[attribute].first
end
end
+
+ private
+
+ def recipients_for_pages_domain(domain)
+ project = domain.project
+
+ return [] unless project
+
+ notifiable_users(project.team.masters, :watch, target: project)
+ end
+
+ def notifiable?(*args)
+ NotificationRecipientService.notifiable?(*args)
+ end
+
+ def notifiable_users(*args)
+ NotificationRecipientService.notifiable_users(*args)
+ end
+
+ def deliver_access_request_email(recipient, member)
+ mailer.member_access_requested_email(member.real_source_type, member.id, recipient.user.notification_email).deliver_later
+ end
+
+ def fallback_to_group_owners_masters?(recipients, member)
+ return false if recipients.present?
+
+ member.source.respond_to?(:group) && member.source.group
+ end
end
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
new file mode 100644
index 00000000000..3047268b2d1
--- /dev/null
+++ b/app/services/projects/after_import_service.rb
@@ -0,0 +1,24 @@
+module Projects
+ class AfterImportService
+ RESERVED_REF_PREFIXES = Repository::RESERVED_REFS_NAMES.map { |n| File.join('refs', n, '/') }
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ Projects::HousekeepingService.new(@project).execute do
+ repository.delete_all_refs_except(RESERVED_REF_PREFIXES)
+ end
+ rescue Projects::HousekeepingService::LeaseTaken => e
+ Rails.logger.info(
+ "Could not perform housekeeping for project #{@project.full_path} (#{@project.id}): #{e}")
+ end
+
+ private
+
+ def repository
+ @repository ||= @project.repository
+ end
+ end
+end
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index fc85f398935..e61ecb696d0 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -5,15 +5,38 @@ module Projects
end
def milestones
- @project.milestones.active.reorder(due_date: :asc, title: :asc).select([:iid, :title])
+ finder_params = {
+ project_ids: [@project.id],
+ state: :active,
+ order: { due_date: :asc, title: :asc }
+ }
+
+ finder_params[:group_ids] = [@project.group.id] if @project.group
+
+ MilestonesFinder.new(finder_params).execute.select([:iid, :title])
end
def merge_requests
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end
- def labels
- LabelsFinder.new(current_user, project_id: project.id).execute.select([:title, :color])
+ def labels(target = nil)
+ labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title])
+
+ return labels unless target&.respond_to?(:labels)
+
+ issuable_label_titles = target.labels.pluck(:title)
+
+ if issuable_label_titles
+ labels = labels.as_json(only: [:title, :color])
+
+ issuable_label_titles.each do |issuable_label_title|
+ found_label = labels.find { |label| label['title'] == issuable_label_title }
+ found_label[:set] = true if found_label
+ end
+ end
+
+ labels
end
def commands(noteable, type)
@@ -25,18 +48,9 @@ module Projects
@project.merge_requests.build
end
- return [] unless noteable && noteable.is_a?(Issuable)
-
- opts = {
- project: project,
- issuable: noteable,
- current_user: current_user
- }
- QuickActions::InterpretService.command_definitions.map do |definition|
- next unless definition.available?(opts)
+ return [] unless noteable&.is_a?(Issuable)
- definition.to_h(opts)
- end.compact
+ QuickActions::InterpretService.new(project, current_user).available_commands(noteable)
end
end
end
diff --git a/app/services/projects/batch_count_service.rb b/app/services/projects/batch_count_service.rb
new file mode 100644
index 00000000000..178ebc5a143
--- /dev/null
+++ b/app/services/projects/batch_count_service.rb
@@ -0,0 +1,31 @@
+# Service class for getting and caching the number of elements of several projects
+# Warning: do not user this service with a really large set of projects
+# because the service use maps to retrieve the project ids.
+module Projects
+ class BatchCountService
+ def initialize(projects)
+ @projects = projects
+ end
+
+ def refresh_cache
+ @projects.each do |project|
+ service = count_service.new(project)
+ unless service.count_stored?
+ service.refresh_cache { global_count[project.id].to_i }
+ end
+ end
+ end
+
+ def project_ids
+ @projects.map(&:id)
+ end
+
+ def global_count(project)
+ raise NotImplementedError, 'global_count must be implemented and return an hash indexed by the project id'
+ end
+
+ def count_service
+ raise NotImplementedError, 'count_service must be implemented and return a Projects::CountService object'
+ end
+ end
+end
diff --git a/app/services/projects/batch_forks_count_service.rb b/app/services/projects/batch_forks_count_service.rb
new file mode 100644
index 00000000000..e61fe6c86b2
--- /dev/null
+++ b/app/services/projects/batch_forks_count_service.rb
@@ -0,0 +1,18 @@
+# Service class for getting and caching the number of forks of several projects
+# Warning: do not user this service with a really large set of projects
+# because the service use maps to retrieve the project ids
+module Projects
+ class BatchForksCountService < Projects::BatchCountService
+ def global_count
+ @global_count ||= begin
+ count_service.query(project_ids)
+ .group(:forked_from_project_id)
+ .count
+ end
+ end
+
+ def count_service
+ ::Projects::ForksCountService
+ end
+ end
+end
diff --git a/app/services/projects/batch_open_issues_count_service.rb b/app/services/projects/batch_open_issues_count_service.rb
new file mode 100644
index 00000000000..3b0ade2419b
--- /dev/null
+++ b/app/services/projects/batch_open_issues_count_service.rb
@@ -0,0 +1,16 @@
+# Service class for getting and caching the number of issues of several projects
+# Warning: do not user this service with a really large set of projects
+# because the service use maps to retrieve the project ids
+module Projects
+ class BatchOpenIssuesCountService < Projects::BatchCountService
+ def global_count
+ @global_count ||= begin
+ count_service.query(project_ids).group(:project_id).count
+ end
+ end
+
+ def count_service
+ ::Projects::OpenIssuesCountService
+ end
+ end
+end
diff --git a/app/services/projects/count_service.rb b/app/services/projects/count_service.rb
new file mode 100644
index 00000000000..933829b557b
--- /dev/null
+++ b/app/services/projects/count_service.rb
@@ -0,0 +1,36 @@
+module Projects
+ # Base class for the various service classes that count project data (e.g.
+ # issues or forks).
+ class CountService < BaseCountService
+ # The version of the cache format. This should be bumped whenever the
+ # underlying logic changes. This removes the need for explicitly flushing
+ # all caches.
+ VERSION = 1
+
+ def initialize(project)
+ @project = project
+ end
+
+ def relation_for_count
+ self.class.query(@project.id)
+ end
+
+ def cache_key_name
+ raise(
+ NotImplementedError,
+ '"cache_key_name" must be implemented and return a String'
+ )
+ end
+
+ def cache_key
+ ['projects', 'count_service', VERSION, @project.id, cache_key_name]
+ end
+
+ def self.query(project_ids)
+ raise(
+ NotImplementedError,
+ '"query" must be implemented and return an ActiveRecord::Relation'
+ )
+ end
+ end
+end
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
new file mode 100644
index 00000000000..a549cfbabea
--- /dev/null
+++ b/app/services/projects/create_from_template_service.rb
@@ -0,0 +1,19 @@
+module Projects
+ class CreateFromTemplateService < BaseService
+ def initialize(user, params)
+ @current_user, @params = user, params.dup
+ end
+
+ def execute
+ template_name = params.delete(:template_name)
+ file = Gitlab::ProjectTemplate.find(template_name).file
+
+ params[:file] = file
+
+ GitlabProjectsImportService.new(current_user, params).execute
+
+ ensure
+ file&.close
+ end
+ end
+end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index e874a2d8789..7fa1387084c 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -5,6 +5,10 @@ module Projects
end
def execute
+ if @params[:template_name]&.present?
+ return ::Projects::CreateFromTemplateService.new(current_user, params).execute
+ end
+
forked_from_project_id = params.delete(:forked_from_project_id)
import_data = params.delete(:import_data)
@skip_wiki = params.delete(:skip_wiki)
@@ -40,6 +44,8 @@ module Projects
@project.namespace_id = current_user.namespace_id
end
+ yield(@project) if block_given?
+
@project.creator = current_user
if forked_from_project_id
@@ -50,11 +56,7 @@ module Projects
after_create_actions if @project.persisted?
- if @project.errors.empty?
- @project.import_schedule if @project.import?
- else
- fail(error: @project.errors.full_messages.join(', '))
- end
+ import_schedule
@project
rescue ActiveRecord::RecordInvalid => e
@@ -83,9 +85,10 @@ module Projects
end
def after_create_actions
- log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"")
+ log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"")
unless @project.gitlab_project_import?
+ @project.write_repository_config
@project.create_wiki unless skip_wiki?
create_services_from_active_templates(@project)
@@ -95,12 +98,19 @@ module Projects
event_service.create_project(@project, current_user)
system_hook_service.execute_hooks_for(@project, :create)
- unless @project.group || @project.gitlab_project_import?
- owners = [current_user, @project.namespace.owner].compact.uniq
- @project.add_master(owners, current_user: current_user)
- end
+ setup_authorizations
+ end
- @project.group&.refresh_members_authorized_projects
+ # Refresh the current user's authorizations inline (so they can access the
+ # project immediately after this request completes), and any other affected
+ # users in the background
+ def setup_authorizations
+ if @project.group
+ @project.group.refresh_members_authorized_projects(blocking: false)
+ current_user.refresh_authorized_projects
+ else
+ @project.add_master(@project.namespace.owner, current_user: current_user)
+ end
end
def skip_wiki?
@@ -151,5 +161,15 @@ module Projects
@project.path = @project.name.dup.parameterize
end
end
+
+ private
+
+ def import_schedule
+ if @project.errors.empty?
+ @project.import_schedule if @project.import? && !@project.bare_repository_import?
+ else
+ fail(error: @project.errors.full_messages.join(', '))
+ end
+ end
end
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index e2b2660ea71..4b8f955ae69 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -9,46 +9,61 @@ module Projects
def async_execute
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}")
+ Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}")
end
def execute
return false unless can?(current_user, :remove_project, project)
- repo_path = project.path_with_namespace
- wiki_path = repo_path + '.wiki'
-
# Flush the cache for both repositories. This has to be done _before_
# removing the physical repositories as some expiration code depends on
# Git data (e.g. a list of branch names).
- flush_caches(project, wiki_path)
+ flush_caches(project)
Projects::UnlinkForkService.new(project, current_user).execute
- Project.transaction do
- project.team.truncate
- project.destroy!
-
- unless remove_legacy_registry_tags
- raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
- end
-
- unless remove_repository(repo_path)
- raise_error('Failed to remove project repository. Please try again or contact administrator.')
- end
-
- unless remove_repository(wiki_path)
- raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
- end
+ # The project is not necessarily a fork, so update the fork network originating
+ # from this project
+ if fork_network = project.root_of_fork_network
+ fork_network.update(root_project: nil,
+ deleted_root_project_name: project.full_name)
end
- log_info("Project \"#{project.path_with_namespace}\" was removed")
+ attempt_destroy_transaction(project)
+
system_hook_service.execute_hooks_for(project, :destroy)
+ log_info("Project \"#{project.full_path}\" was removed")
+
true
+ rescue => error
+ attempt_rollback(project, error.message)
+ false
+ rescue Exception => error # rubocop:disable Lint/RescueException
+ # Project.transaction can raise Exception
+ attempt_rollback(project, error.message)
+ raise
end
private
+ def repo_path
+ project.disk_path
+ end
+
+ def wiki_path
+ project.wiki.disk_path
+ end
+
+ def trash_repositories!
+ unless remove_repository(repo_path)
+ raise_error('Failed to remove project repository. Please try again or contact administrator.')
+ end
+
+ unless remove_repository(wiki_path)
+ raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
+ end
+ end
+
def remove_repository(path)
# Skip repository removal. We use this flag when remove user or group
return true if params[:skip_repo] == true
@@ -70,6 +85,30 @@ module Projects
end
end
+ def attempt_rollback(project, message)
+ return unless project
+
+ # It's possible that the project was destroyed, but some after_commit
+ # hook failed and caused us to end up here. A destroyed model will be a frozen hash,
+ # which cannot be altered.
+ project.update_attributes(delete_error: message, pending_delete: false) unless project.destroyed?
+
+ log_error("Deletion failed on #{project.full_path} with the following message: #{message}")
+ end
+
+ def attempt_destroy_transaction(project)
+ Project.transaction do
+ unless remove_legacy_registry_tags
+ raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
+ end
+
+ trash_repositories!
+
+ project.team.truncate
+ project.destroy!
+ end
+ end
+
##
# This method makes sure that we correctly remove registry tags
# for legacy image repository (when repository path equals project path).
@@ -96,10 +135,12 @@ module Projects
"#{path}+#{project.id}#{DELETED_FLAG}"
end
- def flush_caches(project, wiki_path)
+ def flush_caches(project)
project.repository.before_delete
- Repository.new(wiki_path, project).before_delete
+ Repository.new(wiki_path, project, disk_path: repo_path).before_delete
+
+ Projects::ForksCountService.new(project).delete_cache
end
end
end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index a2b23ea6171..348eb0bf8d8 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -1,6 +1,24 @@
module Projects
class ForkService < BaseService
- def execute
+ def execute(fork_to_project = nil)
+ if fork_to_project
+ link_existing_project(fork_to_project)
+ else
+ fork_new_project
+ end
+ end
+
+ private
+
+ def link_existing_project(fork_to_project)
+ return if fork_to_project.forked?
+
+ link_fork_network(fork_to_project)
+
+ fork_to_project
+ end
+
+ def fork_new_project
new_params = {
forked_from_project_id: @project.id,
visibility_level: allowed_visibility_level,
@@ -8,7 +26,7 @@ module Projects
name: @project.name,
path: @project.path,
shared_runners_enabled: @project.shared_runners_enabled,
- namespace_id: @params[:namespace].try(:id) || current_user.namespace.id
+ namespace_id: target_namespace.id
}
if @project.avatar.present? && @project.avatar.image?
@@ -21,19 +39,49 @@ module Projects
builds_access_level = @project.project_feature.builds_access_level
new_project.project_feature.update_attributes(builds_access_level: builds_access_level)
+ link_fork_network(new_project)
+
new_project
end
- private
+ def fork_network
+ if @project.fork_network
+ @project.fork_network
+ elsif forked_from_project = @project.forked_from_project
+ # TODO: remove this case when all background migrations have completed
+ # this only happens when a project had a `forked_project_link` that was
+ # not migrated to the `fork_network` relation
+ forked_from_project.fork_network || forked_from_project.create_root_of_fork_network
+ else
+ @project.create_root_of_fork_network
+ end
+ end
- def allowed_visibility_level
- project_level = @project.visibility_level
+ def link_fork_network(fork_to_project)
+ fork_network.fork_network_members.create(project: fork_to_project,
+ forked_from_project: @project)
- if Gitlab::VisibilityLevel.non_restricted_level?(project_level)
- project_level
- else
- Gitlab::VisibilityLevel.highest_allowed_level
+ # TODO: remove this when ForkedProjectLink model is removed
+ unless fork_to_project.forked_project_link
+ fork_to_project.create_forked_project_link(forked_to_project: fork_to_project,
+ forked_from_project: @project)
end
+
+ refresh_forks_count
+ end
+
+ def refresh_forks_count
+ Projects::ForksCountService.new(@project).refresh_cache
+ end
+
+ def target_namespace
+ @target_namespace ||= @params[:namespace] || current_user.namespace
+ end
+
+ def allowed_visibility_level
+ target_level = [@project.visibility_level, target_namespace.visibility_level].min
+
+ Gitlab::VisibilityLevel.closest_allowed_level(target_level)
end
end
end
diff --git a/app/services/projects/forks_count_service.rb b/app/services/projects/forks_count_service.rb
new file mode 100644
index 00000000000..dc6eb19affd
--- /dev/null
+++ b/app/services/projects/forks_count_service.rb
@@ -0,0 +1,15 @@
+module Projects
+ # Service class for getting and caching the number of forks of a project.
+ class ForksCountService < Projects::CountService
+ def cache_key_name
+ 'forks_count'
+ end
+
+ def self.query(project_ids)
+ # We can't directly change ForkedProjectLink to ForkNetworkMember here
+ # Nowadays, when a call using v3 to projects/:id/fork is made,
+ # the relationship to ForkNetworkMember is not updated
+ ForkedProjectLink.where(forked_from_project: project_ids)
+ end
+ end
+end
diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
new file mode 100644
index 00000000000..a68ecb4abe1
--- /dev/null
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -0,0 +1,34 @@
+# This service is an adapter used to for the GitLab Import feature, and
+# creating a project from a template.
+# The latter will under the hood just import an archive supplied by GitLab.
+module Projects
+ class GitlabProjectsImportService
+ attr_reader :current_user, :params
+
+ def initialize(user, params)
+ @current_user, @params = user, params.dup
+ end
+
+ def execute
+ FileUtils.mkdir_p(File.dirname(import_upload_path))
+
+ file = params.delete(:file)
+ FileUtils.copy_entry(file.path, import_upload_path)
+
+ params[:import_type] = 'gitlab_project'
+ params[:import_source] = import_upload_path
+
+ ::Projects::CreateService.new(current_user, params).execute
+ end
+
+ private
+
+ def import_upload_path
+ @import_upload_path ||= Gitlab::ImportExport.import_upload_path(filename: tmp_filename)
+ end
+
+ def tmp_filename
+ SecureRandom.hex
+ end
+ end
+end
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
new file mode 100644
index 00000000000..35624577024
--- /dev/null
+++ b/app/services/projects/group_links/create_service.rb
@@ -0,0 +1,15 @@
+module Projects
+ module GroupLinks
+ class CreateService < BaseService
+ def execute(group)
+ return false unless group
+
+ project.project_group_links.create(
+ group: group,
+ group_access: params[:link_group_access],
+ expires_at: params[:expires_at]
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb
new file mode 100644
index 00000000000..e3a20b4c1e4
--- /dev/null
+++ b/app/services/projects/group_links/destroy_service.rb
@@ -0,0 +1,11 @@
+module Projects
+ module GroupLinks
+ class DestroyService < BaseService
+ def execute(group_link)
+ return false unless group_link
+
+ group_link.destroy
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb
new file mode 100644
index 00000000000..bc897d891d5
--- /dev/null
+++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb
@@ -0,0 +1,54 @@
+module Projects
+ module HashedStorage
+ AttachmentMigrationError = Class.new(StandardError)
+
+ class MigrateAttachmentsService < BaseService
+ attr_reader :logger, :old_path, :new_path
+
+ def initialize(project, logger = nil)
+ @project = project
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ @old_path = project.full_path
+ @new_path = project.disk_path
+
+ origin = FileUploader.absolute_base_dir(project)
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments]
+ target = FileUploader.absolute_base_dir(project)
+
+ result = move_folder!(origin, target)
+ project.save!
+
+ if result && block_given?
+ yield
+ end
+
+ result
+ end
+
+ private
+
+ def move_folder!(old_path, new_path)
+ unless File.directory?(old_path)
+ logger.info("Skipped attachments migration from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
+ return
+ end
+
+ if File.exist?(new_path)
+ logger.error("Cannot migrate attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})")
+ raise AttachmentMigrationError, "Target path '#{new_path}' already exist"
+ end
+
+ # Create hashed storage base path folder
+ FileUtils.mkdir_p(File.dirname(new_path))
+
+ FileUtils.mv(old_path, new_path)
+ logger.info("Migrated project attachments from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})")
+
+ true
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
new file mode 100644
index 00000000000..67178de75de
--- /dev/null
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -0,0 +1,72 @@
+module Projects
+ module HashedStorage
+ class MigrateRepositoryService < BaseService
+ include Gitlab::ShellAdapter
+
+ attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger
+
+ def initialize(project, logger = nil)
+ @project = project
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ @old_disk_path = project.disk_path
+ has_wiki = project.wiki.repository_exists?
+
+ @old_storage_version = project.storage_version
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
+ project.ensure_storage_path_exists
+
+ @new_disk_path = project.disk_path
+
+ result = move_repository(@old_disk_path, @new_disk_path)
+
+ if has_wiki
+ @old_wiki_disk_path = "#{@old_disk_path}.wiki"
+ result &&= move_repository("#{@old_wiki_disk_path}", "#{@new_disk_path}.wiki")
+ end
+
+ if result
+ project.write_repository_config
+ else
+ rollback_folder_move
+ project.storage_version = nil
+ end
+
+ project.repository_read_only = false
+ project.save!
+
+ if result && block_given?
+ yield
+ end
+
+ result
+ end
+
+ private
+
+ def move_repository(from_name, to_name)
+ from_exists = gitlab_shell.exists?(project.repository_storage_path, "#{from_name}.git")
+ to_exists = gitlab_shell.exists?(project.repository_storage_path, "#{to_name}.git")
+
+ # If we don't find the repository on either original or target we should log that as it could be an issue if the
+ # project was not originally empty.
+ if !from_exists && !to_exists
+ logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
+ return false
+ elsif !from_exists
+ # Repository have been moved already.
+ return true
+ end
+
+ gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
+ end
+
+ def rollback_folder_move
+ move_repository(@new_disk_path, @old_disk_path)
+ move_repository("#{@new_disk_path}.wiki", "#{@old_disk_path}.wiki")
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb
new file mode 100644
index 00000000000..662702c1db5
--- /dev/null
+++ b/app/services/projects/hashed_storage_migration_service.rb
@@ -0,0 +1,22 @@
+module Projects
+ class HashedStorageMigrationService < BaseService
+ attr_reader :logger
+
+ def initialize(project, logger = nil)
+ @project = project
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ # Migrate repository from Legacy to Hashed Storage
+ unless project.hashed_storage?(:repository)
+ return unless HashedStorage::MigrateRepositoryService.new(project, logger).execute
+ end
+
+ # Migrate attachments from Legacy to Hashed Storage
+ unless project.hashed_storage?(:attachments)
+ HashedStorage::MigrateAttachmentsService.new(project, logger).execute
+ end
+ end
+ end
+end
diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb
index 4b8946f8ee2..120d57a188d 100644
--- a/app/services/projects/housekeeping_service.rb
+++ b/app/services/projects/housekeeping_service.rb
@@ -7,9 +7,8 @@
#
module Projects
class HousekeepingService < BaseService
- include Gitlab::CurrentSettings
-
- LEASE_TIMEOUT = 3600
+ # Timeout set to 24h
+ LEASE_TIMEOUT = 86400
class LeaseTaken < StandardError
def to_s
@@ -25,6 +24,8 @@ module Projects
lease_uuid = try_obtain_lease
raise LeaseTaken unless lease_uuid.present?
+ yield if block_given?
+
execute_gitlab_shell_gc(lease_uuid)
end
@@ -80,19 +81,19 @@ module Projects
end
def housekeeping_enabled?
- current_application_settings.housekeeping_enabled
+ Gitlab::CurrentSettings.housekeeping_enabled
end
def gc_period
- current_application_settings.housekeeping_gc_period
+ Gitlab::CurrentSettings.housekeeping_gc_period
end
def full_repack_period
- current_application_settings.housekeeping_full_repack_period
+ Gitlab::CurrentSettings.housekeeping_full_repack_period
end
def repack_period
- current_application_settings.housekeeping_incremental_repack_period
+ Gitlab::CurrentSettings.housekeeping_incremental_repack_period
end
end
end
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index 535da706159..d16aa3de639 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -2,7 +2,7 @@ module Projects
module ImportExport
class ExportService < BaseService
def execute(_options = {})
- @shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.path_with_namespace, 'work'))
+ @shared = project.import_export_shared
save_all
end
@@ -26,7 +26,7 @@ module Projects
end
def project_tree_saver
- Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared)
+ Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared, params: @params)
end
def uploads_saver
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index eea17e24903..a34024f4f80 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -4,19 +4,37 @@ module Projects
Error = Class.new(StandardError)
+ # Returns true if this importer is supposed to perform its work in the
+ # background.
+ #
+ # This method will only return `true` if async importing is explicitly
+ # supported by an importer class (`Gitlab::GithubImport::ParallelImporter`
+ # for example).
+ def async?
+ has_importer? && !!importer_class.try(:async?)
+ end
+
def execute
- add_repository_to_project unless project.gitlab_project_import?
+ add_repository_to_project
import_data
success
rescue => e
- error("Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}")
+ error("Error importing repository #{project.import_url} into #{project.full_path} - #{e.message}")
end
private
def add_repository_to_project
+ if project.external_import? && !unknown_url?
+ raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS)
+ end
+
+ # We should skip the repository for a GitHub import or GitLab project import,
+ # because these importers fetch the project repositories for us.
+ return if has_importer? && importer_class.try(:imports_repository?)
+
if unknown_url?
# In this case, we only want to import issues, not a repository.
create_repository
@@ -32,15 +50,16 @@ module Projects
end
def import_repository
- raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url)
-
begin
- if project.github_import? || project.gitea_import?
- fetch_repository
+ refmap = importer_class.try(:refmap) if has_importer?
+
+ if refmap
+ project.ensure_repository
+ project.repository.fetch_as_mirror(project.import_url, refmap: refmap)
else
- clone_repository
+ gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, project.import_url)
end
- rescue Gitlab::Shell::Error => e
+ rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e
# Expire cache to prevent scenarios such as:
# 1. First import failed, but the repo was imported successfully, so +exists?+ returns true
# 2. Retried import, repo is broken or not imported but +exists?+ still returns true
@@ -50,17 +69,6 @@ module Projects
end
end
- def clone_repository
- gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url)
- end
-
- def fetch_repository
- project.create_repository
- project.repository.add_remote(project.import_type, project.import_url)
- project.repository.set_remote_as_mirror(project.import_type)
- project.repository.fetch_remote(project.import_type, forced: true)
- end
-
def import_data
return unless has_importer?
@@ -71,12 +79,16 @@ module Projects
end
end
+ def importer_class
+ @importer_class ||= Gitlab::ImportSources.importer(project.import_type)
+ end
+
def has_importer?
Gitlab::ImportSources.importer_names.include?(project.import_type)
end
def importer
- Gitlab::ImportSources.importer(project.import_type).new(project)
+ importer_class.new(project)
end
def unknown_url?
diff --git a/app/services/projects/open_issues_count_service.rb b/app/services/projects/open_issues_count_service.rb
new file mode 100644
index 00000000000..a975a06a05c
--- /dev/null
+++ b/app/services/projects/open_issues_count_service.rb
@@ -0,0 +1,15 @@
+module Projects
+ # Service class for counting and caching the number of open issues of a
+ # project.
+ class OpenIssuesCountService < Projects::CountService
+ def cache_key_name
+ 'open_issues_count'
+ end
+
+ def self.query(project_ids)
+ # We don't include confidential issues in this number since this would
+ # expose the number of confidential issues to non project members.
+ Issue.opened.public_only.where(project: project_ids)
+ end
+ end
+end
diff --git a/app/services/projects/open_merge_requests_count_service.rb b/app/services/projects/open_merge_requests_count_service.rb
new file mode 100644
index 00000000000..77e6448fd5e
--- /dev/null
+++ b/app/services/projects/open_merge_requests_count_service.rb
@@ -0,0 +1,13 @@
+module Projects
+ # Service class for counting and caching the number of open merge requests of
+ # a project.
+ class OpenMergeRequestsCountService < Projects::CountService
+ def relation_for_count
+ @project.merge_requests.opened
+ end
+
+ def cache_key_name
+ 'open_merge_requests_count'
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index fd701e33524..26765e5c3f3 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -34,7 +34,7 @@ module Projects
private
def transfer(project)
- @old_path = project.path_with_namespace
+ @old_path = project.full_path
@old_group = project.group
@new_path = File.join(@new_namespace.try(:full_path) || '', project.path)
@old_namespace = project.namespace
@@ -60,24 +60,22 @@ module Projects
# Notifications
project.send_move_instructions(@old_path)
- # Move main repository
- unless move_repo_folder(@old_path, @new_path)
- raise TransferError.new('Cannot move project')
- end
-
- # Move wiki repo also if present
- move_repo_folder("#{@old_path}.wiki", "#{@new_path}.wiki")
+ # Directories on disk
+ move_project_folders(project)
# Move missing group labels to project
Labels::TransferService.new(current_user, @old_group, project).execute
# Move uploads
- Gitlab::UploadsTransfer.new.move_project(project.path, @old_namespace.full_path, @new_namespace.full_path)
+ move_project_uploads(project)
# Move pages
Gitlab::PagesTransfer.new.move_project(project.path, @old_namespace.full_path, @new_namespace.full_path)
project.old_path_with_namespace = @old_path
+ project.expires_full_path_cache
+
+ write_repository_config(@new_path)
execute_system_hooks
end
@@ -102,6 +100,10 @@ module Projects
project.save!
end
+ def write_repository_config(full_path)
+ project.write_repository_config(gl_full_path: full_path)
+ end
+
def refresh_permissions
# This ensures we only schedule 1 job for every user that has access to
# the namespaces.
@@ -114,6 +116,7 @@ module Projects
def rollback_side_effects
rollback_folder_move
update_namespace_and_visibility(@old_namespace)
+ write_repository_config(@old_path)
end
def rollback_folder_move
@@ -128,5 +131,30 @@ module Projects
def execute_system_hooks
SystemHooksService.new.execute_hooks_for(project, :transfer)
end
+
+ def move_project_folders(project)
+ return if project.hashed_storage?(:repository)
+
+ # Move main repository
+ unless move_repo_folder(@old_path, @new_path)
+ raise TransferError.new("Cannot move project")
+ end
+
+ # Disk path is changed; we need to ensure we reload it
+ project.reload_repository!
+
+ # Move wiki repo also if present
+ move_repo_folder("#{@old_path}.wiki", "#{@new_path}.wiki")
+ end
+
+ def move_project_uploads(project)
+ return if project.hashed_storage?(:attachments)
+
+ Gitlab::UploadsTransfer.new.move_project(
+ project.path,
+ @old_namespace.full_path,
+ @new_namespace.full_path
+ )
+ end
end
end
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index 315c3e16292..842fe4e09c4 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -3,17 +3,30 @@ module Projects
def execute
return unless @project.forked?
- @project.forked_from_project.lfs_objects.find_each do |lfs_object|
- lfs_object.projects << @project
+ if fork_source = @project.fork_source
+ fork_source.lfs_objects.find_each do |lfs_object|
+ lfs_object.projects << @project unless lfs_object.projects.include?(@project)
+ end
+
+ refresh_forks_count(fork_source)
end
- merge_requests = @project.forked_from_project.merge_requests.opened.from_project(@project)
+ merge_requests = @project.fork_network
+ .merge_requests
+ .opened
+ .where.not(target_project: @project)
+ .from_project(@project)
merge_requests.each do |mr|
- MergeRequests::CloseService.new(@project, @current_user).execute(mr)
+ ::MergeRequests::CloseService.new(@project, @current_user).execute(mr)
end
+ @project.fork_network_member.destroy
@project.forked_project_link.destroy
end
+
+ def refresh_forks_count(project)
+ Projects::ForksCountService.new(project).refresh_cache
+ end
end
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index cacb74b1205..25017c5cbe3 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -18,20 +18,30 @@ module Projects
def pages_config
{
- domains: pages_domains_config
+ domains: pages_domains_config,
+ https_only: project.pages_https_only?
}
end
def pages_domains_config
- project.pages_domains.map do |domain|
+ enabled_pages_domains.map do |domain|
{
domain: domain.domain,
certificate: domain.certificate,
- key: domain.key
+ key: domain.key,
+ https_only: project.pages_https_only? && domain.https?
}
end
end
+ def enabled_pages_domains
+ if Gitlab::CurrentSettings.pages_domain_verification_enabled?
+ project.pages_domains.enabled
+ else
+ project.pages_domains
+ end
+ end
+
def reload_daemon
# GitLab Pages daemon constantly watches for modification time of `pages.path`
# It reloads configuration when `pages.path` is modified
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 17cf71cf098..00fdd047208 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -1,5 +1,8 @@
module Projects
class UpdatePagesService < BaseService
+ InvaildStateError = Class.new(StandardError)
+ FailedToExtractError = Class.new(StandardError)
+
BLOCK_SIZE = 32.kilobytes
MAX_SIZE = 1.terabyte
SITE_PATH = 'public/'.freeze
@@ -11,13 +14,15 @@ module Projects
end
def execute
+ register_attempt
+
# Create status notifying the deployment of pages
@status = create_status
@status.enqueue!
@status.run!
- raise 'missing pages artifacts' unless build.artifacts_file?
- raise 'pages are outdated' unless latest?
+ raise InvaildStateError, 'missing pages artifacts' unless build.artifacts?
+ raise InvaildStateError, 'pages are outdated' unless latest?
# Create temporary directory in which we will extract the artifacts
FileUtils.mkdir_p(tmp_path)
@@ -26,22 +31,22 @@ module Projects
# Check if we did extract public directory
archive_public_path = File.join(archive_path, 'public')
- raise 'pages miss the public folder' unless Dir.exist?(archive_public_path)
- raise 'pages are outdated' unless latest?
+ raise FailedToExtractError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
+ raise InvaildStateError, 'pages are outdated' unless latest?
deploy_page!(archive_public_path)
success
end
- rescue => e
+ rescue InvaildStateError, FailedToExtractError => e
+ register_failure
error(e.message)
- ensure
- build.erase_artifacts! unless build.has_expiring_artifacts?
end
private
def success
@status.success
+ delete_artifact!
super
end
@@ -49,7 +54,8 @@ module Projects
log_error("Projects::UpdatePagesService: #{message}")
@status.allow_failure = !latest?
@status.description = message
- @status.drop
+ @status.drop(:script_failure)
+ delete_artifact!
super
end
@@ -70,7 +76,7 @@ module Projects
elsif artifacts.ends_with?('.zip')
extract_zip_archive!(temp_path)
else
- raise 'unsupported artifacts format'
+ raise FailedToExtractError, 'unsupported artifacts format'
end
end
@@ -79,25 +85,26 @@ module Projects
%W(dd bs=#{BLOCK_SIZE} count=#{blocks}),
%W(tar -x -C #{temp_path} #{SITE_PATH}),
err: '/dev/null')
- raise 'pages failed to extract' unless results.compact.all?(&:success?)
+ raise FailedToExtractError, 'pages failed to extract' unless results.compact.all?(&:success?)
end
def extract_zip_archive!(temp_path)
- raise 'missing artifacts metadata' unless build.artifacts_metadata?
+ raise FailedToExtractError, 'missing artifacts metadata' unless build.artifacts_metadata?
# Calculate page size after extract
public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true)
if public_entry.total_size > max_size
- raise "artifacts for pages are too large: #{public_entry.total_size}"
+ raise FailedToExtractError, "artifacts for pages are too large: #{public_entry.total_size}"
end
# Requires UnZip at least 6.00 Info-ZIP.
+ # -qq be (very) quiet
# -n never overwrite existing files
# We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories
site_path = File.join(SITE_PATH, '*')
- unless system(*%W(unzip -n #{artifacts} #{site_path} -d #{temp_path}))
- raise 'pages failed to extract'
+ unless system(*%W(unzip -qq -n #{artifacts} #{site_path} -d #{temp_path}))
+ raise FailedToExtractError, 'pages failed to extract'
end
end
@@ -129,7 +136,11 @@ module Projects
end
def max_size
- current_application_settings.max_pages_size.megabytes || MAX_SIZE
+ max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes
+
+ return MAX_SIZE if max_pages_size.zero?
+
+ [max_pages_size, MAX_SIZE].min
end
def tmp_path
@@ -156,6 +167,11 @@ module Projects
build.artifacts_file.path
end
+ def delete_artifact!
+ build.reload # Reload stable object to prevent erase artifacts with old state
+ build.erase_artifacts! unless build.has_expiring_artifacts?
+ end
+
def latest_sha
project.commit(build.ref).try(:sha).to_s
end
@@ -163,5 +179,21 @@ module Projects
def sha
build.sha
end
+
+ def register_attempt
+ pages_deployments_total_counter.increment
+ end
+
+ def register_failure
+ pages_deployments_failed_total_counter.increment
+ end
+
+ def pages_deployments_total_counter
+ @pages_deployments_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_total, "Counter of GitLab Pages deployments triggered")
+ end
+
+ def pages_deployments_failed_total_counter
+ @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
+ end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 55d9cb13ae4..679f4a9cb62 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -1,24 +1,22 @@
module Projects
class UpdateService < BaseService
- def execute
- # check that user is allowed to set specified visibility_level
- new_visibility = params[:visibility_level]
-
- if new_visibility && new_visibility.to_i != project.visibility_level
- unless can?(current_user, :change_visibility_level, project) &&
- Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
+ include UpdateVisibilityLevel
- deny_visibility_level(project, new_visibility)
- return error('Visibility level unallowed')
- end
+ def execute
+ unless valid_visibility_level_change?(project, params[:visibility_level])
+ return error('New visibility level not allowed!')
end
- new_branch = params[:default_branch]
+ if renaming_project_with_container_registry_tags?
+ return error('Cannot rename project because it contains container registry tags!')
+ end
- if project.repository.exists? && new_branch && new_branch != project.default_branch
- project.change_head(new_branch)
+ if changing_default_branch?
+ return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end
+ ensure_wiki_exists if enabling_wiki?
+
if project.update_attributes(params.except(:default_branch))
if project.previous_changes.include?('path')
project.rename_repo
@@ -26,10 +24,58 @@ module Projects
system_hook_service.execute_hooks_for(project, :update)
end
+ update_pages_config if changing_pages_https_only?
+
success
else
- error('Project could not be updated')
+ model_errors = project.errors.full_messages.to_sentence
+ error_message = model_errors.presence || 'Project could not be updated!'
+
+ error(error_message)
end
end
+
+ def run_auto_devops_pipeline?
+ return false if project.repository.gitlab_ci_yml || !project.auto_devops.previous_changes.include?('enabled')
+
+ project.auto_devops.enabled? || (project.auto_devops.enabled.nil? && Gitlab::CurrentSettings.auto_devops_enabled?)
+ end
+
+ private
+
+ def renaming_project_with_container_registry_tags?
+ new_path = params[:path]
+
+ new_path && new_path != project.path &&
+ project.has_container_registry_tags?
+ end
+
+ def changing_default_branch?
+ new_branch = params[:default_branch]
+
+ project.repository.exists? &&
+ new_branch && new_branch != project.default_branch
+ end
+
+ def enabling_wiki?
+ return false if @project.wiki_enabled?
+
+ params.dig(:project_feature_attributes, :wiki_access_level).to_i > ProjectFeature::DISABLED
+ end
+
+ def ensure_wiki_exists
+ ProjectWiki.new(project, project.owner).wiki
+ rescue ProjectWiki::CouldNotCreateWikiError
+ log_error("Could not create wiki for #{project.full_name}")
+ Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki')
+ end
+
+ def update_pages_config
+ Projects::UpdatePagesConfigurationService.new(project).execute
+ end
+
+ def changing_pages_https_only?
+ project.previous_changes.include?(:pages_https_only)
+ end
end
end
diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb
new file mode 100644
index 00000000000..4504d2ccfe6
--- /dev/null
+++ b/app/services/prometheus/adapter_service.rb
@@ -0,0 +1,36 @@
+module Prometheus
+ class AdapterService
+ def initialize(project, deployment_platform = nil)
+ @project = project
+
+ @deployment_platform = if deployment_platform
+ deployment_platform
+ else
+ project.deployment_platform
+ end
+ end
+
+ attr_reader :deployment_platform, :project
+
+ def prometheus_adapter
+ @prometheus_adapter ||= if service_prometheus_adapter.can_query?
+ service_prometheus_adapter
+ else
+ cluster_prometheus_adapter
+ end
+ end
+
+ def service_prometheus_adapter
+ project.find_or_initialize_service('prometheus')
+ end
+
+ def cluster_prometheus_adapter
+ return unless deployment_platform.respond_to?(:cluster)
+
+ cluster = deployment_platform.cluster
+ return unless cluster.application_prometheus&.installed?
+
+ cluster.application_prometheus
+ end
+ end
+end
diff --git a/app/services/protected_branches/access_level_params.rb b/app/services/protected_branches/access_level_params.rb
new file mode 100644
index 00000000000..253ae8b0124
--- /dev/null
+++ b/app/services/protected_branches/access_level_params.rb
@@ -0,0 +1,33 @@
+module ProtectedBranches
+ class AccessLevelParams
+ attr_reader :type, :params
+
+ def initialize(type, params)
+ @type = type
+ @params = params_with_default(params)
+ end
+
+ def access_levels
+ ce_style_access_level
+ end
+
+ private
+
+ def params_with_default(params)
+ params[:"#{type}_access_level"] ||= Gitlab::Access::MASTER if use_default_access_level?(params)
+ params
+ end
+
+ def use_default_access_level?(params)
+ true
+ end
+
+ def ce_style_access_level
+ access_level = params[:"#{type}_access_level"]
+
+ return [] unless access_level
+
+ [{ access_level: access_level }]
+ end
+ end
+end
diff --git a/app/services/protected_branches/api_create_service.rb b/app/services/protected_branches/api_create_service.rb
deleted file mode 100644
index f2040dfa03a..00000000000
--- a/app/services/protected_branches/api_create_service.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# The protected branches API still uses the `developers_can_push` and `developers_can_merge`
-# flags for backward compatibility, and so performs translation between that format and the
-# internal data model (separate access levels). The translation code is non-trivial, and so
-# lives in this service.
-module ProtectedBranches
- class ApiCreateService < BaseService
- def execute
- push_access_level =
- if params.delete(:developers_can_push)
- Gitlab::Access::DEVELOPER
- else
- Gitlab::Access::MASTER
- end
-
- merge_access_level =
- if params.delete(:developers_can_merge)
- Gitlab::Access::DEVELOPER
- else
- Gitlab::Access::MASTER
- end
-
- @params.merge!(push_access_levels_attributes: [{ access_level: push_access_level }],
- merge_access_levels_attributes: [{ access_level: merge_access_level }])
-
- service = ProtectedBranches::CreateService.new(@project, @current_user, @params)
- service.execute
- end
- end
-end
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
new file mode 100644
index 00000000000..4b40200644b
--- /dev/null
+++ b/app/services/protected_branches/api_service.rb
@@ -0,0 +1,24 @@
+module ProtectedBranches
+ class ApiService < BaseService
+ def create
+ @push_params = AccessLevelParams.new(:push, params)
+ @merge_params = AccessLevelParams.new(:merge, params)
+
+ verify_params!
+
+ protected_branch_params = {
+ name: params[:name],
+ push_access_levels_attributes: @push_params.access_levels,
+ merge_access_levels_attributes: @merge_params.access_levels
+ }
+
+ ::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute
+ end
+
+ private
+
+ def verify_params!
+ # EE-only
+ end
+ end
+end
diff --git a/app/services/protected_branches/api_update_service.rb b/app/services/protected_branches/api_update_service.rb
deleted file mode 100644
index bdb0e0cc8bf..00000000000
--- a/app/services/protected_branches/api_update_service.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# The protected branches API still uses the `developers_can_push` and `developers_can_merge`
-# flags for backward compatibility, and so performs translation between that format and the
-# internal data model (separate access levels). The translation code is non-trivial, and so
-# lives in this service.
-module ProtectedBranches
- class ApiUpdateService < BaseService
- def execute(protected_branch)
- @developers_can_push = params.delete(:developers_can_push)
- @developers_can_merge = params.delete(:developers_can_merge)
-
- @protected_branch = protected_branch
-
- protected_branch.transaction do
- delete_redundant_access_levels
-
- case @developers_can_push
- when true
- params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }]
- when false
- params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::MASTER }]
- end
-
- case @developers_can_merge
- when true
- params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }]
- when false
- params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::MASTER }]
- end
-
- service = ProtectedBranches::UpdateService.new(@project, @current_user, @params)
- service.execute(protected_branch)
- end
- end
-
- private
-
- def delete_redundant_access_levels
- unless @developers_can_merge.nil?
- @protected_branch.merge_access_levels.destroy_all
- end
-
- unless @developers_can_push.nil?
- @protected_branch.push_access_levels.destroy_all
- end
- end
- end
-end
diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb
index a84e335340d..6212fd69077 100644
--- a/app/services/protected_branches/create_service.rb
+++ b/app/services/protected_branches/create_service.rb
@@ -2,8 +2,8 @@ module ProtectedBranches
class CreateService < BaseService
attr_reader :protected_branch
- def execute
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+ def execute(skip_authorization: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || can?(current_user, :admin_project, project)
project.protected_branches.create(params)
end
diff --git a/app/services/protected_branches/legacy_api_create_service.rb b/app/services/protected_branches/legacy_api_create_service.rb
new file mode 100644
index 00000000000..e358fd0374e
--- /dev/null
+++ b/app/services/protected_branches/legacy_api_create_service.rb
@@ -0,0 +1,29 @@
+# The branches#protect API still uses the `developers_can_push` and `developers_can_merge`
+# flags for backward compatibility, and so performs translation between that format and the
+# internal data model (separate access levels). The translation code is non-trivial, and so
+# lives in this service.
+module ProtectedBranches
+ class LegacyApiCreateService < BaseService
+ def execute
+ push_access_level =
+ if params.delete(:developers_can_push)
+ Gitlab::Access::DEVELOPER
+ else
+ Gitlab::Access::MASTER
+ end
+
+ merge_access_level =
+ if params.delete(:developers_can_merge)
+ Gitlab::Access::DEVELOPER
+ else
+ Gitlab::Access::MASTER
+ end
+
+ @params.merge!(push_access_levels_attributes: [{ access_level: push_access_level }],
+ merge_access_levels_attributes: [{ access_level: merge_access_level }])
+
+ service = ProtectedBranches::CreateService.new(@project, @current_user, @params)
+ service.execute
+ end
+ end
+end
diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb
new file mode 100644
index 00000000000..33176253ca2
--- /dev/null
+++ b/app/services/protected_branches/legacy_api_update_service.rb
@@ -0,0 +1,47 @@
+# The branches#protect API still uses the `developers_can_push` and `developers_can_merge`
+# flags for backward compatibility, and so performs translation between that format and the
+# internal data model (separate access levels). The translation code is non-trivial, and so
+# lives in this service.
+module ProtectedBranches
+ class LegacyApiUpdateService < BaseService
+ def execute(protected_branch)
+ @developers_can_push = params.delete(:developers_can_push)
+ @developers_can_merge = params.delete(:developers_can_merge)
+
+ @protected_branch = protected_branch
+
+ protected_branch.transaction do
+ delete_redundant_access_levels
+
+ case @developers_can_push
+ when true
+ params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }]
+ when false
+ params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::MASTER }]
+ end
+
+ case @developers_can_merge
+ when true
+ params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }]
+ when false
+ params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::MASTER }]
+ end
+
+ service = ProtectedBranches::UpdateService.new(@project, @current_user, @params)
+ service.execute(protected_branch)
+ end
+ end
+
+ private
+
+ def delete_redundant_access_levels
+ unless @developers_can_merge.nil?
+ @protected_branch.merge_access_levels.destroy_all
+ end
+
+ unless @developers_can_push.nil?
+ @protected_branch.push_access_levels.destroy_all
+ end
+ end
+ end
+end
diff --git a/app/services/push_event_payload_service.rb b/app/services/push_event_payload_service.rb
new file mode 100644
index 00000000000..b0a389c85f9
--- /dev/null
+++ b/app/services/push_event_payload_service.rb
@@ -0,0 +1,120 @@
+# Service class for creating push event payloads as stored in the
+# "push_event_payloads" table.
+#
+# Example:
+#
+# data = Gitlab::DataBuilder::Push.build(...)
+# event = Event.create(...)
+#
+# PushEventPayloadService.new(event, data).execute
+class PushEventPayloadService
+ # event - The event this push payload belongs to.
+ # push_data - A Hash produced by `Gitlab::DataBuilder::Push.build` to use for
+ # building the push payload.
+ def initialize(event, push_data)
+ @event = event
+ @push_data = push_data
+ end
+
+ # Creates and returns a new PushEventPayload row.
+ #
+ # This method will raise upon encountering validation errors.
+ #
+ # Returns an instance of PushEventPayload.
+ def execute
+ @event.build_push_event_payload(
+ commit_count: commit_count,
+ action: action,
+ ref_type: ref_type,
+ commit_from: commit_from_id,
+ commit_to: commit_to_id,
+ ref: trimmed_ref,
+ commit_title: commit_title,
+ event_id: @event.id
+ )
+
+ @event.push_event_payload.save!
+ @event.push_event_payload
+ end
+
+ # Returns the commit title to use.
+ #
+ # The commit title is limited to the first line and a maximum of 70
+ # characters.
+ def commit_title
+ commit = @push_data.fetch(:commits).last
+
+ return nil unless commit && commit[:message]
+
+ raw_msg = commit[:message]
+
+ # Find where the first line ends, without turning the entire message into an
+ # Array of lines (this is a waste of memory for large commit messages).
+ index = raw_msg.index("\n")
+ message = index ? raw_msg[0..index] : raw_msg
+
+ message.strip.truncate(70)
+ end
+
+ def commit_from_id
+ if create?
+ nil
+ else
+ revision_before
+ end
+ end
+
+ def commit_to_id
+ if remove?
+ nil
+ else
+ revision_after
+ end
+ end
+
+ def commit_count
+ @push_data.fetch(:total_commits_count)
+ end
+
+ def ref
+ @push_data.fetch(:ref)
+ end
+
+ def revision_before
+ @push_data.fetch(:before)
+ end
+
+ def revision_after
+ @push_data.fetch(:after)
+ end
+
+ def trimmed_ref
+ Gitlab::Git.ref_name(ref)
+ end
+
+ def create?
+ Gitlab::Git.blank_ref?(revision_before)
+ end
+
+ def remove?
+ Gitlab::Git.blank_ref?(revision_after)
+ end
+
+ def action
+ if create?
+ :created
+ elsif remove?
+ :removed
+ else
+ :pushed
+ end
+ end
+
+ def ref_type
+ if Gitlab::Git.tag_ref?(ref)
+ :tag
+ else
+ :branch
+ end
+ end
+end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 6816b137361..cba49faac31 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -4,6 +4,21 @@ module QuickActions
attr_reader :issuable
+ SHRUG = '¯\\_(ツ)_/¯'.freeze
+ TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze
+
+ # Takes an issuable and returns an array of all the available commands
+ # represented with .to_h
+ def available_commands(issuable)
+ @issuable = issuable
+
+ self.class.command_definitions.map do |definition|
+ next unless definition.available?(self)
+
+ definition.to_h(self)
+ end.compact
+ end
+
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
def execute(content, issuable)
@@ -12,8 +27,9 @@ module QuickActions
@issuable = issuable
@updates = {}
- content, commands = extractor.extract_commands(content, context)
- extract_updates(commands, context)
+ content, commands = extractor.extract_commands(content)
+ extract_updates(commands)
+
[content, @updates]
end
@@ -24,8 +40,8 @@ module QuickActions
@issuable = issuable
- content, commands = extractor.extract_commands(content, context)
- commands = explain_commands(commands, context)
+ content, commands = extractor.extract_commands(content)
+ commands = explain_commands(commands)
[content, commands]
end
@@ -92,9 +108,12 @@ module QuickActions
desc 'Assign'
explanation do |users|
- "Assigns #{users.first.to_reference}." if users.any?
+ users = issuable.allows_multiple_assignees? ? users : users.take(1)
+ "Assigns #{users.map(&:to_reference).to_sentence}."
+ end
+ params do
+ issuable.allows_multiple_assignees? ? '@user1 @user2' : '@user'
end
- params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
@@ -104,28 +123,43 @@ module QuickActions
command :assign do |users|
next if users.empty?
- if issuable.is_a?(Issue)
- @updates[:assignee_ids] = [users.last.id]
+ @updates[:assignee_ids] =
+ if issuable.allows_multiple_assignees?
+ issuable.assignees.pluck(:id) + users.map(&:id)
+ else
+ [users.first.id]
+ end
+ end
+
+ desc do
+ if issuable.allows_multiple_assignees?
+ 'Remove all or specific assignee(s)'
else
- @updates[:assignee_id] = users.last.id
+ 'Remove assignee'
end
end
-
- desc 'Remove assignee'
explanation do
- "Removes assignee #{issuable.assignees.first.to_reference}."
+ "Removes #{'assignee'.pluralize(issuable.assignees.size)} #{issuable.assignees.map(&:to_reference).to_sentence}."
+ end
+ params do
+ issuable.allows_multiple_assignees? ? '@user1 @user2' : ''
end
condition do
issuable.persisted? &&
issuable.assignees.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
- command :unassign do
- if issuable.is_a?(Issue)
- @updates[:assignee_ids] = []
- else
- @updates[:assignee_id] = nil
- end
+ parse_params do |unassign_param|
+ # When multiple users are assigned, all will be unassigned if multiple assignees are no longer allowed
+ extract_users(unassign_param) if issuable.allows_multiple_assignees?
+ end
+ command :unassign do |users = nil|
+ @updates[:assignee_ids] =
+ if users&.any?
+ issuable.assignees.pluck(:id) - users.map(&:id)
+ else
+ []
+ end
end
desc 'Set milestone'
@@ -135,11 +169,11 @@ module QuickActions
params '%"milestone"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- project.milestones.active.any?
+ find_milestones(project, state: 'active').any?
end
parse_params do |milestone_param|
extract_references(milestone_param, :milestone).first ||
- project.milestones.find_by(title: milestone_param.strip)
+ find_milestones(project, title: milestone_param.strip).first
end
command :milestone do |milestone|
@updates[:milestone_id] = milestone.id if milestone
@@ -313,9 +347,9 @@ module QuickActions
"#{verb} this #{noun} as Work In Progress."
end
condition do
- issuable.persisted? &&
- issuable.respond_to?(:work_in_progress?) &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ issuable.respond_to?(:work_in_progress?) &&
+ # Allow it to mark as WIP on MR creation page _or_ through MR notes.
+ (issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable))
end
command :wip do
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
@@ -359,7 +393,7 @@ module QuickActions
end
desc 'Add or substract spent time'
- explanation do |time_spent|
+ explanation do |time_spent, time_spent_date|
if time_spent
if time_spent > 0
verb = 'Adds'
@@ -372,16 +406,20 @@ module QuickActions
"#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
end
end
- params '<1h 30m | -1h 30m>'
+ params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
- parse_params do |raw_duration|
- Gitlab::TimeTrackingFormatter.parse(raw_duration)
+ parse_params do |raw_time_date|
+ Gitlab::QuickActions::SpendTimeAndDateSeparator.new(raw_time_date).execute
end
- command :spend do |time_spent|
+ command :spend do |time_spent, time_spent_date|
if time_spent
- @updates[:spend_time] = { duration: time_spent, user: current_user }
+ @updates[:spend_time] = {
+ duration: time_spent,
+ user_id: current_user.id,
+ spent_at: time_spent_date
+ }
end
end
@@ -402,7 +440,19 @@ module QuickActions
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_time_spent do
- @updates[:spend_time] = { duration: :reset, user: current_user }
+ @updates[:spend_time] = { duration: :reset, user_id: current_user.id }
+ end
+
+ desc "Append the comment with #{SHRUG}"
+ params '<Comment>'
+ substitution :shrug do |comment|
+ "#{comment} #{SHRUG}"
+ end
+
+ desc "Append the comment with #{TABLEFLIP}"
+ params '<Comment>'
+ substitution :tableflip do |comment|
+ "#{comment} #{TABLEFLIP}"
end
# This is a dummy command, so that it appears in the autocomplete commands
@@ -424,7 +474,7 @@ module QuickActions
target_branch_param.strip
end
command :target_branch do |branch_name|
- @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name)
+ @updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name)
end
desc 'Move issue from one column of the board to another'
@@ -453,18 +503,63 @@ module QuickActions
end
end
+ desc 'Mark this issue as a duplicate of another issue'
+ explanation do |duplicate_reference|
+ "Marks this issue as a duplicate of #{duplicate_reference}."
+ end
+ params '#issue'
+ condition do
+ issuable.is_a?(Issue) &&
+ issuable.persisted? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :duplicate do |duplicate_param|
+ canonical_issue = extract_references(duplicate_param, :issue).first
+
+ if canonical_issue.present?
+ @updates[:canonical_issue_id] = canonical_issue.id
+ end
+ end
+
+ desc 'Move this issue to another project.'
+ explanation do |path_to_project|
+ "Moves this issue to #{path_to_project}."
+ end
+ params 'path/to/project'
+ condition do
+ issuable.is_a?(Issue) &&
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :move do |target_project_path|
+ target_project = Project.find_by_full_path(target_project_path)
+
+ if target_project.present?
+ @updates[:target_project] = target_project
+ 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))
+ users =
+ if params == 'me'
+ [current_user]
+ else
+ User.where(username: params.split(' ').map(&:strip))
+ end
end
users
end
+ def find_milestones(project, params = {})
+ MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute
+ end
+
def find_labels(labels_param)
extract_references(labels_param, :label) |
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
@@ -478,21 +573,21 @@ module QuickActions
find_labels(labels_param).map(&:id)
end
- def explain_commands(commands, opts)
+ def explain_commands(commands)
commands.map do |name, arg|
definition = self.class.definition_by_name(name)
next unless definition
- definition.explain(self, opts, arg)
+ definition.explain(self, arg)
end.compact
end
- def extract_updates(commands, opts)
+ def extract_updates(commands)
commands.each do |name, arg|
definition = self.class.definition_by_name(name)
next unless definition
- definition.execute(self, opts, arg)
+ definition.execute(self, arg)
end
end
@@ -502,14 +597,5 @@ module QuickActions
ext.references(type)
end
-
- def context
- {
- issuable: issuable,
- current_user: current_user,
- project: project,
- params: params
- }
- end
end
end
diff --git a/app/services/reset_project_cache_service.rb b/app/services/reset_project_cache_service.rb
new file mode 100644
index 00000000000..a162a6eedb9
--- /dev/null
+++ b/app/services/reset_project_cache_service.rb
@@ -0,0 +1,5 @@
+class ResetProjectCacheService < BaseService
+ def execute
+ @project.increment!(:jobs_cache_index)
+ end
+end
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index ff188102b62..92a32e703af 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -1,13 +1,16 @@
module Search
class GlobalService
attr_accessor :current_user, :params
+ attr_reader :default_project_filter
def initialize(user, params)
@current_user, @params = user, params.dup
+ @default_project_filter = true
end
def execute
- Gitlab::SearchResults.new(current_user, projects, params[:search])
+ Gitlab::SearchResults.new(current_user, projects, params[:search],
+ default_project_filter: default_project_filter)
end
def projects
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index 29478e3251f..b4efba68715 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -5,6 +5,7 @@ module Search
def initialize(user, group, params)
super(user, params)
+ @default_project_filter = false
@group = group
end
diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb
index 11030bee8f1..d4ade869777 100644
--- a/app/services/spam_check_service.rb
+++ b/app/services/spam_check_service.rb
@@ -7,16 +7,19 @@
# - params with :request
#
module SpamCheckService
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def filter_spam_check_params
@request = params.delete(:request)
@api = params.delete(:api)
@recaptcha_verified = params.delete(:recaptcha_verified)
@spam_log_id = params.delete(:spam_log_id)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
# In order to be proceed to the spam check process, @spammable has to be
# a dirty instance, which means it should be already assigned with the new
# attribute values.
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def spam_check(spammable, user)
spam_service = SpamService.new(spammable, @request)
@@ -24,4 +27,5 @@ module SpamCheckService
user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true)
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb
index 3e65b7d31a3..73ea3018fbd 100644
--- a/app/services/spam_service.rb
+++ b/app/services/spam_service.rb
@@ -45,7 +45,7 @@ class SpamService
def check(api)
return false unless request && check_for_spam?
- return false unless akismet.is_spam?
+ return false unless akismet.spam?
create_spam_log(api)
true
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 17857ca62f2..ac029fad7ea 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -1,21 +1,30 @@
class SubmitUsagePingService
URL = 'https://version.gitlab.com/usage_data'.freeze
- include Gitlab::CurrentSettings
+ METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
+ percentage_notes leader_milestones instance_milestones percentage_milestones
+ leader_boards instance_boards percentage_boards leader_merge_requests
+ instance_merge_requests percentage_merge_requests leader_ci_pipelines
+ instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments
+ percentage_environments leader_deployments instance_deployments percentage_deployments
+ leader_projects_prometheus_active instance_projects_prometheus_active
+ percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
+ percentage_service_desk_issues].freeze
def execute
- return false unless current_application_settings.usage_ping_enabled?
+ return false unless Gitlab::CurrentSettings.usage_ping_enabled?
- response = HTTParty.post(
+ response = Gitlab::HTTP.post(
URL,
body: Gitlab::UsageData.to_json(force_refresh: true),
+ allow_local_requests: true,
headers: { 'Content-type' => 'application/json' }
)
store_metrics(response)
true
- rescue HTTParty::Error => e
+ rescue Gitlab::HTTP::Error => e
Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
false
@@ -27,15 +36,7 @@ class SubmitUsagePingService
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'
- )
+ response['conv_index'].slice(*METRICS)
)
end
end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index ed476fc9d0c..ba7946fd23c 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -1,12 +1,18 @@
class SystemHooksService
def execute_hooks_for(model, event)
- execute_hooks(build_event_data(model, event))
+ data = build_event_data(model, event)
+
+ model.run_after_commit_or_now do
+ SystemHooksService.new.execute_hooks(data)
+ end
end
def execute_hooks(data, hooks_scope = :all)
- SystemHook.send(hooks_scope).each do |hook|
+ SystemHook.hooks_for(hooks_scope).find_each do |hook|
hook.async_execute(data, 'system_hooks')
end
+
+ Gitlab::Plugin.execute_all_async(data)
end
private
@@ -14,8 +20,8 @@ class SystemHooksService
def build_event_data(model, event)
data = {
event_name: build_event_name(model, event),
- created_at: model.created_at.xmlschema,
- updated_at: model.updated_at.xmlschema
+ created_at: model.created_at&.xmlschema,
+ updated_at: model.updated_at&.xmlschema
}
case model
@@ -24,7 +30,7 @@ class SystemHooksService
key: model.key,
id: model.id
)
-
+
if model.user
data[:username] = model.user.username
end
@@ -35,28 +41,29 @@ class SystemHooksService
data[:old_path_with_namespace] = model.old_path_with_namespace
end
when User
- data.merge!({
- name: model.name,
- email: model.email,
- user_id: model.id,
- username: model.username
- })
+ data.merge!(user_data(model))
+
+ case event
+ when :rename
+ data[:old_username] = model.username_was
+ when :failed_login
+ data[:state] = model.state
+ end
when ProjectMember
data.merge!(project_member_data(model))
when Group
- owner = model.owner
+ data.merge!(group_data(model))
- data.merge!(
- name: model.name,
- 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
- )
+ if event == :rename
+ data.merge!(
+ old_path: model.path_was,
+ old_full_path: model.full_path_was
+ )
+ end
when GroupMember
data.merge!(group_member_data(model))
end
-
+
data
end
@@ -79,11 +86,11 @@ class SystemHooksService
{
name: model.name,
path: model.path,
- path_with_namespace: model.path_with_namespace,
+ path_with_namespace: model.full_path,
project_id: model.id,
owner_name: owner.name,
owner_email: owner.respond_to?(:email) ? owner.email : "",
- project_visibility: Project.visibility_levels.key(model.visibility_level_value).downcase
+ project_visibility: model.visibility.downcase
}
end
@@ -93,7 +100,7 @@ class SystemHooksService
{
project_name: project.name,
project_path: project.path,
- project_path_with_namespace: project.path_with_namespace,
+ project_path_with_namespace: project.full_path,
project_id: project.id,
user_username: model.user.username,
user_name: model.user.name,
@@ -104,6 +111,19 @@ class SystemHooksService
}
end
+ def group_data(model)
+ owner = model.owner
+
+ {
+ name: model.name,
+ path: model.path,
+ full_path: model.full_path,
+ group_id: model.id,
+ owner_name: owner.try(:name),
+ owner_email: owner.try(:email)
+ }
+ end
+
def group_member_data(model)
{
group_name: model.group.name,
@@ -116,4 +136,13 @@ class SystemHooksService
group_access: model.human_access
}
end
+
+ def user_data(model)
+ {
+ name: model.name,
+ email: model.email,
+ user_id: model.id,
+ username: model.username
+ }
+ end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 0837c07e6aa..2253d638e93 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -22,8 +22,7 @@ module SystemNoteService
commits_text = "#{total_count} commit".pluralize(total_count)
body = "added #{commits_text}\n\n"
- body << existing_commit_summary(noteable, existing_commits, oldrev)
- body << new_commit_summary(new_commits).join("\n")
+ body << commits_list(noteable, new_commits, existing_commits, oldrev)
body << "\n\n[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})"
create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count))
@@ -68,21 +67,14 @@ module SystemNoteService
#
# Returns the created Note object
def change_issue_assignees(issue, project, author, old_assignees)
- body =
- if issue.assignees.any? && old_assignees.any?
- unassigned_users = old_assignees - issue.assignees
- added_users = issue.assignees.to_a - old_assignees
-
- text_parts = []
- text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
- text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
-
- text_parts.join(' and ')
- elsif old_assignees.any?
- "removed assignee"
- elsif issue.assignees.any?
- "assigned to #{issue.assignees.map(&:to_reference).to_sentence}"
- end
+ unassigned_users = old_assignees - issue.assignees
+ added_users = issue.assignees.to_a - old_assignees
+
+ text_parts = []
+ text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+
+ body = text_parts.join(' and ')
create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
end
@@ -142,7 +134,8 @@ module SystemNoteService
#
# Returns the created Note object
def change_milestone(noteable, project, author, milestone)
- body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project)}"
+ format = milestone&.group_milestone? ? :name : :iid
+ body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone'))
end
@@ -161,7 +154,6 @@ module SystemNoteService
# "changed time estimate to 3d 5h"
#
# Returns the created Note object
-
def change_time_estimate(noteable, project, author)
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
@@ -187,16 +179,17 @@ module SystemNoteService
# "added 2h 30m of time spent"
#
# Returns the created Note object
-
def change_time_spent(noteable, project, author)
time_spent = noteable.time_spent
if time_spent == :reset
body = "removed time spent"
else
+ spent_at = noteable.spent_at
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'added' : 'subtracted'
body = "#{action} #{parsed_time} of time spent"
+ body << " at #{spent_at}" if spent_at
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
@@ -240,14 +233,10 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
- def remove_merge_request_wip(noteable, project, author)
- body = 'unmarked as a **Work In Progress**'
+ def handle_merge_request_wip(noteable, project, author)
+ prefix = noteable.work_in_progress? ? "marked" : "unmarked"
- create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
- end
-
- def add_merge_request_wip(noteable, project, author)
- body = 'marked as a **Work In Progress**'
+ body = "#{prefix} as a **Work In Progress**"
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
@@ -282,7 +271,7 @@ module SystemNoteService
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))
+ url = url_helpers.diffs_project_merge_request_url(project, merge_request, version_params.merge(anchor: line_code))
body << " [version #{version_index} of the diff](#{url})"
else
@@ -413,7 +402,7 @@ module SystemNoteService
#
# "created branch `201-issue-branch-button`"
def new_issue_branch(issue, project, author, branch)
- link = url_helpers.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch)
+ link = url_helpers.project_compare_url(project, from: project.default_branch, to: branch)
body = "created branch [`#{branch}`](#{link})"
@@ -450,10 +439,6 @@ module SystemNoteService
end
end
- def cross_reference?(note_text)
- note_text =~ /\A#{cross_reference_note_prefix}/i
- end
-
# Check if a cross-reference is disallowed
#
# This method prevents adding a "mentioned in !1" note on every single commit
@@ -483,19 +468,8 @@ module SystemNoteService
# mentioner - Mentionable object
#
# Returns Boolean
-
def cross_reference_exists?(noteable, mentioner)
- # Initial scope should be system notes of this noteable type
- notes = Note.system.where(noteable_type: noteable.class)
-
- notes =
- if noteable.is_a?(Commit)
- # Commits have non-integer IDs, so they're stored in `commit_id`
- notes.where(commit_id: noteable.id)
- else
- notes.where(noteable_id: noteable.id)
- end
-
+ notes = noteable.notes.system
notes_for_mentioner(mentioner, noteable, notes).exists?
end
@@ -506,7 +480,7 @@ module SystemNoteService
# Returns an Array of Strings
def new_commit_summary(new_commits)
new_commits.collect do |commit|
- "* #{commit.short_id} - #{escape_html(commit.title)}"
+ content_tag('li', "#{commit.short_id} - #{commit.title}")
end
end
@@ -552,6 +526,55 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
+ # Called when a Noteable has been marked as a duplicate of another Issue
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # canonical_issue - Issue that this is a duplicate of
+ #
+ # Example Note text:
+ #
+ # "marked this issue as a duplicate of #1234"
+ #
+ # "marked this issue as a duplicate of other_project#5678"
+ #
+ # Returns the created Note object
+ def mark_duplicate_issue(noteable, project, author, canonical_issue)
+ body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
+ # Called when a Noteable has been marked as the canonical Issue of a duplicate
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # duplicate_issue - Issue that was a duplicate of this
+ #
+ # Example Note text:
+ #
+ # "marked #1234 as a duplicate of this issue"
+ #
+ # "marked other_project#5678 as a duplicate of this issue"
+ #
+ # Returns the created Note object
+ def mark_canonical_issue_of_duplicate(noteable, project, author, duplicate_issue)
+ body = "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue"
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
+ def discussion_lock(issuable, author)
+ action = issuable.discussion_locked? ? 'locked' : 'unlocked'
+ body = "#{action} this #{issuable.class.to_s.titleize.downcase}"
+
+ create_note(NoteSummary.new(issuable, issuable.project, author, body, action: action))
+ end
+
+ def cross_reference?(note_text)
+ note_text =~ /\A#{cross_reference_note_prefix}/i
+ end
+
private
def notes_for_mentioner(mentioner, noteable, notes)
@@ -580,6 +603,16 @@ module SystemNoteService
"#{cross_reference_note_prefix}#{gfm_reference}"
end
+ # Builds a list of existing and new commits according to existing_commits and
+ # new_commits methods.
+ # Returns a String wrapped in `ul` and `li` tags.
+ def commits_list(noteable, new_commits, existing_commits, oldrev)
+ existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev)
+ new_commit_summary = new_commit_summary(new_commits).join
+
+ content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe)
+ end
+
# Build a single line summarizing existing commits being added in a merge
# request
#
@@ -616,11 +649,8 @@ module SystemNoteService
branch = noteable.target_branch
branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork?
- "* #{commit_ids} - #{commits_text} from branch `#{branch}`\n"
- end
-
- def escape_html(text)
- Rack::Utils.escape_html(text)
+ branch_name = content_tag('code', branch)
+ content_tag('li', "#{commit_ids} - #{commits_text} from branch #{branch_name}".html_safe)
end
def url_helpers
@@ -630,12 +660,15 @@ module SystemNoteService
def diff_comparison_url(merge_request, project, oldrev)
diff_id = merge_request.merge_request_diff.id
- url_helpers.diffs_namespace_project_merge_request_url(
- project.namespace,
+ url_helpers.diffs_project_merge_request_url(
project,
- merge_request.iid,
+ merge_request,
diff_id: diff_id,
start_sha: oldrev
)
end
+
+ def content_tag(*args)
+ ActionController::Base.helpers.content_tag(*args)
+ end
end
diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb
index 674792f6138..cc76d0df3a1 100644
--- a/app/services/tags/create_service.rb
+++ b/app/services/tags/create_service.rb
@@ -11,9 +11,9 @@ module Tags
begin
new_tag = repository.add_tag(current_user, tag_name, target, message)
- rescue Rugged::TagError
+ rescue Gitlab::Git::Repository::TagExistsError
return error("Tag #{tag_name} already exists")
- rescue GitHooksService::PreReceiveError => ex
+ rescue Gitlab::Git::HooksService::PreReceiveError => ex
return error(ex.message)
end
diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb
index a368f4f5b61..d3d46064729 100644
--- a/app/services/tags/destroy_service.rb
+++ b/app/services/tags/destroy_service.rb
@@ -21,7 +21,7 @@ module Tags
else
error('Failed to remove tag')
end
- rescue GitHooksService::PreReceiveError => ex
+ rescue Gitlab::Git::HooksService::PreReceiveError => ex
error(ex.message)
end
diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb
deleted file mode 100644
index 280c81f7d2d..00000000000
--- a/app/services/test_hook_service.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class TestHookService
- def execute(hook, current_user)
- data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user)
- hook.execute(data, 'push_hooks')
- end
-end
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
new file mode 100644
index 00000000000..e9aefb1fb75
--- /dev/null
+++ b/app/services/test_hooks/base_service.rb
@@ -0,0 +1,40 @@
+module TestHooks
+ class BaseService
+ attr_accessor :hook, :current_user, :trigger
+
+ def initialize(hook, current_user, trigger)
+ @hook = hook
+ @current_user = current_user
+ @trigger = trigger
+ end
+
+ def execute
+ trigger_key = hook.class.triggers.key(trigger.to_sym)
+ trigger_data_method = "#{trigger}_data"
+
+ if trigger_key.nil? || !self.respond_to?(trigger_data_method, true)
+ return error('Testing not available for this hook')
+ end
+
+ error_message = catch(:validation_error) do
+ sample_data = self.__send__(trigger_data_method) # rubocop:disable GitlabSecurity/PublicSend
+
+ return hook.execute(sample_data, trigger_key)
+ end
+
+ error(error_message)
+ end
+
+ private
+
+ def error(message, http_status = nil)
+ result = {
+ message: message,
+ status: :error
+ }
+
+ result[:http_status] = http_status if http_status
+ result
+ end
+ end
+end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
new file mode 100644
index 00000000000..01d5d774cd5
--- /dev/null
+++ b/app/services/test_hooks/project_service.rb
@@ -0,0 +1,63 @@
+module TestHooks
+ class ProjectService < TestHooks::BaseService
+ private
+
+ def project
+ @project ||= hook.project
+ end
+
+ def push_events_data
+ throw(:validation_error, 'Ensure the project has at least one commit.') if project.empty_repo?
+
+ Gitlab::DataBuilder::Push.build_sample(project, current_user)
+ end
+
+ alias_method :tag_push_events_data, :push_events_data
+
+ def note_events_data
+ note = project.notes.first
+ throw(:validation_error, 'Ensure the project has notes.') unless note.present?
+
+ Gitlab::DataBuilder::Note.build(note, current_user)
+ end
+
+ def issues_events_data
+ issue = project.issues.first
+ throw(:validation_error, 'Ensure the project has issues.') unless issue.present?
+
+ issue.to_hook_data(current_user)
+ end
+
+ alias_method :confidential_issues_events_data, :issues_events_data
+
+ def merge_requests_events_data
+ merge_request = project.merge_requests.first
+ throw(:validation_error, 'Ensure the project has merge requests.') unless merge_request.present?
+
+ merge_request.to_hook_data(current_user)
+ end
+
+ def job_events_data
+ build = project.builds.first
+ throw(:validation_error, 'Ensure the project has CI jobs.') unless build.present?
+
+ Gitlab::DataBuilder::Build.build(build)
+ end
+
+ def pipeline_events_data
+ pipeline = project.pipelines.first
+ throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present?
+
+ Gitlab::DataBuilder::Pipeline.build(pipeline)
+ end
+
+ def wiki_page_events_data
+ page = project.wiki.pages.first
+ if !project.wiki_enabled? || page.blank?
+ throw(:validation_error, 'Ensure the wiki is enabled and has pages.')
+ end
+
+ Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
+ end
+ end
+end
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
new file mode 100644
index 00000000000..9016c77b7f0
--- /dev/null
+++ b/app/services/test_hooks/system_service.rb
@@ -0,0 +1,24 @@
+module TestHooks
+ class SystemService < TestHooks::BaseService
+ private
+
+ def push_events_data
+ Gitlab::DataBuilder::Push.sample_data
+ end
+
+ def tag_push_events_data
+ Gitlab::DataBuilder::Push.sample_data
+ end
+
+ def repository_update_events_data
+ Gitlab::DataBuilder::Repository.sample_data
+ end
+
+ def merge_requests_events_data
+ merge_request = MergeRequest.of_projects(current_user.projects.select(:id)).first
+ throw(:validation_error, 'Ensure one of your projects has merge requests.') unless merge_request.present?
+
+ merge_request.to_hook_data(current_user)
+ end
+ end
+end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 322c6286365..ffd48e842c2 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -31,20 +31,28 @@ class TodoService
mark_pending_todos_as_done(issue, current_user)
end
- # When we destroy an issue we should:
+ # When we destroy a todo target we should:
#
- # * refresh the todos count cache for the current user
+ # * refresh the todos count cache for all users with todos on the target
#
- def destroy_issue(issue, current_user)
- destroy_issuable(issue, current_user)
+ # This needs to yield back to the caller to destroy the target, because it
+ # collects the todo users before the todos themselves are deleted, then
+ # updates the todo counts for those users.
+ #
+ def destroy_target(target)
+ todo_users = User.where(id: target.todos.pending.select(:user_id)).to_a
+
+ yield target
+
+ todo_users.each(&:update_todos_count_cache)
end
# When we reassign an issue we should:
#
# * create a pending todo for new assignee if issue is assigned
#
- def reassigned_issue(issue, current_user)
- create_assignment_todo(issue, current_user)
+ def reassigned_issue(issue, current_user, old_assignees = [])
+ create_assignment_todo(issue, current_user, old_assignees)
end
# When create a merge request we should:
@@ -72,14 +80,6 @@ class TodoService
mark_pending_todos_as_done(merge_request, current_user)
end
- # When we destroy a merge request we should:
- #
- # * refresh the todos count cache for the current user
- #
- def destroy_merge_request(merge_request, current_user)
- destroy_issuable(merge_request, current_user)
- end
-
# When we reassign a merge request we should:
#
# * creates a pending todo for new assignee if merge request is assigned
@@ -170,20 +170,22 @@ class TodoService
# When user marks some todos as done
def mark_todos_as_done(todos, current_user)
- update_todos_state_by_ids(todos.select(&:id), current_user, :done)
+ update_todos_state(todos, current_user, :done)
end
def mark_todos_as_done_by_ids(ids, current_user)
- update_todos_state_by_ids(ids, current_user, :done)
+ todos = todos_by_ids(ids, current_user)
+ mark_todos_as_done(todos, current_user)
end
# When user marks some todos as pending
def mark_todos_as_pending(todos, current_user)
- update_todos_state_by_ids(todos.select(&:id), current_user, :pending)
+ update_todos_state(todos, current_user, :pending)
end
def mark_todos_as_pending_by_ids(ids, current_user)
- update_todos_state_by_ids(ids, current_user, :pending)
+ todos = todos_by_ids(ids, current_user)
+ mark_todos_as_pending(todos, current_user)
end
# When user marks an issue as todo
@@ -198,9 +200,11 @@ class TodoService
private
- def update_todos_state_by_ids(ids, current_user, state)
- todos = current_user.todos.where(id: ids)
+ def todos_by_ids(ids, current_user)
+ current_user.todos.where(id: Array(ids))
+ end
+ def update_todos_state(todos, current_user, state)
# Only update those that are not really on that state
todos = todos.where.not(state: state)
todos_ids = todos.pluck(:id)
@@ -212,6 +216,7 @@ class TodoService
def create_todos(users, attributes)
Array(users).map do |user|
next if pending_todos(user, attributes).exists?
+
todo = Todo.create(attributes.merge(user_id: user.id))
user.update_todos_count_cache
todo
@@ -230,18 +235,13 @@ class TodoService
create_mention_todos(issuable.project, issuable, author, nil, skip_users)
end
- def destroy_issuable(issuable, user)
- user.update_todos_count_cache
- end
-
def toggling_tasks?(issuable)
issuable.previous_changes.include?('description') &&
issuable.tasks? && issuable.updated_tasks.any?
end
def handle_note(note, author, skip_users = [])
- # Skip system notes, and notes on project snippet
- return if note.system? || note.for_snippet?
+ return unless note.can_create_todo?
project = note.project
target = note.noteable
@@ -250,10 +250,11 @@ class TodoService
create_mention_todos(project, target, author, note, skip_users)
end
- def create_assignment_todo(issuable, author)
+ def create_assignment_todo(issuable, author, old_assignees = [])
if issuable.assignees.any?
+ assignees = issuable.assignees - old_assignees
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
- create_todos(issuable.assignees, attributes)
+ create_todos(assignees, attributes)
end
end
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index 6c5b2baff41..d5a9b344905 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -15,6 +15,6 @@ class UploadService
private
def max_attachment_size
- current_application_settings.max_attachment_size.megabytes.to_i
+ Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
end
end
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index d7a6804ee88..8630e572624 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -3,7 +3,13 @@ class UserProjectAccessChangedService
@user_ids = Array.wrap(user_ids)
end
- def execute
- AuthorizedProjectsWorker.bulk_perform_and_wait(@user_ids.map { |id| [id] })
+ def execute(blocking: true)
+ bulk_args = @user_ids.map { |id| [id] }
+
+ if blocking
+ AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args)
+ else
+ AuthorizedProjectsWorker.bulk_perform_async(bulk_args)
+ end
end
end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index ab532a1fdcf..5803404c3c8 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -14,7 +14,7 @@ module Users
private
def record_activity
- Gitlab::UserActivities.record(@author.id)
+ Gitlab::UserActivities.record(@author.id) if Gitlab::Database.read_write?
Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})")
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index ff234a3440f..4fb6d221909 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -32,7 +32,7 @@ module Users
private
def can_create_user?
- (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin?
+ (current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin?
end
# Allowed params for creating a user (admins only)
@@ -100,7 +100,7 @@ module Users
end
def skip_user_confirmation_email_from_setting
- !current_application_settings.send_user_confirmation_email
+ !Gitlab::CurrentSettings.send_user_confirmation_email
end
end
end
diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb
index 74abc017cea..c8a3c461d60 100644
--- a/app/services/users/create_service.rb
+++ b/app/services/users/create_service.rb
@@ -1,5 +1,7 @@
module Users
class CreateService < BaseService
+ include NewUserNotifier
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params.dup
@@ -10,11 +12,7 @@ module Users
@reset_token = user.generate_reset_token if user.recently_sent_password_reset?
- if user.save
- log_info("User \"#{user.name}\" (#{user.email}) was created")
- notification_service.new_user(user, @reset_token) if @reset_token
- system_hook_service.execute_hooks_for(user, :create)
- end
+ notify_new_user(user, @reset_token) if user.save
user
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 673afb8b5b9..06b604dad4d 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -31,22 +31,31 @@ module Users
return user
end
+ # Calling all before/after_destroy hooks for the user because
+ # there is no dependent: destroy in the relationship. And the removal
+ # is done by a foreign_key. Otherwise they won't be called
+ user.members.find_each { |member| member.run_callbacks(:destroy) }
+
user.solo_owned_groups.each do |group|
Groups::DestroyService.new(group, current_user).execute
end
- user.personal_projects.with_deleted.each do |project|
+ namespace = user.namespace
+ namespace.prepare_for_destroy
+
+ user.personal_projects.each do |project|
# Skip repository removal because we remove directory with namespace
# that contain all this repositories
- ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
+ ::Projects::DestroyService.new(project, current_user, skip_repo: project.legacy_storage?).execute
end
+ yield(user) if block_given?
+
MigrateToGhostUserService.new(user).execute unless options[:hard_delete]
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
- namespace = user.namespace
user_data = user.destroy
- namespace.really_destroy!
+ namespace.destroy
user_data
end
diff --git a/app/services/users/keys_count_service.rb b/app/services/users/keys_count_service.rb
new file mode 100644
index 00000000000..f82d27eded9
--- /dev/null
+++ b/app/services/users/keys_count_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Users
+ # Service class for getting the number of SSH keys that belong to a user.
+ class KeysCountService < BaseCountService
+ attr_reader :user
+
+ # user - The User for which to get the number of SSH keys.
+ def initialize(user)
+ @user = user
+ end
+
+ def relation_for_count
+ user.keys
+ end
+
+ def raw?
+ # Since we're storing simple integers we don't need all of the additional
+ # Marshal data Rails includes by default.
+ true
+ end
+
+ def cache_key
+ "users/key-count-service/#{user.id}"
+ end
+ end
+end
diff --git a/app/services/users/last_push_event_service.rb b/app/services/users/last_push_event_service.rb
new file mode 100644
index 00000000000..57e446d7f30
--- /dev/null
+++ b/app/services/users/last_push_event_service.rb
@@ -0,0 +1,83 @@
+module Users
+ # Service class for caching and retrieving the last push event of a user.
+ class LastPushEventService
+ EXPIRATION = 2.hours
+
+ def initialize(user)
+ @user = user
+ end
+
+ # Caches the given push event for the current user in the Rails cache.
+ #
+ # event - An instance of PushEvent to cache.
+ def cache_last_push_event(event)
+ keys = [
+ project_cache_key(event.project),
+ user_cache_key
+ ]
+
+ if forked_from = event.project.forked_from_project
+ keys << project_cache_key(forked_from)
+ end
+
+ keys.each { |key| set_key(key, event.id) }
+ end
+
+ # Returns the last PushEvent for the current user.
+ #
+ # This method will return nil if no event was found.
+ def last_event_for_user
+ find_cached_event(user_cache_key)
+ end
+
+ # Returns the last PushEvent for the current user and the given project.
+ #
+ # project - An instance of Project for which to retrieve the PushEvent.
+ #
+ # This method will return nil if no event was found.
+ def last_event_for_project(project)
+ find_cached_event(project_cache_key(project))
+ end
+
+ def find_cached_event(cache_key)
+ event_id = get_key(cache_key)
+
+ return unless event_id
+
+ unless (event = find_event_in_database(event_id))
+ # We don't want to keep querying the same data over and over when a
+ # merge request has been created, thus we remove the key if no event
+ # (meaning an MR was created) is returned.
+ Rails.cache.delete(cache_key)
+ end
+
+ event
+ end
+
+ private
+
+ def find_event_in_database(id)
+ PushEvent
+ .without_existing_merge_requests
+ .find_by(id: id)
+ end
+
+ def user_cache_key
+ "last-push-event/#{@user.id}"
+ end
+
+ def project_cache_key(project)
+ "last-push-event/#{@user.id}/#{project.id}"
+ end
+
+ def get_key(key)
+ Rails.cache.read(key, raw: true)
+ end
+
+ def set_key(key, value)
+ # We're using raw values here since this takes up less space and we don't
+ # store complex objects.
+ Rails.cache.write(key, value, raw: true, expires_in: EXPIRATION)
+ end
+ end
+end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 4628c4c6f6e..976017dfa82 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -25,7 +25,7 @@ module Users
user.block
# Reverse the user block if record migration fails
- if !migrate_records && transition
+ if !migrate_records_in_transaction && transition
transition.rollback
user.save!
end
@@ -36,24 +36,30 @@ module Users
private
- def migrate_records
+ def migrate_records_in_transaction
user.transaction(requires_new: true) do
@ghost_user = User.ghost
- migrate_issues
- migrate_merge_requests
- migrate_notes
- migrate_abuse_reports
- migrate_award_emojis
+ migrate_records
end
end
+ def migrate_records
+ migrate_issues
+ migrate_merge_requests
+ migrate_notes
+ migrate_abuse_reports
+ migrate_award_emojis
+ end
+
def migrate_issues
user.issues.update_all(author_id: ghost_user.id)
+ Issue.where(last_edited_by_id: user.id).update_all(last_edited_by_id: ghost_user.id)
end
def migrate_merge_requests
user.merge_requests.update_all(author_id: ghost_user.id)
+ MergeRequest.where(merge_user_id: user.id).update_all(merge_user_id: ghost_user.id)
end
def migrate_notes
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index dfbd6016c3f..15ca1a55a5b 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -1,17 +1,22 @@
module Users
class UpdateService < BaseService
- def initialize(user, params = {})
- @user = user
+ include NewUserNotifier
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @user = params.delete(:user)
@params = params.dup
end
def execute(validate: true, &block)
yield(@user) if block_given?
+ user_exists = @user.persisted?
+
assign_attributes(&block)
if @user.save(validate: validate)
- success
+ notify_success(user_exists)
else
error(@user.errors.full_messages.uniq.join('. '))
end
@@ -27,7 +32,17 @@ module Users
private
+ def notify_success(user_exists)
+ notify_new_user(@user, nil) unless user_exists
+
+ success
+ end
+
def assign_attributes(&block)
+ if @user.user_synced_attributes_metadata
+ params.except!(*@user.user_synced_attributes_metadata.read_only_attributes)
+ end
+
@user.assign_attributes(params) if params.any?
end
end
diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb
index d232e85cd33..7d1ed768ee8 100644
--- a/app/services/validate_new_branch_service.rb
+++ b/app/services/validate_new_branch_service.rb
@@ -13,7 +13,7 @@ class ValidateNewBranchService < BaseService
end
success
- rescue GitHooksService::PreReceiveError => ex
+ rescue Gitlab::Git::HooksService::PreReceiveError => ex
error(ex.message)
end
end
diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb
new file mode 100644
index 00000000000..86166047302
--- /dev/null
+++ b/app/services/verify_pages_domain_service.rb
@@ -0,0 +1,107 @@
+require 'resolv'
+
+class VerifyPagesDomainService < BaseService
+ # The maximum number of seconds to be spent on each DNS lookup
+ RESOLVER_TIMEOUT_SECONDS = 15
+
+ # How long verification lasts for
+ VERIFICATION_PERIOD = 7.days
+
+ attr_reader :domain
+
+ def initialize(domain)
+ @domain = domain
+ end
+
+ def execute
+ return error("No verification code set for #{domain.domain}") unless domain.verification_code.present?
+
+ if !verification_enabled? || dns_record_present?
+ verify_domain!
+ elsif expired?
+ disable_domain!
+ else
+ unverify_domain!
+ end
+ end
+
+ private
+
+ def verify_domain!
+ was_disabled = !domain.enabled?
+ was_unverified = domain.unverified?
+
+ # Prevent any pre-existing grace period from being truncated
+ reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max
+
+ domain.update!(verified_at: Time.now, enabled_until: reverify)
+
+ if was_disabled
+ notify(:enabled)
+ elsif was_unverified
+ notify(:verification_succeeded)
+ end
+
+ success
+ end
+
+ def unverify_domain!
+ if domain.verified?
+ domain.update!(verified_at: nil)
+ notify(:verification_failed)
+ end
+
+ error("Couldn't verify #{domain.domain}")
+ end
+
+ def disable_domain!
+ domain.update!(verified_at: nil, enabled_until: nil)
+
+ notify(:disabled)
+
+ error("Couldn't verify #{domain.domain}. It is now disabled.")
+ end
+
+ # A domain is only expired until `disable!` has been called
+ def expired?
+ domain.enabled_until && domain.enabled_until < Time.now
+ end
+
+ def dns_record_present?
+ Resolv::DNS.open do |resolver|
+ resolver.timeouts = RESOLVER_TIMEOUT_SECONDS
+
+ check(domain.domain, resolver) || check(domain.verification_domain, resolver)
+ end
+ end
+
+ def check(domain_name, resolver)
+ records = parse(txt_records(domain_name, resolver))
+
+ records.any? do |record|
+ record == domain.keyed_verification_code || record == domain.verification_code
+ end
+ rescue => err
+ log_error("Failed to check TXT records on #{domain_name} for #{domain.domain}: #{err}")
+ false
+ end
+
+ def txt_records(domain_name, resolver)
+ resolver.getresources(domain_name, Resolv::DNS::Resource::IN::TXT)
+ end
+
+ def parse(records)
+ records.flat_map(&:strings).flat_map(&:split)
+ end
+
+ def verification_enabled?
+ Gitlab::CurrentSettings.pages_domain_verification_enabled?
+ end
+
+ def notify(type)
+ return unless verification_enabled?
+
+ Gitlab::AppLogger.info("Pages domain '#{domain.domain}' changed state to '#{type}'")
+ notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 4241b912d5b..809ce1303d8 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -3,23 +3,20 @@ class WebHookService
attr_reader :body, :headers, :code
def initialize
- @headers = HTTParty::Response::Headers.new({})
+ @headers = Gitlab::HTTP::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
+ attr_accessor :hook, :data, :hook_name, :request_options
def initialize(hook, data, hook_name)
@hook = hook
@data = data
- @hook_name = hook_name
+ @hook_name = hook_name.to_s
+ @request_options = { timeout: Gitlab.config.gitlab.webhook_timeout }
+ @request_options.merge!(allow_local_requests: true) if @hook.is_a?(SystemHook)
end
def execute
@@ -39,8 +36,12 @@ class WebHookService
execution_duration: Time.now - start_time
)
- [response.code, response.to_s]
- rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e
+ {
+ status: :success,
+ http_status: response.code,
+ message: response.to_s
+ }
+ rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
log_execution(
trigger: hook_name,
url: hook.url,
@@ -52,11 +53,14 @@ class WebHookService
Rails.logger.error("WebHook Error => #{e}")
- [nil, e.to_s]
+ {
+ status: :error,
+ message: e.to_s
+ }
end
def async_execute
- Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name)
+ WebHookWorker.perform_async(hook.id, data, hook_name)
end
private
@@ -66,11 +70,12 @@ class WebHookService
end
def make_request(url, basic_auth = false)
- self.class.post(url,
+ Gitlab::HTTP.post(url,
body: data.to_json,
headers: build_headers(hook_name),
verify: hook.enable_ssl_verification,
- basic_auth: basic_auth)
+ basic_auth: basic_auth,
+ **request_options)
end
def make_request_with_auth
@@ -94,7 +99,7 @@ class WebHookService
request_headers: build_headers(hook_name),
request_data: request_data,
response_headers: format_response_headers(response),
- response_body: response.body,
+ response_body: safe_response_body(response),
response_status: response.code,
internal_error_message: error_message
)
@@ -106,7 +111,7 @@ class WebHookService
'Content-Type' => 'application/json',
'X-Gitlab-Event' => hook_name.singularize.titleize
}.tap do |hash|
- hash['X-Gitlab-Token'] = hook.token if hook.token.present?
+ hash['X-Gitlab-Token'] = Gitlab::Utils.remove_line_breaks(hook.token) if hook.token.present?
end
end
end
@@ -117,4 +122,10 @@ class WebHookService
def format_response_headers(response)
response.headers.each_capitalized.to_h
end
+
+ def safe_response_body(response)
+ return '' unless response.body
+
+ response.body.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
+ end
end
diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb
index 14317ea65c8..260c04a8b94 100644
--- a/app/services/wiki_pages/base_service.rb
+++ b/app/services/wiki_pages/base_service.rb
@@ -1,23 +1,9 @@
module WikiPages
class BaseService < ::BaseService
- def hook_data(page, action)
- hook_data = {
- object_kind: page.class.name.underscore,
- user: current_user.hook_attrs,
- project: @project.hook_attrs,
- wiki: @project.wiki.hook_attrs,
- object_attributes: page.hook_attrs
- }
-
- page_url = Gitlab::UrlBuilder.build(page)
- hook_data[:object_attributes].merge!(url: page_url, action: action)
- hook_data
- end
-
private
def execute_hooks(page, action = 'create')
- page_data = hook_data(page, action)
+ page_data = Gitlab::DataBuilder::WikiPage.build(page, current_user, action)
@project.execute_hooks(page_data, :wiki_page_hooks)
@project.execute_services(page_data, :wiki_page_hooks)
end
diff --git a/app/services/wiki_pages/update_service.rb b/app/services/wiki_pages/update_service.rb
index 8f6a50da838..93cbd9a509f 100644
--- a/app/services/wiki_pages/update_service.rb
+++ b/app/services/wiki_pages/update_service.rb
@@ -1,7 +1,7 @@
module WikiPages
class UpdateService < WikiPages::BaseService
def execute(page)
- if page.update(@params[:content], @params[:format], @params[:message])
+ if page.update(@params)
execute_hooks(page, 'update')
end
diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb
deleted file mode 100644
index 14addb6cf14..00000000000
--- a/app/uploaders/artifact_uploader.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-class ArtifactUploader < GitlabUploader
- storage :file
-
- attr_reader :job, :field
-
- def self.local_artifacts_store
- Gitlab.config.artifacts.path
- end
-
- def self.artifacts_upload_path
- File.join(self.local_artifacts_store, 'tmp/uploads/')
- end
-
- def initialize(job, field)
- @job, @field = job, field
- end
-
- def store_dir
- default_local_path
- end
-
- def cache_dir
- File.join(self.class.local_artifacts_store, 'tmp/cache')
- end
-
- def work_dir
- File.join(self.class.local_artifacts_store, 'tmp/work')
- end
-
- private
-
- def default_local_path
- File.join(self.class.local_artifacts_store, default_path)
- end
-
- 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/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb
index 109eb2fea0b..4930fb2fca7 100644
--- a/app/uploaders/attachment_uploader.rb
+++ b/app/uploaders/attachment_uploader.rb
@@ -1,10 +1,12 @@
class AttachmentUploader < GitlabUploader
- include RecordsUploads
include UploaderHelper
+ include RecordsUploads::Concern
storage :file
- def store_dir
- "#{base_dir}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
+ private
+
+ def dynamic_segment
+ File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index 66d3bcb998a..5c8e1cea62e 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -1,25 +1,24 @@
class AvatarUploader < GitlabUploader
- include RecordsUploads
include UploaderHelper
+ include RecordsUploads::Concern
storage :file
- def store_dir
- "#{base_dir}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
+ def exists?
+ model.avatar.file && model.avatar.file.present?
end
- def exists?
- model.avatar.file && model.avatar.file.exists?
+ def move_to_cache
+ false
end
- # We set move_to_store and move_to_cache to 'false' to prevent stealing
- # the avatar file from a project when forking it.
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/26158
def move_to_store
false
end
- def move_to_cache
- false
+ private
+
+ def dynamic_segment
+ File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index 00c2888d224..8f56f09c9f7 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -21,7 +21,8 @@ class FileMover
end
def update_markdown
- updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown)
+ updated_text = model.read_attribute(update_field)
+ .gsub(temp_file_uploader.markdown_link, uploader.markdown_link)
model.update_attribute(update_field, updated_text)
true
@@ -48,11 +49,11 @@ class FileMover
end
def uploader
- @uploader ||= PersonalFileUploader.new(model, secret)
+ @uploader ||= PersonalFileUploader.new(model, secret: secret)
end
def temp_file_uploader
- @temp_file_uploader ||= PersonalFileUploader.new(nil, secret)
+ @temp_file_uploader ||= PersonalFileUploader.new(nil, secret: secret)
end
def revert
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index 652277e3b78..bde1161dfa8 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -1,23 +1,40 @@
+# This class breaks the actual CarrierWave concept.
+# Every uploader should use a base_dir that is model agnostic so we can build
+# back URLs from base_dir-relative paths saved in the `Upload` model.
+#
+# As the `.base_dir` is model dependent and **not** saved in the upload model (see #upload_path)
+# there is no way to build back the correct file path without the model, which defies
+# CarrierWave way of storing files.
+#
class FileUploader < GitlabUploader
- include RecordsUploads
include UploaderHelper
+ include RecordsUploads::Concern
MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}
+ DYNAMIC_PATH_PATTERN = %r{(?<secret>\h{32})/(?<identifier>.*)}
storage :file
- def self.absolute_path(upload_record)
+ after :remove, :prune_store_dir
+
+ def self.root
+ File.join(options.storage_path, 'uploads')
+ end
+
+ def self.absolute_path(upload)
File.join(
- self.dynamic_path_segment(upload_record.model),
- upload_record.path
+ absolute_base_dir(upload.model),
+ upload.path # already contain the dynamic_segment, see #upload_path
)
end
- # Not using `GitlabUploader.base_dir` because all project namespaces are in
- # the `public/uploads` dir.
- #
- def self.base_dir
- root_dir
+ def self.base_dir(model)
+ model_path_segment(model)
+ end
+
+ # used in migrations and import/exports
+ def self.absolute_base_dir(model)
+ File.join(root, base_dir(model))
end
# Returns the part of `store_dir` that can change based on the model's current
@@ -26,55 +43,119 @@ class FileUploader < GitlabUploader
# This is used to build Upload paths dynamically based on the model's current
# namespace and path, allowing us to ignore renames or transfers.
#
- # model - Object that responds to `path_with_namespace`
+ # model - Object that responds to `full_path` and `disk_path`
#
# Returns a String without a trailing slash
- def self.dynamic_path_segment(model)
- File.join(CarrierWave.root, base_dir, model.path_with_namespace)
+ def self.model_path_segment(model)
+ if model.hashed_storage?(:attachments)
+ model.disk_path
+ else
+ model.full_path
+ end
+ end
+
+ def self.upload_path(secret, identifier)
+ File.join(secret, identifier)
+ end
+
+ def self.generate_secret
+ SecureRandom.hex
end
attr_accessor :model
- attr_reader :secret
- def initialize(model, secret = nil)
+ def initialize(model, mounted_as = nil, **uploader_context)
+ super(model, nil, **uploader_context)
+
@model = model
- @secret = secret || generate_secret
+ apply_context!(uploader_context)
end
- def store_dir
- File.join(dynamic_path_segment, @secret)
+ def base_dir
+ self.class.base_dir(@model)
end
- def relative_path
- self.file.path.sub("#{dynamic_path_segment}/", '')
+ # we don't need to know the actual path, an uploader instance should be
+ # able to yield the file content on demand, so we should build the digest
+ def absolute_path
+ self.class.absolute_path(@upload)
end
- def to_markdown
- to_h[:markdown]
+ def upload_path
+ self.class.upload_path(dynamic_segment, identifier)
end
- def to_h
- filename = image_or_video? ? self.file.basename : self.file.filename
- escaped_filename = filename.gsub("]", "\\]")
+ def model_path_segment
+ self.class.model_path_segment(@model)
+ end
- markdown = "[#{escaped_filename}](#{secure_url})"
+ def store_dir
+ File.join(base_dir, dynamic_segment)
+ end
+
+ def markdown_link
+ markdown = "[#{markdown_name}](#{secure_url})"
markdown.prepend("!") if image_or_video? || dangerous?
+ markdown
+ end
+ def to_h
{
- alt: filename,
+ alt: markdown_name,
url: secure_url,
- markdown: markdown
+ markdown: markdown_link
}
end
+ def filename
+ self.file.filename
+ end
+
+ def upload=(value)
+ super
+
+ return unless value
+ return if apply_context!(value.uploader_context)
+
+ # fallback to the regex based extraction
+ if matches = DYNAMIC_PATH_PATTERN.match(value.path)
+ @secret = matches[:secret]
+ @identifier = matches[:identifier]
+ end
+ end
+
+ def secret
+ @secret ||= self.class.generate_secret
+ end
+
private
- def dynamic_path_segment
- self.class.dynamic_path_segment(model)
+ def apply_context!(uploader_context)
+ @secret, @identifier = uploader_context.values_at(:secret, :identifier)
+
+ !!(@secret && @identifier)
end
- def generate_secret
- SecureRandom.hex
+ def build_upload
+ super.tap do |upload|
+ upload.secret = secret
+ end
+ end
+
+ def prune_store_dir
+ storage.delete_dir!(store_dir) # only remove when empty
+ end
+
+ def markdown_name
+ (image_or_video? ? File.basename(filename, File.extname(filename)) : filename).gsub("]", "\\]")
+ end
+
+ def identifier
+ @identifier ||= filename
+ end
+
+ def dynamic_segment
+ secret
end
def secure_url
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 0da7a025591..010100f2da1 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -1,72 +1,85 @@
class GitlabUploader < CarrierWave::Uploader::Base
- def self.absolute_path(upload_record)
- File.join(CarrierWave.root, upload_record.path)
- end
+ class_attribute :options
- def self.root_dir
- 'uploads'
- end
+ class << self
+ # DSL setter
+ def storage_options(options)
+ self.options = options
+ end
- # When object storage is used, keep the `root_dir` as `base_dir`.
- # The files aren't really in folders there, they just have a name.
- # The files that contain user input in their name, also contain a hash, so
- # the names are still unique
- #
- # This method is overridden in the `FileUploader`
- def self.base_dir
- return root_dir unless file_storage?
+ def root
+ options.storage_path
+ end
- File.join(root_dir, 'system')
- end
+ # represent the directory namespacing at the class level
+ def base_dir
+ options.fetch('base_dir', '')
+ end
- def self.file_storage?
- self.storage == CarrierWave::Storage::File
+ def file_storage?
+ storage == CarrierWave::Storage::File
+ end
+
+ def absolute_path(upload_record)
+ File.join(root, upload_record.path)
+ end
end
+ storage_options Gitlab.config.uploads
+
delegate :base_dir, :file_storage?, to: :class
+ def initialize(model, mounted_as = nil, **uploader_context)
+ super(model, mounted_as)
+ end
+
def file_cache_storage?
cache_storage.is_a?(CarrierWave::Storage::File)
end
# Reduce disk IO
def move_to_cache
- true
+ file_storage?
end
# Reduce disk IO
def move_to_store
- true
+ file_storage?
end
- # Designed to be overridden by child uploaders that have a dynamic path
- # segment -- that is, a path that changes based on mutable attributes of its
- # associated model
- #
- # For example, `FileUploader` builds the storage path based on the associated
- # project model's `path_with_namespace` value, which can change when the
- # project or its containing namespace is moved or renamed.
- def relative_path
- self.file.path.sub("#{root}/", '')
+ def exists?
+ file.present?
end
- def exists?
- file.try(:exists?)
+ def store_dir
+ File.join(base_dir, dynamic_segment)
+ end
+
+ def cache_dir
+ File.join(root, base_dir, 'tmp/cache')
end
- # Override this if you don't want to save files by default to the Rails.root directory
def work_dir
- # Default path set by CarrierWave:
- # https://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L182
- CarrierWave.tmp_path
+ File.join(root, base_dir, 'tmp/work')
end
def filename
super || file&.filename
end
+ def model_valid?
+ !!model
+ end
+
private
+ # Designed to be overridden by child uploaders that have a dynamic path
+ # segment -- that is, a path that changes based on mutable attributes of its
+ # associated model
+ def dynamic_segment
+ raise(NotImplementedError)
+ end
+
# To prevent 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
@@ -74,6 +87,6 @@ class GitlabUploader < CarrierWave::Uploader::Base
# 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)
+ File.join(work_dir, cache_id, version_name.to_s, for_file)
end
end
diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb
new file mode 100644
index 00000000000..ad5385f45a4
--- /dev/null
+++ b/app/uploaders/job_artifact_uploader.rb
@@ -0,0 +1,34 @@
+class JobArtifactUploader < GitlabUploader
+ extend Workhorse::UploadPath
+
+ storage_options Gitlab.config.artifacts
+
+ def size
+ return super if model.size.nil?
+
+ model.size
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ def open
+ raise 'Only File System is supported' unless file_storage?
+
+ File.open(path, "rb") if path
+ end
+
+ private
+
+ def dynamic_segment
+ creation_date = model.created_at.utc.strftime('%Y_%m_%d')
+
+ File.join(disk_hash[0..1], disk_hash[2..3], disk_hash,
+ creation_date, model.job_id.to_s, model.id.to_s)
+ end
+
+ def disk_hash
+ @disk_hash ||= Digest::SHA2.hexdigest(model.project_id.to_s)
+ end
+end
diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb
new file mode 100644
index 00000000000..28c458d3ff1
--- /dev/null
+++ b/app/uploaders/legacy_artifact_uploader.rb
@@ -0,0 +1,15 @@
+class LegacyArtifactUploader < GitlabUploader
+ extend Workhorse::UploadPath
+
+ storage_options Gitlab.config.artifacts
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.id.to_s)
+ end
+end
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index d11ebf0f9ca..e04c97ce179 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -1,19 +1,24 @@
class LfsObjectUploader < GitlabUploader
- storage :file
+ extend Workhorse::UploadPath
- def store_dir
- "#{Gitlab.config.lfs.storage_path}/#{model.oid[0, 2]}/#{model.oid[2, 2]}"
+ # LfsObject are in `tmp/upload` instead of `tmp/uploads`
+ def self.workhorse_upload_path
+ File.join(root, 'tmp/upload')
end
- def cache_dir
- "#{Gitlab.config.lfs.storage_path}/tmp/cache"
- end
+ storage_options Gitlab.config.lfs
def filename
model.oid[4..-1]
end
- def work_dir
- File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work')
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.oid[0, 2], model.oid[2, 2])
end
end
diff --git a/app/uploaders/namespace_file_uploader.rb b/app/uploaders/namespace_file_uploader.rb
new file mode 100644
index 00000000000..993e85fbc13
--- /dev/null
+++ b/app/uploaders/namespace_file_uploader.rb
@@ -0,0 +1,19 @@
+class NamespaceFileUploader < FileUploader
+ # Re-Override
+ def self.root
+ options.storage_path
+ end
+
+ def self.base_dir(model)
+ File.join(options.base_dir, 'namespace', model_path_segment(model))
+ end
+
+ def self.model_path_segment(model)
+ File.join(model.id.to_s)
+ end
+
+ # Re-Override
+ def store_dir
+ File.join(base_dir, dynamic_segment)
+ end
+end
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
index 7f857765fbf..f2ad0badd53 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -1,19 +1,33 @@
class PersonalFileUploader < FileUploader
- def self.dynamic_path_segment(model)
- File.join(CarrierWave.root, model_path(model))
+ # Re-Override
+ def self.root
+ options.storage_path
end
- private
+ def self.base_dir(model)
+ File.join(options.base_dir, model_path_segment(model))
+ end
- def secure_url
- File.join(self.class.model_path(model), secret, file.filename)
+ def self.model_path_segment(model)
+ return 'temp/' unless model
+
+ File.join(model.class.to_s.underscore, model.id.to_s)
+ end
+
+ # model_path_segment does not require a model to be passed, so we can always
+ # generate a path, even when there's no model.
+ def model_valid?
+ true
end
- def self.model_path(model)
- if model
- File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
- else
- File.join("/#{base_dir}", 'temp')
- end
+ # Revert-Override
+ def store_dir
+ File.join(base_dir, dynamic_segment)
+ end
+
+ private
+
+ def secure_url
+ File.join('/', base_dir, secret, file.filename)
end
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index feb4f04d7b7..458928bc067 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -1,35 +1,62 @@
module RecordsUploads
- extend ActiveSupport::Concern
+ module Concern
+ extend ActiveSupport::Concern
- included do
- after :store, :record_upload
- before :remove, :destroy_upload
- end
+ attr_accessor :upload
- # After storing an attachment, create a corresponding Upload record
- #
- # NOTE: We're ignoring the argument passed to this callback because we want
- # the `SanitizedFile` object from `CarrierWave::Uploader::Base#file`, not the
- # `Tempfile` object the callback gets.
- #
- # Called `after :store`
- def record_upload(_tempfile = nil)
- return unless model
- return unless file_storage?
- return unless file.exists?
-
- Upload.record(self)
- end
+ included do
+ after :store, :record_upload
+ before :remove, :destroy_upload
+ end
+
+ # After storing an attachment, create a corresponding Upload record
+ #
+ # NOTE: We're ignoring the argument passed to this callback because we want
+ # the `SanitizedFile` object from `CarrierWave::Uploader::Base#file`, not the
+ # `Tempfile` object the callback gets.
+ #
+ # Called `after :store`
+ def record_upload(_tempfile = nil)
+ return unless model
+ return unless file && file.exists?
+
+ Upload.transaction do
+ uploads.where(path: upload_path).delete_all
+ upload.destroy! if upload
+
+ self.upload = build_upload
+ upload.save!
+ end
+ end
+
+ def upload_path
+ File.join(store_dir, filename.to_s)
+ end
+
+ private
+
+ def uploads
+ Upload.order(id: :desc).where(uploader: self.class.to_s)
+ end
- private
+ def build_upload
+ Upload.new(
+ uploader: self.class.to_s,
+ size: file.size,
+ path: upload_path,
+ model: model,
+ mount_point: mounted_as
+ )
+ end
- # Before removing an attachment, destroy any Upload records at the same path
- #
- # Called `before :remove`
- def destroy_upload(*args)
- return unless file_storage?
- return unless file
+ # Before removing an attachment, destroy any Upload records at the same path
+ #
+ # Called `before :remove`
+ def destroy_upload(*args)
+ return unless file && file.exists?
- Upload.remove_path(relative_path)
+ self.upload = nil
+ uploads.where(path: upload_path).delete_all
+ end
end
end
diff --git a/app/uploaders/uploader_helper.rb b/app/uploaders/uploader_helper.rb
index 7635c20ab3a..fd446d31092 100644
--- a/app/uploaders/uploader_helper.rb
+++ b/app/uploaders/uploader_helper.rb
@@ -32,14 +32,7 @@ module UploaderHelper
def extension_match?(extensions)
return false unless file
- extension =
- if file.respond_to?(:extension)
- file.extension
- else
- # Not all CarrierWave storages respond to :extension
- File.extname(file.path).delete('.')
- end
-
+ extension = file.try(:extension) || File.extname(file.path).delete('.')
extensions.include?(extension.downcase)
end
end
diff --git a/app/uploaders/workhorse.rb b/app/uploaders/workhorse.rb
new file mode 100644
index 00000000000..782032cf516
--- /dev/null
+++ b/app/uploaders/workhorse.rb
@@ -0,0 +1,7 @@
+module Workhorse
+ module UploadPath
+ def workhorse_upload_path
+ File.join(root, base_dir, 'tmp/uploads')
+ end
+ end
+end
diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb
new file mode 100644
index 00000000000..e43b66cbe3a
--- /dev/null
+++ b/app/validators/abstract_path_validator.rb
@@ -0,0 +1,34 @@
+class AbstractPathValidator < ActiveModel::EachValidator
+ extend Gitlab::EncodingHelper
+
+ def self.path_regex
+ raise NotImplementedError
+ end
+
+ def self.format_regex
+ raise NotImplementedError
+ end
+
+ def self.format_error_message
+ raise NotImplementedError
+ end
+
+ def self.valid_path?(path)
+ encode!(path)
+ "#{path}/" =~ path_regex
+ end
+
+ def validate_each(record, attribute, value)
+ unless value =~ self.class.format_regex
+ record.errors.add(attribute, self.class.format_error_message)
+ return
+ end
+
+ full_path = record.build_full_path
+ return unless full_path
+
+ unless self.class.valid_path?(full_path)
+ record.errors.add(attribute, "#{value} is a reserved name")
+ end
+ end
+end
diff --git a/app/validators/certificate_key_validator.rb b/app/validators/certificate_key_validator.rb
index 098b16017d2..8c7bb750339 100644
--- a/app/validators/certificate_key_validator.rb
+++ b/app/validators/certificate_key_validator.rb
@@ -17,6 +17,7 @@ class CertificateKeyValidator < ActiveModel::EachValidator
def valid_private_key_pem?(value)
return false unless value
+
pkey = OpenSSL::PKey::RSA.new(value)
pkey.private?
rescue OpenSSL::PKey::PKeyError
diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb
index e3d18097f71..b0c9a1b92a4 100644
--- a/app/validators/certificate_validator.rb
+++ b/app/validators/certificate_validator.rb
@@ -16,7 +16,6 @@ class CertificateValidator < ActiveModel::EachValidator
private
def valid_certificate_pem?(value)
- return false unless value
OpenSSL::X509::Certificate.new(value).present?
rescue OpenSSL::X509::CertificateError
false
diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb
new file mode 100644
index 00000000000..e7d32550176
--- /dev/null
+++ b/app/validators/cluster_name_validator.rb
@@ -0,0 +1,24 @@
+# ClusterNameValidator
+#
+# Custom validator for ClusterName.
+class ClusterNameValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ if record.managed?
+ if record.persisted? && record.name_changed?
+ record.errors.add(attribute, " can not be changed because it's synchronized with provider")
+ end
+
+ unless value.length >= 1 && value.length <= 63
+ record.errors.add(attribute, " is invalid syntax")
+ end
+
+ unless value =~ Gitlab::Regex.kubernetes_namespace_regex
+ record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message)
+ end
+ else
+ unless value.present?
+ record.errors.add(attribute, " has to be present")
+ end
+ end
+ end
+end
diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb
deleted file mode 100644
index 4688aabc2a8..00000000000
--- a/app/validators/dynamic_path_validator.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# DynamicPathValidator
-#
-# Custom validator for GitLab path values.
-# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project`
-#
-# Values are checked for formatting and exclusion from a list of illegal path
-# names.
-class DynamicPathValidator < ActiveModel::EachValidator
- extend Gitlab::EncodingHelper
-
- class << self
- 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)
- encode!(path)
- "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex
- end
- end
-
- def path_valid_for_record?(record, value)
- full_path = record.respond_to?(:build_full_path) ? record.build_full_path : value
-
- return true unless full_path
-
- case record
- when Project
- self.class.valid_project_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::PathRegex.namespace_format_regex
- record.errors.add(attribute, Gitlab::PathRegex.namespace_format_message)
- return
- end
-
- unless path_valid_for_record?(record, value)
- record.errors.add(attribute, "#{value} is a reserved name")
- end
- end
-end
diff --git a/app/validators/importable_url_validator.rb b/app/validators/importable_url_validator.rb
index 37a314adee6..3ec1594e202 100644
--- a/app/validators/importable_url_validator.rb
+++ b/app/validators/importable_url_validator.rb
@@ -4,7 +4,7 @@
# protect against Server-side Request Forgery (SSRF).
class ImportableUrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
- if Gitlab::UrlBlocker.blocked_url?(value)
+ if Gitlab::UrlBlocker.blocked_url?(value, valid_ports: Project::VALID_IMPORT_PORTS)
record.errors.add(attribute, "imports are not allowed from that URL")
end
end
diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb
new file mode 100644
index 00000000000..204be827941
--- /dev/null
+++ b/app/validators/key_restriction_validator.rb
@@ -0,0 +1,29 @@
+class KeyRestrictionValidator < ActiveModel::EachValidator
+ FORBIDDEN = -1
+
+ def self.supported_sizes(type)
+ Gitlab::SSHPublicKey.supported_sizes(type)
+ end
+
+ def self.supported_key_restrictions(type)
+ [0, *supported_sizes(type), FORBIDDEN]
+ end
+
+ def validate_each(record, attribute, value)
+ unless valid_restriction?(value)
+ record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}")
+ end
+ end
+
+ private
+
+ def supported_sizes_message
+ sizes = self.class.supported_sizes(options[:type])
+ sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+ end
+
+ def valid_restriction?(value)
+ choices = self.class.supported_key_restrictions(options[:type])
+ choices.include?(value)
+ end
+end
diff --git a/app/validators/namespace_path_validator.rb b/app/validators/namespace_path_validator.rb
new file mode 100644
index 00000000000..7b0ae4db5d4
--- /dev/null
+++ b/app/validators/namespace_path_validator.rb
@@ -0,0 +1,15 @@
+class NamespacePathValidator < AbstractPathValidator
+ extend Gitlab::EncodingHelper
+
+ def self.path_regex
+ Gitlab::PathRegex.full_namespace_path_regex
+ end
+
+ def self.format_regex
+ Gitlab::PathRegex.namespace_format_regex
+ end
+
+ def self.format_error_message
+ Gitlab::PathRegex.namespace_format_message
+ end
+end
diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb
new file mode 100644
index 00000000000..424fd77a6a3
--- /dev/null
+++ b/app/validators/project_path_validator.rb
@@ -0,0 +1,15 @@
+class ProjectPathValidator < AbstractPathValidator
+ extend Gitlab::EncodingHelper
+
+ def self.path_regex
+ Gitlab::PathRegex.full_project_path_regex
+ end
+
+ def self.format_regex
+ Gitlab::PathRegex.project_path_format_regex
+ end
+
+ def self.format_error_message
+ Gitlab::PathRegex.project_path_format_message
+ end
+end
diff --git a/app/validators/url_placeholder_validator.rb b/app/validators/url_placeholder_validator.rb
new file mode 100644
index 00000000000..dd681218b6b
--- /dev/null
+++ b/app/validators/url_placeholder_validator.rb
@@ -0,0 +1,32 @@
+# UrlValidator
+#
+# Custom validator for URLs.
+#
+# By default, only URLs for the HTTP(S) protocols will be considered valid.
+# Provide a `:protocols` option to configure accepted protocols.
+#
+# Also, this validator can help you validate urls with placeholders inside.
+# Usually, if you have a url like 'http://www.example.com/%{project_path}' the
+# URI parser will reject that URL format. Provide a `:placeholder_regex` option
+# to configure accepted placeholders.
+#
+# Example:
+#
+# class User < ActiveRecord::Base
+# validates :personal_url, url: true
+#
+# validates :ftp_url, url: { protocols: %w(ftp) }
+#
+# validates :git_url, url: { protocols: %w(http https ssh git) }
+#
+# validates :placeholder_url, url: { placeholder_regex: /(project_path|project_id|default_branch)/ }
+# end
+#
+class UrlPlaceholderValidator < UrlValidator
+ def validate_each(record, attribute, value)
+ placeholder_regex = self.options[:placeholder_regex]
+ value = value.gsub(/%{#{placeholder_regex}}/, 'foo') if placeholder_regex && value
+
+ super(record, attribute, value)
+ end
+end
diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb
new file mode 100644
index 00000000000..72660be6c43
--- /dev/null
+++ b/app/validators/variable_duplicates_validator.rb
@@ -0,0 +1,30 @@
+# VariableDuplicatesValidator
+#
+# This validator is designed for especially the following condition
+# - Use `accepts_nested_attributes_for :xxx` in a parent model
+# - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model
+class VariableDuplicatesValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ return if record.errors.include?(:"#{attribute}.key")
+
+ if options[:scope]
+ scoped = value.group_by do |variable|
+ Array(options[:scope]).map { |attr| variable.send(attr) } # rubocop:disable GitlabSecurity/PublicSend
+ end
+ scoped.each_value { |scope| validate_duplicates(record, attribute, scope) }
+ else
+ validate_duplicates(record, attribute, value)
+ end
+ end
+
+ private
+
+ def validate_duplicates(record, attribute, values)
+ duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first)
+ if duplicates.any?
+ error_message = "have duplicate values (#{duplicates.join(", ")})"
+ error_message += " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend
+ record.errors.add(attribute, error_message)
+ end
+ end
+end
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index e403a9da616..15bda97c3b5 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -1,6 +1,23 @@
= form_for @appearance, url: admin_appearances_path, html: { class: 'form-horizontal'} do |f|
= form_errors(@appearance)
+ %fieldset.app_logo
+ %legend
+ Navigation bar:
+ .form-group
+ = f.label :header_logo, 'Header logo', class: 'control-label'
+ .col-sm-10
+ - if @appearance.header_logo?
+ = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :header_logo_cache
+ = f.file_field :header_logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
+
%fieldset.sign-in
%legend
Sign in/Sign up pages:
@@ -21,34 +38,29 @@
= image_tag @appearance.logo_url, class: 'appearance-logo-preview'
- if @appearance.persisted?
%br
- = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-small remove-logo"
+ = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-logo"
%hr
= f.hidden_field :logo_cache
= f.file_field :logo, class: ""
.hint
Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
- %fieldset.app_logo
+ %fieldset
%legend
- Navigation bar:
+ New project pages:
.form-group
- = f.label :header_logo, 'Header logo', class: 'control-label'
+ = f.label :new_project_guidelines, class: 'control-label'
.col-sm-10
- - if @appearance.header_logo?
- = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-small remove-logo"
- %hr
- = f.hidden_field :header_logo_cache
- = f.file_field :header_logo, class: ""
+ = f.text_area :new_project_guidelines, class: "form-control", rows: 10
.hint
- Maximum file size is 1MB. Pages are optimized for a 72x72 px header logo
+ Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
.form-actions
= f.submit 'Save', class: 'btn btn-save append-right-10'
- if @appearance.persisted?
- = link_to 'Preview last save', preview_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ Preview last save:
+ = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- if @appearance.updated_at
%span.pull-right
diff --git a/app/views/admin/appearances/preview.html.haml b/app/views/admin/appearances/preview_sign_in.html.haml
index 1af7dd5bb67..1af7dd5bb67 100644
--- a/app/views/admin/appearances/preview.html.haml
+++ b/app/views/admin/appearances/preview_sign_in.html.haml
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index b21d5665970..54b39df8cf3 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -7,22 +7,24 @@
= f.label :default_branch_protection, class: 'control-label col-sm-2'
.col-sm-10
= f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control'
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :default_project_visibility, class: 'control-label col-sm-2'
.col-sm-10
= render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new)
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :default_snippet_visibility, class: 'control-label col-sm-2'
.col-sm-10
= render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new)
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :default_group_visibility, class: 'control-label col-sm-2'
.col-sm-10
= render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new)
.form-group
= f.label :restricted_visibility_levels, class: 'control-label col-sm-2'
.col-sm-10
- - restricted_level_checkboxes('restricted-visibility-help').each do |level|
+ - checkbox_name = 'application_setting[restricted_visibility_levels][]'
+ = hidden_field_tag(checkbox_name)
+ - restricted_level_checkboxes('restricted-visibility-help', checkbox_name).each do |level|
.checkbox
= level
%span.help-block#restricted-visibility-help
@@ -40,6 +42,14 @@
= link_to "(?)", help_page_path("integration/bitbucket")
and GitLab.com
= link_to "(?)", help_page_path("integration/gitlab")
+
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :project_export_enabled do
+ = f.check_box :project_export_enabled
+ Project export enabled
+
.form-group
%label.control-label.col-sm-2 Enabled Git access protocols
.col-sm-10
@@ -47,6 +57,13 @@
%span.help-block#clone-protocol-help
Allow only the selected protocols to be used for Git access.
+ - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ - field_name = :"#{type}_key_restriction"
+ .form-group
+ = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
+
%fieldset
%legend Account and Limit Settings
.form-group
@@ -143,9 +160,22 @@
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
- = f.label :signin_enabled do
- = f.check_box :signin_enabled
- Sign-in enabled
+ = f.label :password_authentication_enabled_for_web do
+ = f.check_box :password_authentication_enabled_for_web
+ Password authentication enabled for web interface
+ .help-block
+ When disabled, an external authentication provider must be used.
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :password_authentication_enabled_for_git do
+ = f.check_box :password_authentication_enabled_for_git
+ Password authentication enabled for Git over HTTP(S)
+ .help-block
+ When disabled, a Personal Access Token
+ - if Gitlab::Auth::LDAP::Config.enabled?
+ or LDAP password
+ must be used to authenticate.
- if omniauth_enabled? && button_based_providers.any?
.form-group
= f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2'
@@ -207,9 +237,35 @@
.col-sm-10
= f.number_field :max_pages_size, class: 'form-control'
.help-block 0 for unlimited
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :pages_domain_verification_enabled do
+ = f.check_box :pages_domain_verification_enabled
+ Require users to prove ownership of custom domains
+ .help-block
+ Domain verification is an essential security measure for public GitLab
+ sites. Users are required to demonstrate they control a domain before
+ it is enabled
+ = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
%fieldset
- %legend Continuous Integration
+ %legend Continuous Integration and Deployment
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :auto_devops_enabled do
+ = f.check_box :auto_devops_enabled
+ Enabled Auto DevOps (Beta) for projects by default
+ .help-block
+ It will automatically build, test, and deploy applications based on a predefined CI/CD configuration
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md')
+ .form-group
+ = f.label :auto_devops_domain, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com'
+ .help-block
+ = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.")
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
@@ -313,27 +369,50 @@
%fieldset
%legend Metrics - Prometheus
%p
- Enable a Prometheus metrics endpoint at `#{metrics_path}` to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available
+ Enable a Prometheus metrics endpoint at
+ %code= metrics_path
+ to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available
= link_to 'here', admin_health_check_path
\. 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')
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/index')
.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
- - unless Gitlab::Metrics.metrics_folder_present?
- .help-block
- %strong.cred WARNING:
- Environment variable `prometheus_multiproc_dir` does not exist or is not pointing to a valid directory.
+ - unless Gitlab::Metrics.metrics_folder_present?
+ .help-block
+ %strong.cred WARNING:
+ Environment variable
+ %code prometheus_multiproc_dir
+ does not exist or is not pointing to a valid directory.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory')
+
+ %fieldset
+ %legend Profiling - Performance Bar
+ %p
+ Enable the Performance Bar for a given group.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :performance_bar_enabled do
+ = f.check_box :performance_bar_enabled
+ Enable the Performance Bar
+ .form-group
+ = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
%fieldset
%legend Background Jobs
%p
- These settings require a restart to take effect.
+ These settings require a
+ = link_to 'restart', help_page_path('administration/restart_gitlab')
+ to take effect.
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
@@ -462,15 +541,59 @@
%fieldset
%legend Repository Storage
.form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :hashed_storage_enabled do
+ = f.check_box :hashed_storage_enabled
+ Create new projects using hashed storage paths
+ .help-block
+ Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents
+ repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.
+ %em (EXPERIMENTAL)
+ .form-group
= f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2'
.col-sm-10
- = f.select :repository_storages, repository_storages_options_for_select, {include_hidden: false}, multiple: true, class: 'form-control'
+ = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages),
+ {include_hidden: false}, multiple: true, class: 'form-control'
.help-block
Manage repository storage paths. Learn more in the
= succeed "." do
= link_to "repository storages documentation", help_page_path("administration/repository_storages")
%fieldset
+ %legend Git Storage Circuitbreaker settings
+ .form-group
+ = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_check_interval, class: 'form-control'
+ .help-block
+ = circuitbreaker_check_interval_help_text
+ .form-group
+ = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_access_retries, class: 'form-control'
+ .help-block
+ = circuitbreaker_access_retries_help_text
+ .form-group
+ = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
+ .help-block
+ = circuitbreaker_storage_timeout_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_count_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_reset_time_help_text
+
+ %fieldset
%legend Repository Checks
.form-group
.col-sm-offset-2.col-sm-10
@@ -488,24 +611,27 @@
.help-block
If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
- %fieldset
- %legend Koding
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :koding_enabled do
- = f.check_box :koding_enabled
- Enable Koding
- .form-group
- = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
- .help-block
- Koding has integration enabled out of the box for the
- %strong gitlab
- team, and you need to provide that team's URL here. Learn more in the
- = succeed "." do
- = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
+ - if koding_enabled?
+ %fieldset
+ %legend Koding
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :koding_enabled do
+ = f.check_box :koding_enabled
+ Enable Koding
+ .help-block
+ Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again.
+ .form-group
+ = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
+ .help-block
+ Koding has integration enabled out of the box for the
+ %strong gitlab
+ team, and you need to provide that team's URL here. Learn more in the
+ = succeed "." do
+ = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
%fieldset
%legend PlantUML
@@ -531,24 +657,26 @@
.checkbox
= f.label :version_check_enabled do
= f.check_box :version_check_enabled
- Version check enabled
+ Enable version check
.help-block
- Let GitLab inform you when an update is available.
+ GitLab will inform you if a new version is available.
+ = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")
+ about what information is shared with GitLab Inc.
.form-group
.col-sm-offset-2.col-sm-10
- can_be_configured = @application_setting.usage_ping_can_be_configured?
.checkbox
= f.label :usage_ping_enabled do
= f.check_box :usage_ping_enabled, disabled: !can_be_configured
- Usage ping enabled
- = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
+ Enable usage ping
.help-block
- if can_be_configured
- Every week GitLab will report license usage back to GitLab, Inc.
- Disable this option if you do not want this to occur. To see the
- JSON payload that will be sent, visit the
- = succeed '.' do
- = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping')
+ To help improve GitLab and its user experience, GitLab will
+ periodically collect usage information.
+ = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
+ about what information is shared with GitLab Inc. Visit
+ = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping')
+ to see the JSON payload sent.
- else
The usage ping is disabled, and cannot be configured through this
form. For more information, see the documentation on
@@ -617,6 +745,30 @@
Number of Git pushes after which 'git gc' is run.
%fieldset
+ %legend Gitaly Timeouts
+ .form-group
+ = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_default, class: 'form-control'
+ .help-block
+ Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
+ for git fetch/push operations or Sidekiq jobs.
+ .form-group
+ = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_fast, class: 'form-control'
+ .help-block
+ Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast.
+ If they exceed this threshold, there may be a problem with a storage shard and 'failing fast'
+ can help maintain the stability of the GitLab instance.
+ .form-group
+ = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_medium, class: 'form-control'
+ .help-block
+ Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
+
+ %fieldset
%legend Web terminal
.form-group
= f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2'
@@ -641,5 +793,81 @@
installations. Set to 0 to completely disable polling.
= link_to icon('question-circle'), help_page_path('administration/polling')
+ %fieldset
+ %legend Performance optimization
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :authorized_keys_enabled do
+ = f.check_box :authorized_keys_enabled
+ Write to "authorized_keys" file
+ .help-block
+ By default, we write to the "authorized_keys" file to support Git
+ over SSH without additional configuration. GitLab can be optimized
+ to authenticate SSH keys via the database file. Only uncheck this
+ if you have configured your OpenSSH server to use the
+ AuthorizedKeysCommand. Click on the help icon for more details.
+ = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup')
+
+ %fieldset
+ %legend User and IP Rate Limits
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :throttle_unauthenticated_enabled do
+ = f.check_box :throttle_unauthenticated_enabled
+ Enable unauthenticated request rate limit
+ %span.help-block
+ Helps reduce request volume (e.g. from crawlers or abusive bots)
+ .form-group
+ = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control'
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :throttle_authenticated_api_enabled do
+ = f.check_box :throttle_authenticated_api_enabled
+ Enable authenticated API request rate limit
+ %span.help-block
+ Helps reduce request volume (e.g. from crawlers or abusive bots)
+ .form-group
+ = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control'
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :throttle_authenticated_web_enabled do
+ = f.check_box :throttle_authenticated_web_enabled
+ Enable authenticated web request rate limit
+ %span.help-block
+ Helps reduce request volume (e.g. from crawlers or abusive bots)
+ .form-group
+ = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control'
+
+ %fieldset
+ %legend Outbound requests
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :allow_local_requests_from_hooks_and_services do
+ = f.check_box :allow_local_requests_from_hooks_and_services
+ Allow requests to the local network from hooks and services
+
.form-actions
= f.submit 'Save', class: 'btn btn-save'
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 061f8991b11..93827d6a1ab 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -6,6 +6,7 @@
.col-sm-10
= f.text_field :name, class: 'form-control'
= doorkeeper_errors_for application, :name
+
= content_tag :div, class: 'form-group' do
= f.label :redirect_uri, class: 'col-sm-2 control-label'
.col-sm-10
@@ -19,6 +20,13 @@
%code= Doorkeeper.configuration.native_redirect_uri
for local tests
+ = content_tag :div, class: 'form-group' do
+ = f.label :trusted, class: 'col-sm-2 control-label'
+ .col-sm-10
+ = f.check_box :trusted
+ %span.help-block
+ Trusted applications are automatically authorized on GitLab OAuth flow.
+
.form-group
= f.label :scopes, class: 'col-sm-2 control-label'
.col-sm-10
diff --git a/app/views/admin/applications/edit.html.haml b/app/views/admin/applications/edit.html.haml
index c596866bde2..13c408914bb 100644
--- a/app/views/admin/applications/edit.html.haml
+++ b/app/views/admin/applications/edit.html.haml
@@ -1,4 +1,7 @@
+- add_to_breadcrumbs "Applications", admin_applications_path
+- breadcrumb_title @application.name
- page_title "Edit", @application.name, "Applications"
+
%h3.page-title Edit application
- @url = admin_application_path(@application)
= render 'form', application: @application
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index eb4293c7e37..94d33fa6489 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -11,6 +11,7 @@
%th Name
%th Callback URL
%th Clients
+ %th Trusted
%th
%th
%tbody.oauth-applications
@@ -19,5 +20,6 @@
%td= link_to application.name, admin_application_path(application)
%td= application.redirect_uri
%td= application.access_tokens.map(&:resource_owner_id).uniq.count
+ %td= application.trusted? ? 'Y': 'N'
%td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link'
%td= render 'delete_form', application: application
diff --git a/app/views/admin/applications/new.html.haml b/app/views/admin/applications/new.html.haml
index 6310d89bd6b..346c58877d9 100644
--- a/app/views/admin/applications/new.html.haml
+++ b/app/views/admin/applications/new.html.haml
@@ -1,4 +1,6 @@
+- breadcrumb_title "Applications"
- page_title "New Application"
+
%h3.page-title New application
- @url = admin_applications_path
= render 'form', application: @application
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 14683cc66e9..5125aa21b06 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -23,6 +23,12 @@
%div
%span.monospace= uri
+ %tr
+ %td
+ Trusted
+ %td
+ = @application.trusted? ? 'Y' : 'N'
+
= render "shared/tokens/scopes_list", token: @application
.form-actions
diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml
index e5842bd1ea0..f0cc4d7ee62 100644
--- a/app/views/admin/background_jobs/show.html.haml
+++ b/app/views/admin/background_jobs/show.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Background Jobs"
-= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title Background Jobs
@@ -43,4 +42,4 @@
.panel.panel-default
- %iframe{ src: sidekiq_path, width: '100%', height: 970, style: "border: none" }
+ %iframe{ src: sidekiq_path, width: '100%', height: 970, style: "border: 0" }
diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml
index 45e053eb31d..8cbc4597e32 100644
--- a/app/views/admin/broadcast_messages/edit.html.haml
+++ b/app/views/admin/broadcast_messages/edit.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Messages"
- page_title "Broadcast Messages"
= render 'form'
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 4f2ae081d7a..b806882eee3 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Messages"
- page_title "Broadcast Messages"
%h3.page-title
diff --git a/app/views/admin/broadcast_messages/preview.js.haml b/app/views/admin/broadcast_messages/preview.js.haml
deleted file mode 100644
index c72e59640d7..00000000000
--- a/app/views/admin/broadcast_messages/preview.js.haml
+++ /dev/null
@@ -1 +0,0 @@
-$('.js-broadcast-message-preview').html("#{j(render_broadcast_message(@broadcast_message))}");
diff --git a/app/views/admin/cohorts/_usage_ping.html.haml b/app/views/admin/cohorts/_usage_ping.html.haml
index 73aa95d84f1..3dda386fcf7 100644
--- a/app/views/admin/cohorts/_usage_ping.html.haml
+++ b/app/views/admin/cohorts/_usage_ping.html.haml
@@ -7,4 +7,4 @@
= succeed '.' do
= link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics')
-%pre.usage-data.js-syntax-highlight.code.highlight{ data: { endpoint: usage_data_admin_application_settings_path(format: :html, pretty: true) } }
+%pre.usage-data.js-syntax-highlight.code.highlight{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
diff --git a/app/views/admin/cohorts/index.html.haml b/app/views/admin/cohorts/index.html.haml
index be8644c0ca6..5e9a8c083af 100644
--- a/app/views/admin/cohorts/index.html.haml
+++ b/app/views/admin/cohorts/index.html.haml
@@ -1,5 +1,5 @@
+- breadcrumb_title "Cohorts"
- @no_container = true
-= render "admin/dashboard/head"
%div{ class: container_class }
- if @cohorts
diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml
index 833d4c612f8..ed40e7b4d00 100644
--- a/app/views/admin/conversational_development_index/show.html.haml
+++ b/app/views/admin/conversational_development_index/show.html.haml
@@ -1,14 +1,12 @@
- @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
+ - if !Gitlab::CurrentSettings.usage_ping_enabled
= render 'disabled'
- elsif @metric.blank?
= render 'no_data'
diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml
deleted file mode 100644
index dff549f502c..00000000000
--- a/app/views/admin/dashboard/_head.html.haml
+++ /dev/null
@@ -1,33 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: (container_class) }
- = nav_link(controller: :dashboard, html_options: {class: 'home'}) do
- = link_to admin_root_path, title: 'Overview' do
- %span
- Overview
- = nav_link(controller: [:admin, :projects]) do
- = link_to admin_projects_path, title: 'Projects' do
- %span
- Projects
- = nav_link(controller: :users) do
- = link_to admin_users_path, title: 'Users' do
- %span
- Users
- = nav_link(controller: :groups) do
- = link_to admin_groups_path, title: 'Groups' do
- %span
- Groups
- = nav_link path: 'builds#index' do
- = link_to admin_jobs_path, title: 'Jobs' do
- %span
- Jobs
- = nav_link path: ['runners#index', 'runners#show'] do
- = link_to admin_runners_path, title: 'Runners' do
- %span
- Runners
- = nav_link path: 'cohorts#index' do
- = link_to admin_cohorts_path, title: 'Cohorts' do
- %span
- Cohorts
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 3c9f932a225..05c41082882 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,186 +1,189 @@
- @no_container = true
-= render "admin/dashboard/head"
+- breadcrumb_title "Dashboard"
%div{ class: container_class }
.admin-dashboard.prepend-top-default
.row
- .col-md-4
- %h4 Statistics
- %hr
- %p
- Forks
- %span.light.pull-right
- = number_with_delimiter(ForkedProjectLink.count)
- %p
- Issues
- %span.light.pull-right
- = number_with_delimiter(Issue.count)
- %p
- Merge Requests
- %span.light.pull-right
- = number_with_delimiter(MergeRequest.count)
- %p
- Notes
- %span.light.pull-right
- = number_with_delimiter(Note.count)
- %p
- Snippets
- %span.light.pull-right
- = number_with_delimiter(Snippet.count)
- %p
- SSH Keys
- %span.light.pull-right
- = number_with_delimiter(Key.count)
- %p
- Milestones
- %span.light.pull-right
- = number_with_delimiter(Milestone.count)
- %p
- Active Users
- %span.light.pull-right
- = number_with_delimiter(User.active.count)
- .col-md-4
- %h4
- Features
- %hr
- - sign_up = "Sign up"
- %p{ "aria-label" => "#{sign_up}: status " + (signup_enabled? ? "on" : "off") }
- = sign_up
- %span.light.pull-right
- = boolean_to_icon signup_enabled?
- - ldap = "LDAP"
- %p{ "aria-label" => "#{ldap}: status " + (Gitlab.config.ldap.enabled ? "on" : "off") }
- = ldap
- %span.light.pull-right
- = boolean_to_icon Gitlab.config.ldap.enabled
- - gravatar = "Gravatar"
- %p{ "aria-label" => "#{gravatar}: status " + (gravatar_enabled? ? "on" : "off") }
- = gravatar
- %span.light.pull-right
- = boolean_to_icon gravatar_enabled?
- - omniauth = "OmniAuth"
- %p{ "aria-label" => "#{omniauth}: status " + (Gitlab.config.omniauth.enabled ? "on" : "off") }
- = omniauth
- %span.light.pull-right
- = boolean_to_icon Gitlab.config.omniauth.enabled
- - reply_email = "Reply by email"
- %p{ "aria-label" => "#{reply_email}: status " + (Gitlab::IncomingEmail.enabled? ? "on" : "off") }
- = reply_email
- %span.light.pull-right
- = boolean_to_icon Gitlab::IncomingEmail.enabled?
- - container_reg = "Container Registry"
- %p{ "aria-label" => "#{container_reg}: status " + (Gitlab.config.registry.enabled ? "on" : "off") }
- = container_reg
- %span.light.pull-right
- = boolean_to_icon Gitlab.config.registry.enabled
- - gitlab_pages = 'GitLab Pages'
- - gitlab_pages_enabled = Gitlab.config.pages.enabled
- %p{ "aria-label" => "#{gitlab_pages}: status " + (gitlab_pages_enabled ? "on" : "off") }
- = gitlab_pages
- %span.light.pull-right
- = boolean_to_icon gitlab_pages_enabled
- - 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
- Components
- - if current_application_settings.version_check_enabled
- .pull-right
- = version_status_badge
-
- %hr
- %p
- GitLab
- %span.pull-right
- = Gitlab::VERSION
- %p
- GitLab Shell
- %span.pull-right
- = Gitlab::Shell.new.version
- %p
- GitLab Workhorse
- %span.pull-right
- = gitlab_workhorse_version
- %p
- GitLab API
- %span.pull-right
- = API::API::version
- %p
- Git
- %span.pull-right
- = Gitlab::Git.version
- %p
- Ruby
- %span.pull-right
- #{RUBY_VERSION}p#{RUBY_PATCHLEVEL}
-
- %p
- Rails
- %span.pull-right
- #{Rails::VERSION::STRING}
-
- %p
- = Gitlab::Database.adapter_name
- %span.pull-right
- = Gitlab::Database.version
- %hr
- .row
.col-sm-4
- .light-well.well-centered
- %h4 Projects
- .data
+ .info-well.dark-well
+ .well-segment.well-centered
= link_to admin_projects_path do
- %h1= number_with_delimiter(Project.cached_count)
+ %h3.text-center
+ Projects:
+ = number_with_delimiter(Project.cached_count)
%hr
= link_to('New project', new_project_path, class: "btn btn-new")
.col-sm-4
- .light-well.well-centered
- %h4 Users
- .data
+ .info-well.dark-well
+ .well-segment.well-centered
= link_to admin_users_path do
- %h1= number_with_delimiter(User.count)
+ %h3.text-center
+ Users:
+ = number_with_delimiter(User.count)
%hr
= link_to 'New user', new_admin_user_path, class: "btn btn-new"
.col-sm-4
- .light-well.well-centered
- %h4 Groups
- .data
+ .info-well.dark-well
+ .well-segment.well-centered
= link_to admin_groups_path do
- %h1= number_with_delimiter(Group.count)
+ %h3.text-center
+ Groups:
+ = number_with_delimiter(Group.count)
%hr
= link_to 'New group', new_admin_group_path, class: "btn btn-new"
-
- .row.prepend-top-10
+ .row
.col-md-4
- %h4 Latest projects
- %hr
- - @projects.each do |project|
- %p
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
- %span.light.pull-right
- #{time_ago_with_tooltip(project.created_at)}
-
+ .info-well
+ .well-segment.admin-well.admin-well-statistics
+ %h4 Statistics
+ %p
+ Forks
+ %span.light.pull-right
+ = number_with_delimiter(ForkedProjectLink.count)
+ %p
+ Issues
+ %span.light.pull-right
+ = number_with_delimiter(Issue.count)
+ %p
+ Merge Requests
+ %span.light.pull-right
+ = number_with_delimiter(MergeRequest.count)
+ %p
+ Notes
+ %span.light.pull-right
+ = number_with_delimiter(Note.count)
+ %p
+ Snippets
+ %span.light.pull-right
+ = number_with_delimiter(Snippet.count)
+ %p
+ SSH Keys
+ %span.light.pull-right
+ = number_with_delimiter(Key.count)
+ %p
+ Milestones
+ %span.light.pull-right
+ = number_with_delimiter(Milestone.count)
+ %p
+ Active Users
+ %span.light.pull-right
+ = number_with_delimiter(User.active.count)
.col-md-4
- %h4 Latest users
- %hr
- - @users.each do |user|
- %p
- = link_to [:admin, user], class: 'str-truncated-60' do
- = user.name
- %span.light.pull-right
- #{time_ago_with_tooltip(user.created_at)}
-
+ .info-well
+ .well-segment.admin-well.admin-well-features
+ %h4 Features
+ - sign_up = "Sign up"
+ %p{ "aria-label" => "#{sign_up}: status " + (allow_signup? ? "on" : "off") }
+ = sign_up
+ %span.light.pull-right
+ = boolean_to_icon allow_signup?
+ - ldap = "LDAP"
+ %p{ "aria-label" => "#{ldap}: status " + (Gitlab.config.ldap.enabled ? "on" : "off") }
+ = ldap
+ %span.light.pull-right
+ = boolean_to_icon Gitlab.config.ldap.enabled
+ - gravatar = "Gravatar"
+ %p{ "aria-label" => "#{gravatar}: status " + (gravatar_enabled? ? "on" : "off") }
+ = gravatar
+ %span.light.pull-right
+ = boolean_to_icon gravatar_enabled?
+ - omniauth = "OmniAuth"
+ %p{ "aria-label" => "#{omniauth}: status " + (Gitlab.config.omniauth.enabled ? "on" : "off") }
+ = omniauth
+ %span.light.pull-right
+ = boolean_to_icon Gitlab.config.omniauth.enabled
+ - reply_email = "Reply by email"
+ %p{ "aria-label" => "#{reply_email}: status " + (Gitlab::IncomingEmail.enabled? ? "on" : "off") }
+ = reply_email
+ %span.light.pull-right
+ = boolean_to_icon Gitlab::IncomingEmail.enabled?
+ - container_reg = "Container Registry"
+ %p{ "aria-label" => "#{container_reg}: status " + (Gitlab.config.registry.enabled ? "on" : "off") }
+ = container_reg
+ %span.light.pull-right
+ = boolean_to_icon Gitlab.config.registry.enabled
+ - gitlab_pages = 'GitLab Pages'
+ - gitlab_pages_enabled = Gitlab.config.pages.enabled
+ %p{ "aria-label" => "#{gitlab_pages}: status " + (gitlab_pages_enabled ? "on" : "off") }
+ = gitlab_pages
+ %span.light.pull-right
+ = boolean_to_icon gitlab_pages_enabled
+ - 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
+ .info-well
+ .well-segment.admin-well
+ %h4
+ Components
+ - if Gitlab::CurrentSettings.version_check_enabled
+ .pull-right
+ = version_status_badge
+ %p
+ GitLab
+ %span.pull-right
+ = Gitlab::VERSION
+ %p
+ GitLab Shell
+ %span.pull-right
+ = Gitlab::Shell.new.version
+ %p
+ GitLab Workhorse
+ %span.pull-right
+ = gitlab_workhorse_version
+ %p
+ GitLab API
+ %span.pull-right
+ = API::API::version
+ - if Gitlab.config.pages.enabled
+ %p
+ GitLab Pages
+ %span.pull-right
+ = Gitlab::Pages::VERSION
+ %p
+ Ruby
+ %span.pull-right
+ #{RUBY_VERSION}p#{RUBY_PATCHLEVEL}
+ %p
+ Rails
+ %span.pull-right
+ #{Rails::VERSION::STRING}
+ %p
+ = Gitlab::Database.adapter_name
+ %span.pull-right
+ = Gitlab::Database.version
+ %p
+ = link_to "Gitaly Servers", admin_gitaly_servers_path
+ .row
+ .col-md-4
+ .info-well
+ .well-segment.admin-well
+ %h4 Latest projects
+ - @projects.each do |project|
+ %p
+ = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
+ %span.light.pull-right
+ #{time_ago_with_tooltip(project.created_at)}
+ .col-md-4
+ .info-well
+ .well-segment.admin-well
+ %h4 Latest users
+ - @users.each do |user|
+ %p
+ = link_to [:admin, user], class: 'str-truncated-60' do
+ = user.name
+ %span.light.pull-right
+ #{time_ago_with_tooltip(user.created_at)}
.col-md-4
- %h4 Latest groups
- %hr
- - @groups.each do |group|
- %p
- = link_to [:admin, group], class: 'str-truncated-60' do
- = group.full_name
- %span.light.pull-right
- #{time_ago_with_tooltip(group.created_at)}
+ .info-well
+ .well-segment.admin-well
+ %h4 Latest groups
+ - @groups.each do |group|
+ %p
+ = link_to [:admin, group], class: 'str-truncated-60' do
+ = group.full_name
+ %span.light.pull-right
+ #{time_ago_with_tooltip(group.created_at)}
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 92370034baa..1420163fd5a 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -12,7 +12,7 @@
%tr
%th.col-sm-2 Title
%th.col-sm-4 Fingerprint
- %th.col-sm-2 Write access allowed
+ %th.col-sm-2 Projects with write access
%th.col-sm-2 Added at
%th.col-sm-2
%tbody
@@ -23,10 +23,8 @@
%td
%code.key-fingerprint= deploy_key.fingerprint
%td
- - if deploy_key.can_push?
- Yes
- - else
- No
+ - deploy_key.projects_with_write_access.each do |project|
+ = link_to project.full_name, admin_project_path(project), class: 'label deploy-project-label'
%td
%span.cgray
added #{time_ago_with_tooltip(deploy_key.created_at)}
diff --git a/app/views/admin/gitaly_servers/index.html.haml b/app/views/admin/gitaly_servers/index.html.haml
new file mode 100644
index 00000000000..231f94dc95d
--- /dev/null
+++ b/app/views/admin/gitaly_servers/index.html.haml
@@ -0,0 +1,31 @@
+- breadcrumb_title _("Gitaly Servers")
+
+%h3.page-title= _("Gitaly Servers")
+%hr
+.gitaly_servers
+ - if @gitaly_servers.any?
+ .table-holder
+ %table.table.responsive-table
+ %thead.hidden-sm.hidden-xs
+ %tr
+ %th= _("Storage")
+ %th= n_("Gitaly|Address")
+ %th= _("Server version")
+ %th= _("Git version")
+ %th= _("Up to date")
+ - @gitaly_servers.each do |server|
+ %tr
+ %td
+ = server.storage
+ %td
+ = server.address
+ %td
+ = server.server_version
+ %td
+ = server.git_binary_version
+ %td
+ = boolean_to_icon(server.up_to_date?)
+ - else
+ .empty-state
+ .text-center
+ %h4= _("No connection could be made to a Gitaly Server, please check your logs!")
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index e3a77dfdf10..47cc2d4d27e 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -20,7 +20,7 @@
= visibility_level_icon(group.visibility_level, fw: false)
.avatar-container.s40
- = image_tag group_icon(group), class: "avatar s40 hidden-xs"
+ = group_icon(group, class: "avatar s40 hidden-xs")
.title
= link_to [:admin, group], class: 'group-name' do
= group.full_name
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index e5f380c78e2..25946ba6eaf 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Groups"
-= render "admin/dashboard/head"
%div{ class: container_class }
.top-area
@@ -12,23 +11,7 @@
.search-field-holder
= search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name'
= icon("search", class: "search-icon")
- .dropdown
- - toggle_text = @sort.present? ? sort_options_hash[@sort] : sort_title_recently_created
- = dropdown_toggle(toggle_text, { toggle: 'dropdown' })
- %ul.dropdown-menu.dropdown-menu-align-right
- %li.dropdown-header
- Sort by
- %li
- = link_to admin_groups_path(sort: sort_value_recently_created, name: project_name) do
- = sort_title_recently_created
- = link_to admin_groups_path(sort: sort_value_oldest_created, name: project_name) do
- = sort_title_oldest_created
- = link_to admin_groups_path(sort: sort_value_recently_updated, name: project_name) do
- = sort_title_recently_updated
- = link_to admin_groups_path(sort: sort_value_oldest_updated, name: project_name) do
- = sort_title_oldest_updated
- = link_to admin_groups_path(sort: sort_value_largest_group, name: project_name) do
- = sort_title_largest_group
+ = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash
= link_to new_admin_group_path, class: "btn btn-new" do
New group
%ul.content-list
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 9149b8e7fb9..324f3c0a22f 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs "Groups", admin_groups_path
+- breadcrumb_title @group.name
- page_title @group.name, "Groups"
%h3.page-title
Group: #{@group.full_name}
@@ -14,7 +16,7 @@
%ul.well-list
%li
.avatar-container.s60
- = image_tag group_icon(@group), class: "avatar s60"
+ = group_icon(@group, class: "avatar s60")
%li
%span.light Name:
%strong= @group.name
@@ -66,11 +68,11 @@
- @projects.each do |project|
%li
%strong
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project]
+ = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project]
%span.badge
= storage_counter(project.statistics.storage_size)
%span.pull-right.light
- %span.monospace= project.path_with_namespace + ".git"
+ %span.monospace= project.full_path + '.git'
.panel-footer
= paginate @projects, param_name: 'projects_page', theme: 'gitlab'
@@ -84,11 +86,11 @@
- @group.shared_projects.sort_by(&:name).each do |project|
%li
%strong
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project]
+ = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project]
%span.badge
= storage_counter(project.statistics.storage_size)
%span.pull-right.light
- %span.monospace= project.path_with_namespace + ".git"
+ %span.monospace= project.full_path + '.git'
.col-md-6
- if can?(current_user, :admin_group_member, @group)
@@ -107,8 +109,7 @@
= select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2"
%hr
= button_tag 'Add users to group', class: "btn btn-create"
-
- = render 'shared/members/requests', membership_source: @group, requesters: @requesters
+ = render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true
.panel.panel-default
.panel-heading
@@ -117,7 +118,7 @@
%span.badge= @group.members.size
.pull-right
= link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@group, :members]), class: "btn btn-xs"
- %ul.well-list.group-users-list.content-list
+ %ul.well-list.group-users-list.content-list.members-list
= render partial: 'shared/members/member', collection: @members, as: :member, locals: { show_controls: false }
.panel-footer
= paginate @members, param_name: 'members_page', theme: 'gitlab'
diff --git a/app/views/admin/health_check/_failing_storages.html.haml b/app/views/admin/health_check/_failing_storages.html.haml
new file mode 100644
index 00000000000..6830201538d
--- /dev/null
+++ b/app/views/admin/health_check/_failing_storages.html.haml
@@ -0,0 +1,15 @@
+- if failing_storages.any?
+ = _('There are problems accessing Git storage: ')
+ %ul
+ - failing_storages.each do |storage_health|
+ %li
+ = failing_storage_health_message(storage_health)
+ %ul
+ - storage_health.failing_circuit_breakers.each do |circuit_breaker|
+ %li
+ #{circuit_breaker.hostname}: #{message_for_circuit_breaker(circuit_breaker)}
+
+ = _("Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again.")
+ .prepend-top-10
+ = button_to _("Reset git storage health information"), reset_storage_health_admin_health_check_path,
+ method: :post, class: 'btn btn-default'
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index f16f59623f7..e31fb58b205 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -1,42 +1,42 @@
- @no_container = true
-- page_title "Health Check"
-= render 'admin/monitoring/head'
+- page_title _('Health Check')
+- no_errors = @errors.blank? && @failing_storage_statuses.blank?
%div{ class: container_class }
- %h3.page-title
- Health Check
+ %h3.page-title= page_title
.bs-callout.clearfix
.pull-left
%p
- Access token is
- %code#health-check-token= current_application_settings.health_check_access_token
+ #{ s_('HealthCheck|Access token is') }
+ %code#health-check-token= Gitlab::CurrentSettings.health_check_access_token
.prepend-top-10
- = button_to "Reset health check access token", reset_health_check_token_admin_application_settings_path,
+ = 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?' }
+ data: { confirm: _('Are you sure you want to reset the health check token?') }
%p.light
- 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')
+ #{ _('Health information can be retrieved from the following endpoints. More information is available') }
+ = link_to s_('More information is available|here'), help_page_path('user/admin_area/monitoring/health_check')
%ul
%li
- %code= readiness_url(token: current_application_settings.health_check_access_token)
+ %code= readiness_url(token: Gitlab::CurrentSettings.health_check_access_token)
%li
- %code= liveness_url(token: current_application_settings.health_check_access_token)
+ %code= liveness_url(token: Gitlab::CurrentSettings.health_check_access_token)
%li
- %code= metrics_url(token: current_application_settings.health_check_access_token)
+ %code= metrics_url(token: Gitlab::CurrentSettings.health_check_access_token)
%hr
.panel.panel-default
.panel-heading
Current Status:
- - if @errors.blank?
+ - if no_errors
= icon('circle', class: 'cgreen')
- Healthy
+ #{ s_('HealthCheck|Healthy') }
- else
= icon('warning', class: 'cred')
- Unhealthy
+ #{ s_('HealthCheck|Unhealthy') }
.panel-body
- - if @errors.blank?
- No Health Problems Detected
+ - if no_errors
+ #{ s_('HealthCheck|No Health Problems Detected') }
- else
= @errors
+ = render partial: 'failing_storages', object: @failing_storage_statuses
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
index 7dd9943190f..91a8c0c62fe 100644
--- a/app/views/admin/hook_logs/_index.html.haml
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -24,7 +24,7 @@
%td
= truncate(hook_log.url, length: 50)
%td.light
- #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} sec
%td.light
= time_ago_with_tooltip(hook_log.created_at)
%td
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index 645005c6deb..a6324a97fd5 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -1,21 +1,20 @@
= form_errors(hook)
.form-group
- = form.label :url, 'URL', class: 'control-label'
- .col-sm-10
- = form.text_field :url, class: 'form-control'
+ = form.label :url, 'URL', class: 'label-light'
+ = form.text_field :url, class: 'form-control'
.form-group
- = form.label :token, 'Secret Token', class: 'control-label'
- .col-sm-10
- = form.text_field :token, class: 'form-control'
- %p.help-block
- Use this token to validate received payloads
+ = form.label :token, 'Secret Token', class: 'label-light'
+ = form.text_field :token, class: 'form-control'
+ %p.help-block
+ Use this token to validate received payloads
.form-group
- = form.label :url, 'Trigger', class: 'control-label'
- .col-sm-10.prepend-top-10
- %div
- System hook will be triggered on set of events like creating project
- or adding ssh key. But you can also enable extra triggers like Push events.
+ = form.label :url, 'Trigger', class: 'label-light'
+ %ul.list-unstyled
+ %li
+ .help-block
+ System hook will be triggered on set of events like creating project
+ or adding ssh key. But you can also enable extra triggers like Push events.
.prepend-top-default
= form.check_box :repository_update_events, class: 'pull-left'
@@ -24,24 +23,30 @@
%strong Repository update events
%p.light
This URL will be triggered when repository is updated
- %div
+ %li
= form.check_box :push_events, class: 'pull-left'
.prepend-left-20
= form.label :push_events, class: 'list-label' do
%strong Push events
%p.light
This URL will be triggered for each branch updated to the repository
- %div
+ %li
= form.check_box :tag_push_events, class: 'pull-left'
.prepend-left-20
= form.label :tag_push_events, class: 'list-label' do
%strong Tag push events
%p.light
This URL will be triggered when a new tag is pushed to the repository
+ %li
+ = form.check_box :merge_requests_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :merge_requests_events, class: 'list-label' do
+ %strong Merge request events
+ %p.light
+ This URL will be triggered when a merge request is created/updated/merged
.form-group
- = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox'
- .col-sm-10
- .checkbox
- = form.label :enable_ssl_verification do
- = form.check_box :enable_ssl_verification
- %strong Enable SSL verification
+ = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox'
+ .checkbox
+ = form.label :enable_ssl_verification do
+ = form.check_box :enable_ssl_verification
+ %strong Enable SSL verification
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 0e35a1905bf..629b1a9940f 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -1,3 +1,4 @@
+- add_to_breadcrumbs "System Hooks", admin_hooks_path
- page_title 'Edit System Hook'
%h3.page-title
Edit System Hook
@@ -12,7 +13,7 @@
= 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'
+ = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: @hook
= link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' }
%hr
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index e92b8bc39f4..d9e2ce5e74c 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,33 +1,35 @@
- page_title 'System Hooks'
-%h3.page-title
- System hooks
+.row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ = page_title
+ %p
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
+ used for binding events when GitLab creates a User or Project.
-%p.light
- #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
- used for binding events when GitLab creates a User or Project.
+ .col-lg-8.append-bottom-default
+ = form_for @hook, as: :hook, url: admin_hooks_path do |f|
+ = render partial: 'form', locals: { form: f, hook: @hook }
+ = f.submit 'Add system hook', class: 'btn btn-create'
-%hr
+ %hr
-= form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f|
- = render partial: 'form', locals: { form: f, hook: @hook }
- .form-actions
- = f.submit 'Add system hook', class: 'btn btn-create'
-%hr
+ - if @hooks.any?
+ .panel.panel-default
+ .panel-heading
+ System hooks (#{@hooks.count})
+ %ul.content-list
+ - @hooks.each do |hook|
+ %li
+ .controls
+ = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm'
+ = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm'
+ = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
+ .monospace= hook.url
+ %div
+ - SystemHook.triggers.each_value do |event|
+ - if hook.public_send(event)
+ %span.label.label-gray= event.to_s.titleize
+ %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
-- if @hooks.any?
- .panel.panel-default
- .panel-heading
- System hooks (#{@hooks.count})
- %ul.content-list
- - @hooks.each do |hook|
- %li
- .controls
- = link_to 'Test hook', test_admin_hook_path(hook), class: 'btn btn-sm'
- = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm'
- = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
- .monospace= hook.url
- %div
- - %w(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'}
+= render 'shared/plugins/index'
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index 112a201fafa..5381b854f5c 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -4,7 +4,7 @@
.form-group
= f.label :provider, class: 'control-label'
.col-sm-10
- - values = Gitlab::OAuth::Provider.providers.map { |name| ["#{Gitlab::OAuth::Provider.label_for(name)} (#{name})", name] }
+ - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] }
= f.select :provider, values, { allow_blank: false }, class: 'form-control'
.form-group
= f.label :extern_uid, "Identifier", class: 'control-label'
diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml
index 8c658905bd6..ef5a3f1d969 100644
--- a/app/views/admin/identities/_identity.html.haml
+++ b/app/views/admin/identities/_identity.html.haml
@@ -1,6 +1,6 @@
%tr
%td
- #{Gitlab::OAuth::Provider.label_for(identity.provider)} (#{identity.provider})
+ #{Gitlab::Auth::OAuth::Provider.label_for(identity.provider)} (#{identity.provider})
%td
= identity.extern_uid
%td
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
index 09be17f07be..4e3e2f7a475 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -1,15 +1,19 @@
+- breadcrumb_title "Jobs"
- @no_container = true
-= render "admin/dashboard/head"
%div{ class: container_class }
- .top-area
+ .top-area.scrolling-tabs-container.inner-page-scroll-tabs
- 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_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
+ - if @all_builds.running_or_pending.any?
+ #stop-jobs-modal
+ .nav-controls
+ %button#stop-jobs-button.btn.btn-danger{ data: { toggle: 'modal',
+ target: '#stop-jobs-modal',
+ url: cancel_all_admin_jobs_path } }
+ = s_('AdminArea|Stop all jobs')
.row-content-block.second-block
#{(@scope || 'all').capitalize} jobs
diff --git a/app/views/admin/labels/edit.html.haml b/app/views/admin/labels/edit.html.haml
index 309aedceded..96f0d404ac4 100644
--- a/app/views/admin/labels/edit.html.haml
+++ b/app/views/admin/labels/edit.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs "Labels", admin_labels_path
+- breadcrumb_title "Edit Label"
- page_title "Edit", @label.name, "Labels"
%h3.page-title
Edit Label
diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml
index 487f1cf5c4f..78757b6384f 100644
--- a/app/views/admin/logs/show.html.haml
+++ b/app/views/admin/logs/show.html.haml
@@ -1,25 +1,20 @@
- @no_container = true
- page_title "Logs"
-- loggers = [Gitlab::GitLogger, Gitlab::AppLogger,
- Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger,
- Gitlab::RepositoryCheckLogger]
-= render 'admin/monitoring/head'
%div{ class: container_class }
%ul.nav-links.log-tabs
- - loggers.each do |klass|
- %li{ class: active_when(klass == Gitlab::GitLogger) }>
- = link_to klass::file_name, "##{klass::file_name_noext}",
- 'data-toggle' => 'tab'
+ - @loggers.each do |klass|
+ %li{ class: active_when(klass == @loggers.first) }>
+ = link_to klass.file_name, "##{klass.file_name_noext}", data: { toggle: 'tab' }
.row-content-block
To prevent performance issues admin logs output the last 2000 lines
.tab-content
- - loggers.each do |klass|
- .tab-pane{ class: active_when(klass == Gitlab::GitLogger), id: klass::file_name_noext }
+ - @loggers.each do |klass|
+ .tab-pane{ class: active_when(klass == @loggers.first), id: klass.file_name_noext }
.file-holder#README
.js-file-title.file-title
%i.fa.fa-file
- = klass::file_name
+ = klass.file_name
.pull-right
= link_to '#', class: 'log-bottom' do
%i.fa.fa-arrow-down
diff --git a/app/views/admin/monitoring/_head.html.haml b/app/views/admin/monitoring/_head.html.haml
deleted file mode 100644
index 901e30275fd..00000000000
--- a/app/views/admin/monitoring/_head.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = 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
- System Info
- = nav_link(controller: :background_jobs) do
- = link_to admin_background_jobs_path, title: 'Background Jobs' do
- %span
- Background Jobs
- = nav_link(controller: :logs) do
- = link_to admin_logs_path, title: 'Logs' do
- %span
- Logs
- = nav_link(controller: :health_check) do
- = link_to admin_health_check_path, title: 'Health Check' do
- %span
- Health Check
- = nav_link(controller: :requests_profiles) do
- = link_to admin_requests_profiles_path, title: 'Requests Profiles' do
- %span
- Requests Profiles
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 596f367a00d..b5d7b889504 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -4,8 +4,13 @@
- @projects.each_with_index do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
- = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn"
- = link_to 'Delete', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-remove"
+ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
+ %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal',
+ target: '#delete-project-modal',
+ delete_project_url: project_path(project),
+ project_name: project.name }, type: 'button' }
+ = s_('AdminProjects|Delete')
+
.stats
%span.badge
= storage_counter(project.statistics.storage_size)
@@ -31,3 +36,5 @@
= paginate @projects, theme: 'gitlab'
- else
.nothing-here-block No projects found
+
+ #delete-project-modal
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 3301f55b8a8..c37d8ac45b9 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -2,10 +2,9 @@
- page_title "Projects"
- params[:visibility_level] ||= []
-= render "admin/dashboard/head"
%div{ class: container_class }
- .top-area
+ .top-area.scrolling-tabs-container.inner-page-scroll-tabs
.prepend-top-default
.search-holder
= render 'shared/projects/search_form', autofocus: true, icon: true
@@ -15,7 +14,7 @@
= hidden_field_tag :namespace_id, params[:namespace_id]
- namespace = Namespace.find(params[:namespace_id])
- toggle_text = "#{namespace.kind}: #{namespace.full_path}"
- = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { toggle_class: 'js-namespace-select large' })
+ = dropdown_toggle(toggle_text, { toggle: 'dropdown', is_filter: 'true' }, { toggle_class: 'js-namespace-select large' })
.dropdown-menu.dropdown-select.dropdown-menu-align-right
= dropdown_title('Namespaces')
= dropdown_filter("Search for Namespace")
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 08a8f627113..c02ddafe108 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -1,6 +1,8 @@
-- page_title @project.name_with_namespace, "Projects"
+- add_to_breadcrumbs "Projects", admin_projects_path
+- breadcrumb_title @project.full_name
+- page_title @project.full_name, "Projects"
%h3.page-title
- Project: #{@project.name_with_namespace}
+ Project: #{@project.full_name}
= link_to edit_project_path(@project), class: "btn btn-nr pull-right" do
%i.fa.fa-pencil-square-o
Edit
@@ -108,12 +110,12 @@
.panel-heading
Transfer project
.panel-body
- = form_for @project, url: transfer_admin_namespace_project_path(@project.namespace, @project), method: :put, html: { class: 'form-horizontal' } do |f|
+ = form_for @project, url: transfer_admin_project_path(@project), method: :put, html: { class: 'form-horizontal' } do |f|
.form-group
= f.label :new_namespace_id, "Namespace", class: 'control-label'
.col-sm-10
.dropdown
- = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id', show_any: 'false' }, { toggle_class: 'js-namespace-select large' })
+ = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
.dropdown-menu.dropdown-select
= dropdown_title('Namespaces')
= dropdown_filter("Search for Namespace")
@@ -128,7 +130,7 @@
.panel-heading
Repository check
.panel-body
- = form_for @project, url: repository_check_admin_namespace_project_path(@project.namespace, @project), method: :post do |f|
+ = form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
.form-group
- if @project.last_repository_check_at.nil?
This repository has never been checked.
@@ -160,12 +162,12 @@
.pull-right
= link_to admin_group_path(@group), class: 'btn btn-xs' do
= icon('pencil-square-o', text: 'Manage access')
- %ul.well-list.content-list
+ %ul.well-list.content-list.members-list
= render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false }
.panel-footer
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
- = render 'shared/members/requests', membership_source: @project, requesters: @requesters
+ = render 'shared/members/requests', membership_source: @project, requesters: @requesters, force_mobile_view: true
.panel.panel-default
.panel-heading
@@ -174,7 +176,7 @@
%span.badge= @project.users.size
.pull-right
= link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@project, :members]), class: "btn btn-xs"
- %ul.well-list.project_members.content-list
+ %ul.well-list.project_members.content-list.members-list
= render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false }
.panel-footer
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml
index b7db18b2d32..cb02a750490 100644
--- a/app/views/admin/requests_profiles/index.html.haml
+++ b/app/views/admin/requests_profiles/index.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title 'Requests Profiles'
-= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index d4d166ab7b6..e1cee584929 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -17,6 +17,8 @@
%td
= runner.version
%td
+ = runner.ip_address
+ %td
- if runner.shared?
n/a
- else
@@ -32,13 +34,16 @@
#{time_ago_in_words(runner.contacted_at)} ago
- else
Never
- %td
- .pull-right
- = link_to 'Edit', admin_runner_path(runner), class: 'btn btn-sm'
+ %td.admin-runner-btn-group-cell
+ .pull-right.btn-group
+ = link_to admin_runner_path(runner), class: 'btn btn-sm btn-default has-tooltip', title: 'Edit', ref: 'tooltip', aria: { label: 'Edit' }, data: { placement: 'top', container: 'body'} do
+ = icon('pencil')
&nbsp;
- if runner.active?
- = link_to 'Pause', [:pause, :admin, runner], data: { confirm: "Are you sure?" }, method: :get, class: 'btn btn-danger btn-sm'
+ = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-sm btn-default has-tooltip', title: 'Pause', ref: 'tooltip', aria: { label: 'Pause' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
+ = icon('pause')
- else
- = link_to 'Resume', [:resume, :admin, runner], method: :get, class: 'btn btn-success btn-sm'
- = link_to 'Remove', [:admin, runner], data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
-
+ = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-sm has-tooltip', title: 'Resume', ref: 'tooltip', aria: { label: 'Resume' }, data: { placement: 'top', container: 'body'} do
+ = icon('play')
+ = link_to [:admin, runner], method: :delete, class: 'btn btn-danger btn-sm has-tooltip', title: 'Remove', ref: 'tooltip', aria: { label: 'Remove' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
+ = icon('remove')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 2da8f615470..9f13dbbbd82 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -1,27 +1,7 @@
+- breadcrumb_title "Runners"
- @no_container = true
-= render "admin/dashboard/head"
%div{ class: container_class }
-
- %p.prepend-top-default
- %span
- To register a new Runner you should enter the following registration
- token.
- With this token the Runner will request a unique Runner token and use
- that for future communication.
- %br
- Registration token is
- %code#runners-token= current_application_settings.runners_registration_token
-
- .bs-callout.clearfix
- .pull-left
- %p
- You can reset runners registration token by pressing a button below.
- .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?' }
-
.bs-callout
%p
A 'Runner' is a process which runs a job.
@@ -46,6 +26,18 @@
%span.label.label-danger paused
\- Runner will not receive any new jobs
+ .bs-callout.clearfix
+ .pull-left
+ %p
+ You can reset runners registration token by pressing a button below.
+ .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?") }
+
+ = render partial: 'ci/runner/how_to_setup_shared_runner',
+ locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token }
+
.append-bottom-20.clearfix
.pull-left
= form_tag admin_runners_path, id: 'runners-search', class: 'form-inline', method: :get do
@@ -59,22 +51,24 @@
%br
- if @runners.any?
- .table-holder
- %table.table
- %thead
- %tr
- %th Type
- %th Runner token
- %th Description
- %th Version
- %th Projects
- %th Jobs
- %th Tags
- %th Last contact
- %th
+ .runners-content
+ .table-holder
+ %table.table
+ %thead
+ %tr
+ %th Type
+ %th Runner token
+ %th Description
+ %th Version
+ %th IP Address
+ %th Projects
+ %th Jobs
+ %th Tags
+ %th= link_to 'Last contact', admin_runners_path(params.slice(:search).merge(sort: 'contacted_asc'))
+ %th
- - @runners.each do |runner|
- = render "admin/runners/runner", runner: runner
- = paginate @runners, theme: "gitlab"
+ - @runners.each do |runner|
+ = render "admin/runners/runner", runner: runner
+ = paginate @runners, theme: "gitlab"
- else
.nothing-here-block No runners found
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 801430e525e..37269862de6 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -9,6 +9,10 @@
%span.runner-state.runner-state-specific
Specific
+- add_to_breadcrumbs _("Runners"), admin_runners_path
+- breadcrumb_title "##{@runner.id}"
+- @no_container = true
+
- if @runner.shared?
.bs-callout.bs-callout-success
%h4 This Runner will process jobs from ALL UNASSIGNED projects
@@ -39,7 +43,7 @@
%tr.alert-info
%td
%strong
- = project.name_with_namespace
+ = project.full_name
%td
.pull-right
= link_to 'Disable', [:admin, project.namespace.becomes(Namespace), project, runner_project], method: :delete, class: 'btn btn-danger btn-xs'
@@ -61,7 +65,7 @@
- @projects.each do |project|
%tr
%td
- = project.name_with_namespace
+ = project.full_name
%td
.pull-right
= form_for [:admin, project.namespace.becomes(Namespace), project, project.runner_projects.new] do |f|
@@ -85,7 +89,7 @@
%tr.build
%td.id
- if project
- = link_to namespace_project_job_path(project.namespace, project, build) do
+ = link_to project_job_path(project, build) do
%strong ##{build.id}
- else
%strong ##{build.id}
@@ -95,11 +99,11 @@
%td.status
- if project
- = project.name_with_namespace
+ = project.full_name
%td.build-link
- if project
- = link_to ci_status_path(build.pipeline) do
+ = link_to pipeline_path(build.pipeline) do
%strong= build.pipeline.short_sha
%td.timestamp
diff --git a/app/views/admin/services/edit.html.haml b/app/views/admin/services/edit.html.haml
index 53d970e33c1..512176649e6 100644
--- a/app/views/admin/services/edit.html.haml
+++ b/app/views/admin/services/edit.html.haml
@@ -1,2 +1,4 @@
+- add_to_breadcrumbs "Service Templates", admin_application_settings_services_path
+- breadcrumb_title @service.title
- page_title @service.title, "Service Templates"
= render 'form'
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index fd0281e4961..23f9927cfee 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "System Info"
-= render 'admin/monitoring/head'
%div{ class: container_class }
.prepend-top-default
@@ -16,7 +15,7 @@
Unable to collect CPU info
.col-sm-4
.light-well
- %h4 Memory
+ %h4 Memory Usage
.data
- if @memory
%h1 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
@@ -25,7 +24,7 @@
Unable to collect memory info
.col-sm-4
.light-well
- %h4 Disks
+ %h4 Disk Usage
.data
- @disks.each do |disk|
%h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
@@ -35,4 +34,4 @@
.light-well
%h4 Uptime
.data
- %h1= time_ago_with_tooltip(Rails.application.config.booted_at)
+ %h1= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 4cf4a57ba18..bbfeceff5b9 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -1,6 +1,6 @@
%li.flex-row
.user-avatar
- = image_tag avatar_icon(user), class: "avatar", alt: ''
+ = image_tag avatar_icon_for_user(user), class: "avatar", alt: ''
.row-main-content
.user-name.row-title.str-truncated-100
= link_to user.name, [:admin, user]
@@ -38,11 +38,19 @@
%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
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(user),
+ block_user_url: block_admin_user_path(user),
+ username: user.name,
+ delete_contributions: 'false' }, type: 'button' }
+ = s_('AdminUsers|Delete user')
+
%li
- = 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
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(user, hard_delete: true),
+ block_user_url: block_admin_user_path(user),
+ username: user.name,
+ delete_contributions: 'true' }, type: 'button' }
+ = s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 5516134d8a0..0ef4b71f4fe 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Users"
-= render "admin/dashboard/head"
%div{ class: container_class }
.prepend-top-default
@@ -77,3 +76,6 @@
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
+
+#delete-user-modal
+
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 15eaf1c0e67..96835ee9af5 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -29,12 +29,12 @@
.panel.panel-default
.panel-heading Joined projects (#{@joined_projects.count})
%ul.well-list
- - @joined_projects.sort_by(&:name_with_namespace).each do |project|
+ - @joined_projects.sort_by(&:full_name).each do |project|
- member = project.team.find_member(@user.id)
%li.project_member
.list-item-name
- = link_to admin_namespace_project_path(project.namespace, project), class: dom_class(project) do
- = project.name_with_namespace
+ = link_to admin_project_path(project), class: dom_class(project) do
+ = project.full_name
- if member
.pull-right
@@ -44,5 +44,5 @@
%span.light.vertical-align-middle= member.human_access
- if member.respond_to? :project
- = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove prepend-left-10", title: 'Remove user from project' do
+ = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove prepend-left-10", title: 'Remove user from project' do
%i.fa.fa-times
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index b556ff056c0..ec3be869797 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs "Users", admin_users_path
+- breadcrumb_title @user.name
- page_title @user.name, "Users"
= render 'admin/users/head'
@@ -8,7 +10,7 @@
= @user.name
%ul.well-list
%li
- = image_tag avatar_icon(@user, 60), class: "avatar s60"
+ = image_tag avatar_icon_for_user(@user, 60), class: "avatar s60"
%li
%span.light Profile page:
%strong
@@ -155,7 +157,6 @@
%ul
%li User will not be able to login
%li User will not be able to access git repositories
- %li User will be removed from joined projects and groups
%li Personal projects will be left
%li Owned groups will be left
%br
@@ -171,13 +172,19 @@
.panel.panel-danger
.panel-heading
- Remove user
+ = s_('AdminUsers|Delete user')
.panel-body
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user
%br
- = 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"
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(@user),
+ block_user_url: block_admin_user_path(@user),
+ username: @user.name,
+ delete_contributions: 'false' }, type: 'button' }
+ = s_('AdminUsers|Delete user')
- else
- if @user.solo_owned_groups.present?
%p
@@ -191,7 +198,7 @@
.panel.panel-danger
.panel-heading
- Remove user and contributions
+ = s_('AdminUsers|Delete user and contributions')
.panel-body
- if can?(current_user, :destroy_user, @user)
%p
@@ -203,7 +210,15 @@
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"
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(@user, hard_delete: true),
+ block_user_url: block_admin_user_path(@user),
+ username: @user.name,
+ delete_contributions: 'true' }, type: 'button' }
+ = s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
+
+ #delete-user-modal
diff --git a/app/views/ci/lints/_create.html.haml b/app/views/ci/lints/_create.html.haml
index c91602fcff7..30bf1384b22 100644
--- a/app/views/ci/lints/_create.html.haml
+++ b/app/views/ci/lints/_create.html.haml
@@ -22,10 +22,10 @@
%b Tag list:
= build[:tag_list].to_a.join(", ")
%br
- %b Refs only:
+ %b Only policy:
= @jobs[build[:name].to_sym][:only].to_a.join(", ")
%br
- %b Refs except:
+ %b Except policy:
= @jobs[build[:name].to_sym][:except].to_a.join(", ")
%br
%b Environment:
diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml
index dfbc7772698..3c0881caa06 100644
--- a/app/views/ci/lints/show.html.haml
+++ b/app/views/ci/lints/show.html.haml
@@ -1,6 +1,6 @@
- page_title "CI Lint"
- page_description "Validate your GitLab CI configuration file"
-- content_for :page_specific_javascripts do
+- content_for :library_javascripts do
= page_specific_javascript_tag('lib/ace.js')
%h2 Check your .gitlab-ci.yml
@@ -18,6 +18,8 @@
.col-sm-12
.pull-left.prepend-top-10
= submit_tag('Validate', class: 'btn btn-success submit-yml')
+ .pull-right.prepend-top-10
+ = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml')
.row.prepend-top-20
.col-sm-12
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
new file mode 100644
index 00000000000..37fb8fbab26
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -0,0 +1,16 @@
+- link = link_to _("GitLab Runner section"), 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'
+.append-bottom-10
+ %h4= _("Setup a #{type} Runner manually")
+
+%ol
+ %li
+ = _("Install a Runner compatible with GitLab CI")
+ = (_("(checkout the %{link} for information on how to install it).") % { link: link }).html_safe
+ %li
+ = _("Specify the following URL during the Runner setup:")
+ %code#coordinator_address= root_url(only_path: false)
+ %li
+ = _("Use the following registration token during setup:")
+ %code#registration_token= registration_token
+ %li
+ = _("Start the Runner!")
diff --git a/app/views/ci/runner/_how_to_setup_shared_runner.html.haml b/app/views/ci/runner/_how_to_setup_shared_runner.html.haml
new file mode 100644
index 00000000000..2a190cb9250
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_shared_runner.html.haml
@@ -0,0 +1,3 @@
+.bs-callout.help-callout
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: registration_token, type: 'shared' }
diff --git a/app/views/ci/runner/_how_to_setup_specific_runner.html.haml b/app/views/ci/runner/_how_to_setup_specific_runner.html.haml
new file mode 100644
index 00000000000..e765a353fe4
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_specific_runner.html.haml
@@ -0,0 +1,26 @@
+.bs-callout.help-callout
+ .append-bottom-10
+ %h4= _('Setup a specific Runner automatically')
+
+ %p
+ - link_to_help_page = link_to(_('Learn more about Kubernetes'),
+ help_page_path('user/project/clusters/index'),
+ target: '_blank',
+ rel: 'noopener noreferrer')
+
+ = _('You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page }
+
+ %ol
+ %li
+ = _('Click the button below to begin the install process by navigating to the Kubernetes page')
+ %li
+ = _('Select an existing Kubernetes cluster or create a new one')
+ %li
+ = _('From the Kubernetes cluster details view, install Runner from the applications list')
+
+ = link_to _('Install Runner on Kubernetes'),
+ project_clusters_path(@project),
+ class: 'btn btn-info'
+ %hr
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: registration_token, type: 'specific' }
diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml
index 39c7fb0eba2..35a3563dff1 100644
--- a/app/views/ci/status/_badge.html.haml
+++ b/app/views/ci/status/_badge.html.haml
@@ -5,9 +5,9 @@
- if link && status.has_details?
= link_to status.details_path, class: css_classes, title: title do
- = custom_icon(status.icon)
+ = sprite_icon(status.icon)
= status.text
- else
%span{ class: css_classes, title: title }
- = custom_icon(status.icon)
+ = sprite_icon(status.icon)
= status.text
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index 8ed23ac4919..c5b4439e273 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -6,14 +6,14 @@
- tooltip = "#{subject.name} - #{status.label}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip } do
- %span{ class: klass }= custom_icon(status.icon)
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
+ %span{ class: klass }= sprite_icon(status.icon)
%span.ci-build-text= subject.name
- else
- .mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip } }
- %span{ class: klass }= custom_icon(status.icon)
+ .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
+ %span{ class: klass }= sprite_icon(status.icon)
%span.ci-build-text= subject.name
- if status.has_action?
- = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title } do
- = custom_icon(status.action_icon)
+ = link_to status.action_path, class: "ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
+ = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}")
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
new file mode 100644
index 00000000000..d355e7799df
--- /dev/null
+++ b/app/views/ci/variables/_content.html.haml
@@ -0,0 +1 @@
+= _('Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want.')
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
new file mode 100644
index 00000000000..e402801a776
--- /dev/null
+++ b/app/views/ci/variables/_index.html.haml
@@ -0,0 +1,20 @@
+- save_endpoint = local_assigns.fetch(:save_endpoint, nil)
+
+.row
+ .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } }
+ .hide.alert.alert-danger.js-ci-variable-error-box
+
+ %ul.ci-variable-list
+ - @variables.each.each do |variable|
+ = render 'ci/variables/variable_row', form_field: 'variables', variable: variable
+ = render 'ci/variables/variable_row', form_field: 'variables'
+ .prepend-top-20
+ %button.btn.btn-success.js-secret-variables-save-button{ type: 'button' }
+ %span.hide.js-secret-variables-save-loading-icon
+ = icon('spinner spin')
+ = _('Save variables')
+ %button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
+ - if @variables.size == 0
+ = n_('Hide value', 'Hide values', @variables.size)
+ - else
+ = n_('Reveal value', 'Reveal values', @variables.size)
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
new file mode 100644
index 00000000000..5d4229c80af
--- /dev/null
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -0,0 +1,49 @@
+- form_field = local_assigns.fetch(:form_field, nil)
+- variable = local_assigns.fetch(:variable, nil)
+- only_key_value = local_assigns.fetch(:only_key_value, false)
+
+- id = variable&.id
+- key = variable&.key
+- value = variable&.value
+- is_protected = variable && !only_key_value ? variable.protected : false
+
+- id_input_name = "#{form_field}[variables_attributes][][id]"
+- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
+- key_input_name = "#{form_field}[variables_attributes][][key]"
+- value_input_name = "#{form_field}[variables_attributes][][secret_value]"
+- protected_input_name = "#{form_field}[variables_attributes][][protected]"
+
+%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
+ .ci-variable-row-body
+ %input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id }
+ %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
+ %input.js-ci-variable-input-key.ci-variable-body-item.form-control{ type: "text",
+ name: key_input_name,
+ value: key,
+ placeholder: s_('CiVariables|Input variable key') }
+ .ci-variable-body-item
+ .form-control.js-secret-value-placeholder{ class: ('hide' unless id) }
+ = '*' * 20
+ %textarea.js-ci-variable-input-value.js-secret-value.form-control{ class: ('hide' if id),
+ rows: 1,
+ name: value_input_name,
+ placeholder: s_('CiVariables|Input variable value') }
+ = value
+ - unless only_key_value
+ .ci-variable-body-item.ci-variable-protected-item
+ .append-right-default
+ = s_("CiVariable|Protected")
+ %button{ type: 'button',
+ class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_protected}",
+ "aria-label": s_("CiVariable|Toggle protected") }
+ %input{ type: "hidden",
+ class: 'js-ci-variable-input-protected js-project-feature-toggle-input',
+ name: protected_input_name,
+ value: is_protected }
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
+ -# EE-specific start
+ -# EE-specific end
+ %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
+ = icon('minus-circle')
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index ecdf76ef5c5..7a3f3667ac1 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -2,7 +2,7 @@
%ul.nav-links
%li{ class: active_when(params[:filter].nil?) }>
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- Your Projects
+ Your projects
%li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
- Starred Projects
+ Starred projects
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 4594c52b34b..617c20b9635 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,14 +1,13 @@
.top-area
- %ul.nav-links
+ %ul.nav-links.mobile-separator
= nav_link(page: dashboard_groups_path) do
- = link_to dashboard_groups_path, title: 'Your groups' do
+ = link_to dashboard_groups_path, title: _("Your groups") do
Your groups
= nav_link(page: explore_groups_path) do
- = link_to explore_groups_path, title: 'Explore public groups' do
+ = link_to explore_groups_path, title: _("Explore public groups") do
Explore public groups
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
- = link_to new_group_path, class: "btn btn-new" do
- New group
+ = link_to _("New group"), new_group_path, class: "btn btn-new"
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 64b737ee886..449a2ce625e 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -1,22 +1,22 @@
= content_for :flash_message do
= render 'shared/project_limit'
+
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs
+ %ul.nav-links.scrolling-tabs.mobile-separator
= nav_link(page: [dashboard_projects_path, root_path]) do
- = link_to dashboard_projects_path, title: 'Home', class: 'shortcuts-activity', data: {placement: 'right'} do
+ = link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
Your projects
= nav_link(page: starred_dashboard_projects_path) do
- = link_to starred_dashboard_projects_path, title: 'Starred Projects', data: {placement: 'right'} do
+ = link_to starred_dashboard_projects_path, data: {placement: 'right'} do
Starred projects
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
- = link_to explore_root_path, title: 'Explore', data: {placement: 'right'} do
+ = link_to explore_root_path, data: {placement: 'right'} do
Explore projects
.nav-controls
= render 'shared/projects/search_form'
= render 'shared/projects/dropdown'
- if current_user.can_create_project?
- = link_to new_project_path, class: 'btn btn-new' do
- New project
+ = link_to "New project", new_project_path, class: "btn btn-new"
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 02e90bbfa55..a9488df07bd 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -2,12 +2,11 @@
%ul.nav-links
= nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do
= link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do
- Your Snippets
+ Your snippets
= nav_link(page: explore_snippets_path) do
= link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do
- Explore Snippets
+ Explore snippets
- if current_user
.nav-controls.hidden-xs
- = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do
- New snippet
+ = link_to "New snippet", new_snippet_path, class: "btn btn-new", title: "New snippet"
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index f893c3e1675..31d4b3da4f1 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- @no_container = true
= content_for :meta_tags do
@@ -6,10 +7,8 @@
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
-.hidden-xs
- = render "projects/last_push"
-
%div{ class: container_class }
+ = render "projects/last_push"
= render 'dashboard/activity_head'
%section.activities
diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml
deleted file mode 100644
index f5222fe631e..00000000000
--- a/app/views/dashboard/groups/_empty_state.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.groups-empty-state
- = custom_icon("icon_empty_groups")
-
- .text-content
- %h4 A group is a collection of several projects.
- %p If you organize your projects under a group, it works like a folder.
- %p You can manage your group member’s permissions and access to each project in the group.
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 168e6272d8e..db856ef7d7b 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,9 +1,4 @@
.js-groups-list-holder
- #dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
- .groups-list-loading
- = icon('spinner spin', 'v-show' => 'isLoading')
- %template{ 'v-if' => '!isLoading && isEmpty' }
- %div{ 'v-cloak' => true }
- = render 'empty_state'
- %template{ 'v-else-if' => '!isLoading && !isEmpty' }
- %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
+ #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index f9b45a539a1..50f39f93283 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -1,11 +1,9 @@
+- @hide_top_links = true
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
= render 'dashboard/groups_head'
-= webpack_bundle_tag 'common_vue'
-= webpack_bundle_tag 'groups'
-
-- if @groups.empty?
- = render 'empty_state'
+- if params[:filter].blank? && @groups.empty?
+ = render 'shared/groups/empty_state'
- else
= render 'groups'
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index d6b46dee0e4..3e85535dae0 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Issues"
- header_title "Issues", issues_dashboard_path(assignee_id: current_user.id)
= content_for :meta_tags do
@@ -6,9 +7,9 @@
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
- = link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do
+ = link_to params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do
= icon('rss')
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues'
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :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 6f6afe161d1..53cd1130299 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,10 +1,11 @@
+- @hide_top_links = true
- page_title "Merge Requests"
- header_title "Merge Requests", merge_requests_dashboard_path(assignee_id: current_user.id)
.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", with_feature_enabled: 'merge_requests'
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests
= render 'shared/issuable/filter', type: :merge_requests
= render 'shared/merge_requests'
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 664ec618b79..f66e2b40d76 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title 'Milestones'
- header_title 'Milestones', dashboard_milestones_path
@@ -5,7 +6,7 @@
= render 'shared/milestones_filter', counts: @milestone_states
.nav-controls
- = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true
+ = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true, type: :milestones
.milestones
%ul.content-list
diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
new file mode 100644
index 00000000000..c50b20a83dc
--- /dev/null
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -0,0 +1,41 @@
+.blank-state-row
+ = link_to new_project_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_project", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a project
+ %p.blank-state-text
+ Projects are where you store your code, access issues, wiki and other features of GitLab.
+
+ - if current_user.can_create_group?
+ = link_to new_group_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_group", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a group
+ %p.blank-state-text
+ Groups are a great way to organize projects and people.
+
+ = link_to new_admin_user_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_user", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Add people
+ %p.blank-state-text
+ Add your team members and others to GitLab.
+
+ = link_to admin_root_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("configure_server", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Configure GitLab
+ %p.blank-state-text
+ Make adjustments to how your GitLab instance is set up.
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
new file mode 100644
index 00000000000..8d5bddbb288
--- /dev/null
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -0,0 +1,58 @@
+- public_project_count = ProjectsFinder.new(current_user: current_user).execute.count
+
+.blank-state-row
+ - if current_user.can_create_project?
+ = link_to new_project_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_project", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a project
+ %p.blank-state-text
+ Projects are where you store your code, access issues, wiki and other features of GitLab.
+ - else
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_project", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a project
+ %p.blank-state-text
+ If you are added to a project, it will be displayed here.
+
+ - if current_user.can_create_group?
+ = link_to new_group_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_group", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a group
+ %p.blank-state-text
+ Groups are the best way to manage projects and members.
+
+ - if public_project_count > 0
+ = link_to trending_explore_projects_path, class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("globe", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Explore public projects
+ %p.blank-state-text
+ There are
+ = number_with_delimiter(public_project_count)
+ public projects on this server.
+ Public projects are an easy way to allow
+ everyone to have read-only access.
+
+ = link_to "https://docs.gitlab.com/", class: "blank-state-link" do
+ .blank-state
+ .blank-state-icon
+ = custom_icon("lightbulb", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Learn more about GitLab
+ %p.blank-state-text
+ Take a look at the documentation to discover all of GitLab's capabilities.
diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml
new file mode 100644
index 00000000000..97f854cc5f0
--- /dev/null
+++ b/app/views/dashboard/projects/_nav.html.haml
@@ -0,0 +1,6 @@
+.nav-block
+ %ul.nav-links.mobile-separator
+ = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do
+ = link_to s_('DashboardProjects|All'), dashboard_projects_path
+ = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do
+ = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true)
diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml
index 0ebd7c01bab..9e0e908e656 100644
--- a/app/views/dashboard/projects/_projects.html.haml
+++ b/app/views/dashboard/projects/_projects.html.haml
@@ -1 +1 @@
-= render 'shared/projects/list', projects: @projects, ci: true
+= render 'shared/projects/list', projects: @projects, ci: true, user: current_user
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index 8843d4e7c84..18a82feb189 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,47 +1,13 @@
-- publicish_project_count = ProjectsFinder.new(current_user: current_user).execute.count
-.blank-state.blank-state-welcome
- %h2.blank-state-welcome-title
- Welcome to GitLab
- %p.blank-state-text
- Code, test, and deploy together
-
-- if current_user.can_create_group?
- .blank-state
- .blank-state-icon
- = custom_icon("group", size: 50)
- %h3.blank-state-title
- You can create a group for several dependent projects.
- %p.blank-state-text
- Groups are the best way to manage projects and members.
- = link_to new_group_path, class: "btn btn-new" do
- New group
-
-.blank-state
- .blank-state-icon
- = custom_icon("project", size: 50)
- %h3.blank-state-title
- You don't have access to any projects right now
- %p.blank-state-text
- - if current_user.can_create_project?
- You can create up to
- %strong= number_with_delimiter(current_user.projects_limit)
- = succeed "." do
- = "project".pluralize(current_user.projects_limit)
- - else
- If you are added to a project, it will be displayed here.
- - if current_user.can_create_project?
- = link_to new_project_path, class: "btn btn-new" do
- New project
-
-- if publicish_project_count > 0
- .blank-state
- .blank-state-icon
- = icon("globe")
- %h3.blank-state-title
- There are
- = number_with_delimiter(publicish_project_count)
- public projects on this server.
- %p.blank-state-text
- Public projects are an easy way to allow everyone to have read-only access.
- = link_to trending_explore_projects_path, class: "btn btn-new" do
- Browse projects
+.blank-state-parent-container
+ .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
+ .container.section-body
+ .row
+ .blank-state-welcome
+ %h2.blank-state-welcome-title
+ Welcome to GitLab
+ %p.blank-state-text
+ Code, test, and deploy together
+ - if current_user.admin?
+ = render "blank_state_admin_welcome"
+ - else
+ = render "blank_state_welcome"
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 5e63a61e21b..deed774a4a5 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -1,4 +1,5 @@
- @no_container = true
+- @hide_top_links = true
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
@@ -6,16 +7,11 @@
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
-= render "projects/last_push"
-
%div{ class: container_class }
- - if show_callout?('user_callout_dismissed')
- = render 'shared/user_callout'
-
- - if @projects.any? || params[:name]
+ = render "projects/last_push"
+ - if show_projects?(@projects, params)
= render 'dashboard/projects_head'
-
- - if @projects.any? || params[:name]
+ = render 'nav'
= 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 99efe9c9b86..b1efe59aadc 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -1,14 +1,14 @@
+- @hide_top_links = true
- @no_container = true
-
+- breadcrumb_title "Projects"
- page_title "Starred Projects"
- header_title "Projects", dashboard_projects_path
-= render "projects/last_push"
-
%div{ class: container_class }
+ = render "projects/last_push"
= render 'dashboard/projects_head'
- - if @projects.any? || params[:filter_projects]
+ - if params[:filter_projects] || any_projects?(@projects)
= render 'projects'
- else
%h3 You don't have starred projects yet
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 85cbe0bf0e6..e86b1ab3116 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Snippets"
- header_title "Snippets", dashboard_snippets_path
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 38fd053ae65..efe1fb99efc 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -36,7 +36,7 @@
.todo-body
.todo-note
.md
- = event_note(todo.body, project: todo.project)
+ = first_line_in_markdown(todo, :body, 150, project: todo.project)
- if todo.pending?
.todo-actions
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 52d6ebd8a14..664966989db 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -1,13 +1,14 @@
+- @hide_top_links = true
- page_title "Todos"
- header_title "Todos", dashboard_todos_path
- if current_user.todos.any?
.top-area
- %ul.nav-links
+ %ul.nav-links.mobile-separator
%li.todos-pending{ class: active_when(params[:state].blank? || params[:state] == 'pending') }>
= link_to todos_filter_path(state: 'pending') do
%span
- To do
+ Todos
%span.badge
= number_with_delimiter(todos_pending_count)
%li.todos-done{ class: active_when(params[:state] == 'done') }>
@@ -76,17 +77,18 @@
%ul.content-list.todos-list
= render @todos
= paginate @todos, theme: "gitlab"
- .js-nothing-here-container.todos-all-done.hidden
- = render "shared/empty_states/icons/todos_all_done.svg"
+ .js-nothing-here-container.todos-all-done.hidden.svg-content
+ = image_tag 'illustrations/todos_all_done.svg'
%h4.text-center
You're all done!
- elsif current_user.todos.any?
.todos-all-done
- = render "shared/empty_states/icons/todos_all_done.svg"
+ .svg-content.svg-250
+ = image_tag 'illustrations/todos_all_done.svg'
- if todos_filter_empty?
%h4.text-center
= Gitlab.config.gitlab.no_todos_messages.sample
- %p.text-center
+ %p
Are you looking for things to do? Take a look at
= succeed "," do
= link_to "the opened issues", issues_dashboard_path
@@ -98,11 +100,11 @@
There are no todos to show.
- else
.todos-empty
- .todos-empty-hero
- = render "shared/empty_states/icons/todos_empty.svg"
+ .todos-empty-hero.svg-content
+ = image_tag 'illustrations/todos_empty.svg'
.todos-empty-content
%h4
- Todos let you see what you should do next.
+ Todos let you see what you should do next
%p
When an issue or merge request is assigned to you, or when you
%strong
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index fb70d158096..79826a364db 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -4,9 +4,9 @@
%p.lead.append-bottom-20
Please check your email to confirm your account
%hr
-- if current_application_settings.after_sign_up_text.present?
+- if Gitlab::CurrentSettings.after_sign_up_text.present?
.well-confirmation.text-center
- = markdown_field(current_application_settings, :after_sign_up_text)
+ = markdown_field(Gitlab::CurrentSettings, :after_sign_up_text)
%p.text-center
No confirmation email received? Please check your spam folder or
.append-bottom-20.prepend-top-20.text-center
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
new file mode 100644
index 00000000000..65565b7b8a8
--- /dev/null
+++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
@@ -0,0 +1,16 @@
+- confirmation_link = confirmation_url(@resource, confirmation_token: @token)
+- if @resource.unconfirmed_email.present?
+ #content
+ = email_default_heading(@resource.unconfirmed_email)
+ %p Click the link below to confirm your email address.
+ #cta
+ = link_to 'Confirm your email address', confirmation_link
+- else
+ #content
+ - if Gitlab.com?
+ = email_default_heading('Thanks for signing up to GitLab!')
+ - else
+ = email_default_heading("Welcome, #{@resource.name}!")
+ %p To get started, click the link below to confirm your account.
+ #cta
+ = link_to 'Confirm your account', confirmation_link
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.text.erb b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
new file mode 100644
index 00000000000..01f09aa763d
--- /dev/null
+++ b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
@@ -0,0 +1,14 @@
+<% if @resource.unconfirmed_email.present? %>
+<%= @resource.unconfirmed_email %>,
+
+Use the link below to confirm your email address.
+<% else %>
+ <% if Gitlab.com? %>
+Thanks for signing up to GitLab!
+ <% else %>
+Welcome, <%= @resource.name %>!
+ <% end %>
+To get started, use the link below to confirm your account.
+<% end %>
+
+<%= confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
new file mode 100644
index 00000000000..3d0a1f622a5
--- /dev/null
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
@@ -0,0 +1,8 @@
+#content
+ = email_default_heading("#{@resource.user.name}, you've added an additional email!")
+ %p Click the link below to confirm your email address (#{@resource.email})
+ #cta
+ = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
+ %p
+ If this email was added in error, you can remove it here:
+ = link_to "Emails", profile_emails_url
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
new file mode 100644
index 00000000000..a3b28cb0b84
--- /dev/null
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
@@ -0,0 +1,7 @@
+<%= @resource.user.name %>, you've added an additional email!
+
+Use the link below to confirm your email address (<%= @resource.email %>)
+
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+If this email was added in error, you can remove it here: <%= profile_emails_url %>
diff --git a/app/views/devise/mailer/confirmation_instructions.html.haml b/app/views/devise/mailer/confirmation_instructions.html.haml
index a508b7537a2..50ee7b53d8f 100644
--- a/app/views/devise/mailer/confirmation_instructions.html.haml
+++ b/app/views/devise/mailer/confirmation_instructions.html.haml
@@ -1,15 +1 @@
-- if @resource.unconfirmed_email.present?
- #content
- = email_default_heading(@resource.unconfirmed_email)
- %p Click the link below to confirm your email address.
- #cta
- = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
-- else
- #content
- - if Gitlab.com?
- = email_default_heading('Thanks for signing up to GitLab!')
- - else
- = email_default_heading("Welcome, #{@resource.name}!")
- %p To get started, click the link below to confirm your account.
- #cta
- = link_to 'Confirm your account', confirmation_url(@resource, confirmation_token: @token)
+= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}"
diff --git a/app/views/devise/mailer/confirmation_instructions.text.erb b/app/views/devise/mailer/confirmation_instructions.text.erb
index 9f76edb76a4..05fddddf415 100644
--- a/app/views/devise/mailer/confirmation_instructions.text.erb
+++ b/app/views/devise/mailer/confirmation_instructions.text.erb
@@ -1,9 +1 @@
-Welcome, <%= @resource.name %>!
-
-<% if @resource.unconfirmed_email.present? %>
-You can confirm your email (<%= @resource.unconfirmed_email %>) through the link below:
-<% else %>
-You can confirm your account through the link below:
-<% end %>
-
-<%= confirmation_url(@resource, confirmation_token: @token) %>
+<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %> \ No newline at end of file
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index eb0e6701627..35dafb3e980 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -1,7 +1,7 @@
= render 'devise/shared/tab_single', tab_title:'Change your password'
.login-box
.login-body
- = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
+ = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
.devise-errors
= devise_error_messages!
= f.hidden_field :reset_password_token
@@ -17,5 +17,5 @@
.clearfix.prepend-top-20
%p
%span.light Didn't receive a confirmation email?
- = link_to "Request a new one", new_confirmation_path(resource_name)
+ = link_to "Request a new one", new_confirmation_path(:user)
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 4095f30c369..41462f503cb 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -11,6 +11,6 @@
= f.check_box :remember_me, class: 'remember-me-checkbox'
%span Remember me
.pull-right.forgot-password
- = link_to "Forgot your password?", new_password_path(resource_name)
+ = link_to "Forgot your password?", new_password_path(:user)
.submit-container.move-submit-down
= f.submit "Sign in", class: "btn btn-save"
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index af87129e49e..34d4293bd45 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -6,15 +6,15 @@
- else
= render 'devise/shared/tabs_normal'
.tab-content
- - if signin_enabled? || ldap_enabled? || crowd_enabled?
+ - if password_authentication_enabled_for_web? || ldap_enabled? || crowd_enabled?
= render 'devise/shared/signin_box'
-# Signup only makes sense if you can also sign-in
- - if signin_enabled? && signup_enabled?
+ - if allow_signup?
= render 'devise/shared/signup_box'
-# Show a message if none of the mechanisms above are enabled
- - if !signin_enabled? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
+ - if !password_authentication_enabled_for_web? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
%div
No authentication methods configured.
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index a039756c7e2..6e54b9b5645 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,7 +1,3 @@
-- if inject_u2f_api?
- - content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('u2f')
-
%div
= render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
.login-box
diff --git a/app/views/devise/shared/_links.erb b/app/views/devise/shared/_links.erb
index 49e99e25c1d..cb934434c28 100644
--- a/app/views/devise/shared/_links.erb
+++ b/app/views/devise/shared/_links.erb
@@ -1,19 +1,19 @@
<%- if controller_name != 'sessions' %>
- <%= link_to "Sign in", new_session_path(resource_name), class: "btn" %><br />
+ <%= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: "btn" %><br />
<% end -%>
-<%- if devise_mapping.registerable? && controller_name != 'registrations' && gitlab_config.signup_enabled %>
- <%= link_to "Sign up", new_registration_path(resource_name) %><br />
+<%- if devise_mapping.registerable? && controller_name != 'registrations' && allow_signup? %>
+ <%= link_to "Sign up", new_registration_path(:user) %><br />
<% end -%>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' %>
-<%= link_to "Forgot your password?", new_password_path(resource_name), class: "btn" %><br />
+<%= link_to "Forgot your password?", new_password_path(:user), class: "btn" %><br />
<% end -%>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
- <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
+ <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(:user) %><br />
<% end -%>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
- <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
+ <%= link_to "Didn't receive unlock instructions?", new_unlock_path(:user) %><br />
<% end -%>
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index f92f89e73ff..546cec4d565 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -6,4 +6,9 @@
- providers.each do |provider|
%span.light
- has_icon = provider_has_icon?(provider)
- = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn')
+ = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: 'oauth-login' + (has_icon ? ' oauth-image-link' : ' btn'), id: "oauth-login-#{provider}"
+ %fieldset.prepend-top-10.checkbox.remember-me
+ %label
+ = check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
+ %span
+ Remember me
diff --git a/app/views/devise/shared/_sign_in_link.html.haml b/app/views/devise/shared/_sign_in_link.html.haml
index 289bf40f3de..77ef103cc47 100644
--- a/app/views/devise/shared/_sign_in_link.html.haml
+++ b/app/views/devise/shared/_sign_in_link.html.haml
@@ -1,4 +1,4 @@
%p
%span.light
Already have login and password?
- = link_to "Sign in", new_session_path(resource_name)
+ = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes')
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index da4769e214e..5ddb3ece1cb 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -7,12 +7,12 @@
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && !crowd_enabled?) }
.login-body
= render 'devise/sessions/new_ldap', server: server
- - if signin_enabled?
- .login-box.tab-pane{ id: 'ldap-standard', role: 'tabpanel' }
+ - if password_authentication_enabled_for_web?
+ .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
-- elsif signin_enabled?
+- elsif password_authentication_enabled_for_web?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index d696577278d..2554b2688bb 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -25,10 +25,10 @@
%div
- if Gitlab::Recaptcha.enabled?
= recaptcha_tags
- %div
+ .submit-container
= f.submit "Register", class: "btn-register btn"
.clearfix.submit-container
%p
%span.light Didn't receive a confirmation email?
= succeed '.' do
- = link_to "Request a new one", new_confirmation_path(resource_name)
+ = link_to "Request a new one", new_confirmation_path(:user)
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index dd34600490e..270191f9452 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -5,9 +5,9 @@
- @ldap_servers.each_with_index do |server, i|
%li{ class: active_when(i.zero? && !crowd_enabled?) }
= link_to server['label'], "##{server['provider_name']}", 'data-toggle' => 'tab'
- - if signin_enabled?
+ - if password_authentication_enabled_for_web?
%li
- = link_to 'Standard', '#ldap-standard', 'data-toggle' => 'tab'
- - if signin_enabled? && signup_enabled?
+ = link_to 'Standard', '#login-pane', 'data-toggle' => 'tab'
+ - if allow_signup?
%li
= link_to 'Register', '#register-pane', 'data-toggle' => 'tab'
diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml
index c225d800a98..1ba6d390875 100644
--- a/app/views/devise/shared/_tabs_normal.html.haml
+++ b/app/views/devise/shared/_tabs_normal.html.haml
@@ -1,6 +1,6 @@
%ul.nav-links.new-session-tabs.nav-tabs{ role: 'tablist' }
%li.active{ role: 'presentation' }
%a{ href: '#login-pane', data: { toggle: 'tab' }, role: 'tab' } Sign in
- - if signin_enabled? && signup_enabled?
+ - if allow_signup?
%li{ role: 'presentation' }
%a{ href: '#register-pane', data: { toggle: 'tab' }, role: 'tab' } Register
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index e6d307e5568..4b6c4581eb3 100644
--- a/app/views/discussions/_diff_discussion.html.haml
+++ b/app/views/discussions/_diff_discussion.html.haml
@@ -1,6 +1,10 @@
-- expanded = local_assigns.fetch(:expanded, true)
-%tr.notes_holder{ class: ('hide' unless expanded) }
- %td.notes_line{ colspan: 2 }
- %td.notes_content
- .content{ class: ('hide' unless expanded) }
- = render partial: "discussions/notes", collection: discussions, as: :discussion
+- if local_assigns[:on_image]
+ = render partial: "discussions/notes", collection: discussions, as: :discussion
+- else
+ -# Text diff discussions
+ - expanded = local_assigns.fetch(:expanded, true)
+ %tr.notes_holder{ class: ('hide' unless expanded) }
+ %td.notes_line{ colspan: 2 }
+ %td.notes_content
+ .content{ class: ('hide' unless expanded) }
+ = render partial: "discussions/notes", collection: discussions, as: :discussion, locals: { disable_collapse_class: true }
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 4a41be972da..8680ec2e298 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -1,18 +1,38 @@
- diff_file = discussion.diff_file
- blob = discussion.blob
+- discussions = { discussion.original_line_code => [discussion] }
+- diff_file_class = diff_file.text? ? 'text-file' : 'js-image-file'
+- diff_data = {}
+- expanded = discussion.expanded? || local_assigns.fetch(:expanded, nil)
+- unless expanded
+ - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) }
-.diff-file.file-holder
+.diff-file.file-holder{ class: diff_file_class, data: diff_data }
.js-file-title.file-title.file-title-flex-parent
.file-header-content
= render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false
- .diff-content.code.js-syntax-highlight
- %table
- - discussions = { discussion.original_line_code => [discussion] }
- = render partial: "projects/diffs/line",
- collection: discussion.truncated_diff_lines,
- as: :line,
- locals: { diff_file: diff_file,
- discussions: discussions,
- discussion_expanded: true,
- plain: true }
+ - if diff_file.text?
+ .diff-content.code.js-syntax-highlight
+ %table
+ - if expanded
+ - discussions = { discussion.original_line_code => [discussion] }
+ = render partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: diff_file,
+ discussions: discussions,
+ discussion_expanded: true,
+ plain: true }
+ - else
+ %tr.line_holder.line-holder-placeholder
+ %td.old_line.diff-line-num
+ %td.new_line.diff-line-num
+ %td.line_content
+ .js-code-placeholder
+ = render "discussions/diff_discussion", discussions: [discussion], expanded: true
+ - else
+ - partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff'
+ = render partial: "projects/diffs/#{partial}", locals: { diff_file: diff_file, position: discussion.position.to_json, click_to_comment: false }
+ .note-container
+ = render partial: "discussions/notes", locals: { discussion: discussion, show_toggle: false, show_image_comment_badge: true, disable_collapse_class: true }
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 578e751ab47..e9589213f80 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -3,12 +3,12 @@
.timeline-entry-inner
.timeline-icon
= link_to user_path(discussion.author) do
- = image_tag avatar_icon(discussion.author), class: "avatar s40"
+ = image_tag avatar_icon_for_user(discussion.author), class: "avatar s40"
.timeline-content
.discussion.js-toggle-container{ data: { discussion_id: discussion.id } }
.discussion-header
.discussion-actions
- %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" }
+ %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) }
- if expanded
= icon("chevron-up")
- else
@@ -32,9 +32,17 @@
- elsif discussion.diff_discussion?
on
= conditional_link_to url.present?, url do
- - unless discussion.active?
- an old version of
- the diff
+ - if discussion.on_merge_request_commit?
+ - unless discussion.active?
+ an outdated change in
+ commit
+
+ %span.commit-sha= Commit.truncate_sha(discussion.commit_id)
+ - else
+ - 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
@@ -44,4 +52,4 @@
= render "discussions/diff_with_notes", discussion: discussion
- else
.panel.panel-default
- = render "discussions/notes", discussion: discussion
+ = render partial: "discussions/notes", locals: { discussion: discussion, disable_collapse_class: true }
diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml
index c1dabeed387..b865eb815f0 100644
--- a/app/views/discussions/_headline.html.haml
+++ b/app/views/discussions/_headline.html.haml
@@ -1,11 +1,13 @@
- if discussion.resolved?
.discussion-headline-light.js-discussion-headline
- Resolved
+ = discussion_resolved_intro(discussion)
- if discussion.resolved_by
by
= link_to_member(@project, discussion.resolved_by, avatar: false)
+ - if discussion.resolved_by_push?
+ with a push
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
-- elsif discussion.last_updated_at != discussion.created_at
+- elsif discussion.updated?
.discussion-headline-light.js-discussion-headline
Last updated
- if discussion.last_updated_by
diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml
index ca9e0e8728a..50dd5864195 100644
--- a/app/views/discussions/_new_issue_for_all_discussions.html.haml
+++ b/app/views/discussions/_new_issue_for_all_discussions.html.haml
@@ -1,6 +1,8 @@
- if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project)
.btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" }
- .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve all discussions in new issue",
- "aria-label" => "Resolve all discussions in a new issue",
- "data-container" => "body" }
- = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion'
+ = link_to custom_icon('icon_mr_issue'),
+ new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid),
+ title: 'Resolve all discussions in new issue',
+ aria: { label: 'Resolve all discussions in new issue' },
+ data: { container: 'body' },
+ class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml
index df5546a1e32..2bfe118c608 100644
--- a/app/views/discussions/_new_issue_for_discussion.html.haml
+++ b/app/views/discussions/_new_issue_for_discussion.html.haml
@@ -2,7 +2,9 @@
%new-issue-for-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
"inline-template" => true }
.btn-group{ role: "group", "v-if" => "showButton" }
- .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve this discussion in a new issue",
- "aria-label" => "Resolve this discussion in a new issue",
- "data-container" => "body" }
- = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion'
+ = link_to custom_icon('icon_mr_issue'),
+ new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id),
+ title: 'Resolve this discussion in a new issue',
+ aria: { label: 'Resolve this discussion in a new issue' },
+ data: { container: 'body' },
+ class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index db5ab939948..1cc227428e9 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,6 +1,19 @@
-.discussion-notes
- %ul.notes{ data: { discussion_id: discussion.id } }
- = render partial: "shared/notes/note", collection: discussion.notes, as: :note
+- disable_collapse_class = local_assigns.fetch(:disable_collapse_class, false)
+- collapsed_class = 'collapsed' if discussion.resolved? && !disable_collapse_class
+- badge_counter = discussion_counter + 1 if local_assigns[:discussion_counter]
+- show_toggle = local_assigns.fetch(:show_toggle, true)
+- show_image_comment_badge = local_assigns.fetch(:show_image_comment_badge, false)
+
+.discussion-notes{ class: collapsed_class }
+ -# Save the first note position data so that we have a reference and can go
+ -# to the first note position when we click on a badge diff discussion
+ %ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } }
+ - if discussion.try(:on_image?) && show_toggle
+ %button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
+ = sprite_icon('collapse', css_class: 'collapse-icon')
+ %button.btn-transparent.badge.js-diff-notes-toggle{ type: 'button' }
+ = badge_counter
+ = render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge }
.flash-container
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index 253cd336882..079d9083dff 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -4,7 +4,7 @@
%td.notes_line.old
%td.notes_content.parallel.old
.content{ class: ('hide' unless discussions_left.any?(&:expanded?)) }
- = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old'
+ = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old', locals: { disable_collapse_class: true }
- else
%td.notes_line.old= ("")
%td.notes_content.parallel.old
@@ -14,7 +14,7 @@
%td.notes_line.new
%td.notes_content.parallel.new
.content{ class: ('hide' unless discussions_right.any?(&:expanded?)) }
- = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new'
+ = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new', locals: { disable_collapse_class: true }
- else
%td.notes_line.new= ("")
%td.notes_content.parallel.new
diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml
index b3313c7c985..cf0e0de1ca4 100644
--- a/app/views/doorkeeper/applications/_form.html.haml
+++ b/app/views/doorkeeper/applications/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f|
+= form_for application, url: doorkeeper_submit_path(application), html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
= form_errors(application)
.form-group
diff --git a/app/views/doorkeeper/applications/edit.html.haml b/app/views/doorkeeper/applications/edit.html.haml
index fb6aa30acee..49f90298a50 100644
--- a/app/views/doorkeeper/applications/edit.html.haml
+++ b/app/views/doorkeeper/applications/edit.html.haml
@@ -1,3 +1,4 @@
- page_title "Edit", @application.name, "Applications"
+- @content_class = "limit-container-width" unless fluid_layout
%h3.page-title Edit application
= render 'form', application: @application
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index aa271150b07..d1237d7bf6f 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -1,7 +1,8 @@
- page_title "Applications"
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
@@ -10,7 +11,7 @@
and applications that you've authorized to use your account.
- else
Manage applications that you've authorized to use your account.
- .col-lg-9
+ .col-lg-8
- if user_oauth_applications?
%h5.prepend-top-0
Add new application
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 559de63d96d..6364f0be4a3 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -1,4 +1,8 @@
+- add_to_breadcrumbs "Applications", oauth_applications_path
+- breadcrumb_title @application.name
- page_title @application.name, "Applications"
+- @content_class = "limit-container-width" unless fluid_layout
+
%h3.page-title
Application: #{@application.name}
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 82aa51f9778..6d9c6b5572a 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -1,39 +1,55 @@
-%h3.page-title Authorization required
%main{ :role => "main" }
- %p.h4
- Authorize
- %strong.text-info= @pre_auth.client.name
- to use your account?
+ .modal-no-backdrop.modal-doorkeepr-auth
+ .modal-content
+ .modal-header
+ %h3.page-title
+ Authorize
+ = link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer'
+ to use your account?
- - if current_user.admin?
- .text-warning.prepend-top-20
- %p
- = icon("exclamation-triangle fw")
- You are an admin, which means granting access to
- %strong= @pre_auth.client.name
- will allow them to interact with GitLab as an admin as well. Proceed with caution.
+ .modal-body
+ - if current_user.admin?
+ .text-warning
+ %p
+ = icon("exclamation-triangle fw")
+ You are an admin, which means granting access to
+ %strong= @pre_auth.client.name
+ will allow them to interact with GitLab as an admin as well. Proceed with caution.
+ %p
+ An application called
+ = link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer'
+ is requesting access to your GitLab account.
- - if @pre_auth.scopes
- #oauth-permissions
- %p This application will be able to:
- %ul.text-info
- - @pre_auth.scopes.each do |scope|
- %li= t scope, scope: [:doorkeeper, :scopes]
- %hr/
- .actions
- = form_tag oauth_authorization_path, method: :post do
- = hidden_field_tag :client_id, @pre_auth.client.uid
- = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
- = hidden_field_tag :state, @pre_auth.state
- = hidden_field_tag :response_type, @pre_auth.response_type
- = hidden_field_tag :scope, @pre_auth.scope
- = hidden_field_tag :nonce, @pre_auth.nonce
- = submit_tag "Authorize", class: "btn btn-success wide pull-left"
- = form_tag oauth_authorization_path, method: :delete do
- = hidden_field_tag :client_id, @pre_auth.client.uid
- = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
- = hidden_field_tag :state, @pre_auth.state
- = hidden_field_tag :response_type, @pre_auth.response_type
- = hidden_field_tag :scope, @pre_auth.scope
- = hidden_field_tag :nonce, @pre_auth.nonce
- = submit_tag "Deny", class: "btn btn-danger prepend-left-10"
+ - auth_app_owner = @pre_auth.client.application.owner
+ - if auth_app_owner
+ This application was created by
+ = succeed "." do
+ = link_to auth_app_owner.name, user_path(auth_app_owner)
+
+ Please note that this application is not provided by GitLab and you should verify its authenticity before
+ allowing access.
+ - if @pre_auth.scopes
+ %p
+ This application will be able to:
+ %ul
+ - @pre_auth.scopes.each do |scope|
+ %li
+ %strong= t scope, scope: [:doorkeeper, :scopes]
+ .scope-description= t scope, scope: [:doorkeeper, :scope_desc]
+ .form-actions.text-right
+ = form_tag oauth_authorization_path, method: :delete, class: 'inline' do
+ = hidden_field_tag :client_id, @pre_auth.client.uid
+ = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
+ = hidden_field_tag :state, @pre_auth.state
+ = hidden_field_tag :response_type, @pre_auth.response_type
+ = hidden_field_tag :scope, @pre_auth.scope
+ = hidden_field_tag :nonce, @pre_auth.nonce
+ = submit_tag "Deny", class: "btn btn-danger"
+ = form_tag oauth_authorization_path, method: :post, class: 'inline' do
+ = hidden_field_tag :client_id, @pre_auth.client.uid
+ = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
+ = hidden_field_tag :state, @pre_auth.state
+ = hidden_field_tag :response_type, @pre_auth.response_type
+ = hidden_field_tag :scope, @pre_auth.scope
+ = hidden_field_tag :nonce, @pre_auth.nonce
+ = submit_tag "Authorize", class: "btn btn-success prepend-left-10"
diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml
index a97cbd4d4b3..bf540439c79 100644
--- a/app/views/errors/access_denied.html.haml
+++ b/app/views/errors/access_denied.html.haml
@@ -1,3 +1,5 @@
+- message = local_assigns.fetch(:message)
+
- content_for(:title, 'Access Denied')
%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') }
%h1
@@ -5,5 +7,9 @@
.container
%h3 Access Denied
%hr
- %p You are not allowed to access this page.
- %p Read more about project permissions #{link_to "here", help_page_path("user/permissions"), class: "vlink"}
+ - if message
+ %p
+ = message
+ - else
+ %p You are not allowed to access this page.
+ %p Read more about project permissions #{link_to "here", help_page_path("user/permissions"), class: "vlink"}
diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml
index 20b7fa471a0..a2a4c75daad 100644
--- a/app/views/errors/omniauth_error.html.haml
+++ b/app/views/errors/omniauth_error.html.haml
@@ -9,7 +9,7 @@
%p Try logging in using your username or email. If you have forgotten your password, try recovering it
= link_to "Sign in", new_session_path(:user), class: 'btn primary'
- = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary'
+ = link_to "Recover password", new_password_path(:user), class: 'btn secondary'
%hr
%p.light If none of the options work, try contacting a GitLab administrator.
diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml
index 3c64f1be5ff..98cdcca3ecc 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-sha", alt: '', title: truncate_sha(commit[:id])
+ = link_to truncate_sha(event.commit_id), project_commit_path(project, event.commit_id), class: "commit-sha", alt: '', title: truncate_sha(event.commit_id)
&middot;
- = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line, author: event.author
+ = markdown event_commit_title(event.commit_title), project: project, pipeline: :single_line, author: event.author
diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder
index e2aec532a9d..d56234e6c1a 100644
--- a/app/views/events/_event.atom.builder
+++ b/app/views/events/_event.atom.builder
@@ -5,7 +5,12 @@ xml.entry do
xml.link href: event_feed_url(event)
xml.title truncate(event_feed_title(event), length: 80)
xml.updated event.updated_at.xmlschema
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author_email))
+
+ # We're deliberately re-using "event.author" here since this data is
+ # eager-loaded. This allows us to re-use the user object's Email address,
+ # instead of having to run additional queries to figure out what Email to use
+ # for the avatar.
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(event.author))
xml.author do
xml.username event.author_username
diff --git a/app/views/events/_event_note.atom.haml b/app/views/events/_event_note.atom.haml
index 6fa2f9bd4db..7e264eb5575 100644
--- a/app/views/events/_event_note.atom.haml
+++ b/app/views/events/_event_note.atom.haml
@@ -1,2 +1,2 @@
%div{ xmlns: "http://www.w3.org/1999/xhtml" }
- = markdown(note.note, pipeline: :atom, project: note.project, author: note.author)
+ = markdown_field(note, :note)
diff --git a/app/views/events/_event_push.atom.haml b/app/views/events/_event_push.atom.haml
index f8f0bcb7608..e3c5fd55f08 100644
--- a/app/views/events/_event_push.atom.haml
+++ b/app/views/events/_event_push.atom.haml
@@ -1,14 +1,14 @@
%div{ xmlns: "http://www.w3.org/1999/xhtml" }
- - event.commits.first(15).each do |commit|
- %p
- %strong= commit[:author][:name]
- = link_to "(##{truncate_sha(commit[:id])})", namespace_project_commit_path(event.project.namespace, event.project, id: commit[:id])
- %i
- at
- = commit[:timestamp].to_time.to_s(:short)
- %blockquote= markdown(escape_once(commit[:message]), pipeline: :atom, project: event.project, author: event.author)
- - if event.commits_count > 15
- %p
- %i
- \... and
- = pluralize(event.commits_count - 15, "more commit")
+ %p
+ %strong= event.author_name
+ = link_to "(#{truncate_sha(event.commit_id)})", project_commit_path(event.project, event.commit_id)
+ %i
+ at
+ = event.created_at.to_s(:short)
+ - unless event.rm_ref?
+ %blockquote= markdown(escape_once(event.commit_title), pipeline: :atom, project: event.project, author: event.author)
+ - if event.commits_count > 1
+ %p
+ %i
+ \... and
+ = pluralize(event.commits_count - 1, "more commit")
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index df4b9562215..de6383e4097 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -10,7 +10,7 @@
.event-body
.event-note
.md
- = event_note(event.target.note, project: event.project)
+ = first_line_in_markdown(event.target, :note, 150, project: event.project)
- note = event.target
- if note.attachment.url
- if note.attachment.image?
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 769ac655d0a..f85f5c5be88 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -6,23 +6,21 @@
%span.author_name= link_to_author event
%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, class: 'ref-name'
+ - commits_link = project_commits_path(project, event.ref_name)
+ - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name)
+ = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name'
= render "events/event_scope", event: event
- if event.push_with_commits?
.event-body
%ul.well-list.event_commits
- - few_commits = event.commits[0...2]
- - few_commits.each do |commit|
- = render "events/commit", commit: commit, project: project, event: event
+ = render "events/commit", project: project, event: event
- create_mr = event.new_ref? && create_mr_button?(project.default_branch, event.ref_name, project) && event.authored_by?(current_user)
- if event.commits_count > 1
%li.commits-stat
- - if event.commits_count > 2
- %span ... and #{event.commits_count - 2} more commits.
+ %span ... and #{pluralize(event.commits_count - 1, 'more commit')}.
- if event.md_ref?
- from = event.commit_from
@@ -31,7 +29,7 @@
- from = project.default_branch
- from_label = from
- = link_to namespace_project_compare_path(project.namespace, project, from: from, to: event.commit_to) do
+ = link_to project_compare_path(project, from: from, to: event.commit_to) do
Compare #{from_label}...#{truncate_sha(event.commit_to)}
- if create_mr
@@ -43,10 +41,3 @@
%li.commits-stat
= link_to create_mr_path(project.default_branch, event.ref_name, project) do
Create Merge Request
-- elsif event.rm_ref?
- - repository = project.repository
- - last_commit = repository.commit(event.commit_from)
- - if last_commit
- .event-body
- %ul.well-list.event_commits
- = render "events/commit", commit: last_commit, project: project, event: event
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index 794c6d1d170..ff57b39e947 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,6 +1,4 @@
.js-groups-list-holder
- %ul.content-list
- - @groups.each do |group|
- = render 'shared/groups/group', group: group
-
- = paginate @groups, theme: 'gitlab'
+ #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index ffe07b217a7..efa8b2706da 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
@@ -16,7 +17,7 @@
%p Below you will find all the groups that are public.
%p You can easily contribute to them by requesting to join these groups.
-- if @groups.present?
- = render 'groups'
-- else
+- if params[:filter].blank? && @groups.empty?
.nothing-here-block No public groups
+- else
+ = render 'groups'
diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml
index 708fbc27f55..67f2f897137 100644
--- a/app/views/explore/projects/_projects.html.haml
+++ b/app/views/explore/projects/_projects.html.haml
@@ -1 +1 @@
-= render 'shared/projects/list', projects: projects
+= render 'shared/projects/list', projects: projects, user: current_user
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index ec461755103..f00802e0af7 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index ec461755103..f00802e0af7 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index ec461755103..f00802e0af7 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml
index e5706d04736..94fc4ac21d2 100644
--- a/app/views/explore/snippets/index.html.haml
+++ b/app/views/explore/snippets/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Snippets"
- header_title "Snippets", snippets_path
diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml
new file mode 100644
index 00000000000..742b40784d3
--- /dev/null
+++ b/app/views/groups/_children.html.haml
@@ -0,0 +1,4 @@
+.js-groups-list-holder
+ #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/groups/_head.html.haml b/app/views/groups/_head.html.haml
deleted file mode 100644
index 0f63774fb9b..00000000000
--- a/app/views/groups/_head.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: container_class }
- = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to group_path(@group), title: 'Group Home' do
- %span
- Home
-
- = nav_link(path: 'groups#activity') do
- = link_to activity_group_path(@group), title: 'Activity' do
- %span
- Activity
-
-.hidden-xs
- = render "projects/last_push"
diff --git a/app/views/groups/_head_issues.html.haml b/app/views/groups/_head_issues.html.haml
deleted file mode 100644
index d554bc23743..00000000000
--- a/app/views/groups/_head_issues.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: container_class }
- = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
- = link_to issues_group_path(@group), title: 'List' do
- %span
- List
-
- = nav_link(path: 'labels#index') do
- = link_to group_labels_path(@group), title: 'Labels' do
- %span
- Labels
-
- = nav_link(path: 'milestones#index') do
- = link_to group_milestones_path(@group), title: 'Milestones' do
- %span
- Milestones
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 181c7bee702..a0760c2073b 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -1,7 +1,7 @@
.group-home-panel.text-center
%div{ class: container_class }
.avatar-container.s70.group-avatar
- = image_tag group_icon(@group), class: "avatar s70 avatar-tile"
+ = group_icon(@group, class: "avatar s70 avatar-tile")
%h1.group-title
= @group.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
diff --git a/app/views/groups/_settings_head.html.haml b/app/views/groups/_settings_head.html.haml
deleted file mode 100644
index 2454e7355a7..00000000000
--- a/app/views/groups/_settings_head.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: container_class }
- = nav_link(path: 'groups#edit') do
- = link_to edit_group_path(@group), title: 'General' do
- %span
- General
-
- = nav_link(path: 'groups#projects') do
- = link_to projects_group_path(@group), title: 'Projects' do
- %span
- Projects
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
deleted file mode 100644
index b1694c919d0..00000000000
--- a/app/views/groups/_shared_projects.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'shared/projects/list', projects: projects, stars: false, skip_namespace: false
diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml
deleted file mode 100644
index 35b75bc0923..00000000000
--- a/app/views/groups/_show_nav.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%ul.nav-links
- = nav_link(page: group_path(@group)) do
- = link_to group_path(@group) do
- Projects
- - if Group.supports_nested_groups?
- = nav_link(page: subgroups_group_path(@group)) do
- = link_to subgroups_group_path(@group) do
- Subgroups
diff --git a/app/views/groups/activity.html.haml b/app/views/groups/activity.html.haml
index 3969e56f937..cb7dab26332 100644
--- a/app/views/groups/activity.html.haml
+++ b/app/views/groups/activity.html.haml
@@ -2,7 +2,6 @@
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
- page_title "Activity"
-= render 'groups/head'
%section.activities
= render 'activities'
diff --git a/app/views/groups/boards/index.html.haml b/app/views/groups/boards/index.html.haml
new file mode 100644
index 00000000000..bb56769bd3f
--- /dev/null
+++ b/app/views/groups/boards/index.html.haml
@@ -0,0 +1 @@
+= render "shared/boards/show", board: @boards.first
diff --git a/app/views/groups/boards/show.html.haml b/app/views/groups/boards/show.html.haml
new file mode 100644
index 00000000000..92838fa4b11
--- /dev/null
+++ b/app/views/groups/boards/show.html.haml
@@ -0,0 +1 @@
+= render "shared/boards/show", board: @board, group: true
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 7d5add3cc1c..86cd0759a2c 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,4 +1,4 @@
-= render "groups/settings_head"
+- breadcrumb_title "General Settings"
.panel.panel-default.prepend-top-default
.panel-heading
Group settings
@@ -10,16 +10,16 @@
.form-group
.col-sm-offset-2.col-sm-10
.avatar-container.s160
- = image_tag group_icon(@group), alt: '', class: 'avatar group-avatar s160'
+ = group_icon(@group, alt: '', class: 'avatar group-avatar s160')
%p.light
- if @group.avatar?
- You can change your group avatar here
+ You can change the group avatar here
- else
You can upload a group avatar here
= render 'shared/choose_group_avatar_button', f: f
- if @group.avatar?
%hr
- = link_to 'Remove avatar', group_avatar_path(@group.to_param), data: { confirm: "Group avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar"
+ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _("Avatar will be removed. Are you sure?")}, method: :delete, class: "btn btn-danger btn-inverted"
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
@@ -27,17 +27,20 @@
.col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f
- = render 'group_admin_settings', f: f
-
.form-group
- %hr
- = f.label :share_with_group_lock, class: 'control-label' do
- Share with group lock
+ %label.control-label
+ = s_("GroupSettings|Share with group lock")
.col-sm-10
.checkbox
- = f.check_box :share_with_group_lock
- %span.descr Prevent sharing a project with another group within this group
+ = f.label :share_with_group_lock do
+ = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group)
+ %strong
+ - group_link = link_to @group.name, group_path(@group)
+ = s_("GroupSettings|Prevent sharing a project within %{group} with other groups").html_safe % { group: group_link }
+ %br
+ %span.descr= share_with_group_lock_help_text(@group)
+ = render 'group_admin_settings', f: f
.form-actions
= f.submit 'Save group', class: "btn btn-save"
@@ -45,10 +48,29 @@
.panel.panel-danger
.panel-heading Remove group
.panel-body
- %p
- Removing group will cause all child projects and resources to be removed.
- %br
- %strong Removed group can not be restored!
+ = form_tag(@group, method: :delete) do
+ %p
+ Removing group will cause all child projects and resources to be removed.
+ %br
+ %strong Removed group can not be restored!
+
+ .form-actions
+ = button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) }
+
+- if supports_nested_groups?
+ .panel.panel-warning
+ .panel-heading Transfer group
+ .panel-body
+ = form_for @group, url: transfer_group_path(@group), method: :put do |f|
+ .form-group
+ = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: "Search groups", data: { data: parent_group_options(@group) } })
+ = hidden_field_tag 'new_parent_group_id'
+
+ %ul
+ %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}.
+ %li You can only transfer the group to a group you manage.
+ %li You will need to update your local repositories to point to the new location.
+ %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.
+ = f.submit 'Transfer group', class: "btn btn-warning"
- .form-actions
- = link_to 'Remove group', @group, data: {confirm: 'Removed group can not be restored! Are you sure?'}, method: :delete, class: "btn btn-remove"
+= render 'shared/confirm_modal', phrase: @group.path
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 2e4e4511bb6..ad9d5562ded 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -27,6 +27,6 @@
Members with access to
%strong= @group.name
%span.badge= @members.total_count
- %ul.content-list
+ %ul.content-list.members-list
= render partial: 'shared/members/member', collection: @members, as: :member
= paginate @members, theme: 'gitlab'
diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml
deleted file mode 100644
index 9d05bff6c4e..00000000000
--- a/app/views/groups/group_members/update.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/member', member: @group_member))}');
- $("##{dom_id(@group_member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(@group_member)}"));
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 182dbe2f98a..36df03302e8 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,9 +1,10 @@
- page_title "Issues"
-= render "head_issues"
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues")
-- if group_issues(@group).exists?
+- if group_issues_count(state: 'all').zero?
+ = render 'shared/empty_states/issues', project_select_button: true
+- else
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
@@ -11,17 +12,8 @@
= icon('rss')
%span.icon-label
Subscribe
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
-
- = render 'shared/issuable/filter', type: :issues
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues
- .row-content-block.second-block
- Only issues from the
- %strong= @group.name
- group are listed here.
- - if current_user
- To see all issues you should visit #{link_to 'dashboard', issues_dashboard_path} page.
+ = render 'shared/issuable/search_bar', type: :issues
= render 'shared/issues'
-- else
- = render 'shared/empty_states/issues', project_select_button: true
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 2bc00fb16c8..ac7e12fcd0b 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,14 +1,14 @@
- page_title 'Labels'
-= render "groups/head_issues"
+
+- issuables = ['issues', 'merge requests']
.top-area.adjust
.nav-text
- Labels can be applied to issues and merge requests. Group labels are available for any project within the group.
+ = _("Labels can be applied to %{features}. Group labels are available for any project within the group.") % { features: issuables.to_sentence }
.nav-controls
- if can?(current_user, :admin_label, @group)
- = link_to new_group_label_path(@group), class: "btn btn-new" do
- New label
+ = link_to "New label", new_group_label_path(@group), class: "btn btn-new"
.labels
.other-labels
@@ -18,4 +18,4 @@
= paginate @labels, theme: 'gitlab'
- else
.nothing-here-block
- No labels created yet.
+ = _("No labels created yet.")
diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml
index 2be87460b1d..538c353cf2d 100644
--- a/app/views/groups/labels/new.html.haml
+++ b/app/views/groups/labels/new.html.haml
@@ -1,5 +1,5 @@
+- breadcrumb_title "Labels"
- page_title 'New Label'
-- header_title group_title(@group, 'Labels', group_labels_path(@group))
%h3.page-title
New Label
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 45e39252e16..4ccd16f3e11 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -1,21 +1,14 @@
- page_title "Merge Requests"
-- if @group_merge_requests.empty?
+- if group_merge_requests_count(state: 'all').zero?
= render 'shared/empty_states/merge_requests', project_select_button: true
- else
.top-area
= render 'shared/issuable/nav', type: :merge_requests
- if current_user
.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", type: :merge_requests
- = render 'shared/issuable/filter', type: :merge_requests
-
- .row-content-block.second-block
- Only merge requests from
- %strong= @group.name
- group are listed here.
- - if current_user
- To see all merge requests you should visit #{link_to 'dashboard', merge_requests_dashboard_path} page.
+ = render 'shared/issuable/search_bar', type: :merge_requests
= render 'shared/merge_requests'
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
new file mode 100644
index 00000000000..a1be0d3220a
--- /dev/null
+++ b/app/views/groups/milestones/_form.html.haml
@@ -0,0 +1,27 @@
+= form_for [@group, @milestone], html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+ .row
+ = form_errors(@milestone)
+
+ .col-md-6
+ .form-group
+ = f.label :title, "Title", class: "control-label"
+ .col-sm-10
+ = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
+ .form-group.milestone-description
+ = f.label :description, "Description", class: "control-label"
+ .col-sm-10
+ = render layout: 'projects/md_preview', locals: { url: group_preview_markdown_path } do
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false
+ .clearfix
+ .error-alert
+
+ = render "shared/milestones/form_dates", f: f
+
+ .form-actions
+ - if @milestone.new_record?
+ = f.submit 'Create milestone', class: "btn-create btn"
+ = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+ - else
+ = f.submit 'Update milestone', class: "btn-create btn"
+ = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"
+
diff --git a/app/views/groups/milestones/_header_title.html.haml b/app/views/groups/milestones/_header_title.html.haml
index d7fabf53587..24eb39b8e2f 100644
--- a/app/views/groups/milestones/_header_title.html.haml
+++ b/app/views/groups/milestones/_header_title.html.haml
@@ -1 +1,2 @@
-- header_title group_title(@group, "Milestones", group_milestones_path(@group))
+- breadcrumb_title @milestone.title
+- add_to_breadcrumbs "Milestones", group_milestones_path(@group)
diff --git a/app/views/groups/milestones/_milestone.html.haml b/app/views/groups/milestones/_milestone.html.haml
index 4c4e0a26728..bae8997e24c 100644
--- a/app/views/groups/milestones/_milestone.html.haml
+++ b/app/views/groups/milestones/_milestone.html.haml
@@ -1,5 +1,6 @@
+
= render 'shared/milestones/milestone',
- milestone_path: group_milestone_path(@group, milestone.safe_title, title: milestone.title),
+ milestone_path: group_milestone_route(milestone),
issues_path: issues_group_path(@group, milestone_title: milestone.title),
merge_requests_path: merge_requests_group_path(@group, milestone_title: milestone.title),
milestone: milestone
diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml
new file mode 100644
index 00000000000..5f6d7d209d0
--- /dev/null
+++ b/app/views/groups/milestones/edit.html.haml
@@ -0,0 +1,7 @@
+- page_title "Milestones"
+- render "header_title"
+
+%h3.page-title
+ Edit Milestone
+
+= render "form"
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index f91bee0b610..f5f621507b8 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,18 +1,12 @@
- page_title "Milestones"
-= render "groups/head_issues"
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
.nav-controls
+ = render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestones, @group)
- = link_to new_group_milestone_path(@group), class: "btn btn-new" do
- New milestone
-
-.row-content-block
- Only milestones from
- %strong= @group.name
- group are listed here.
+ = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-new"
.milestones
%ul.content-list
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index 7c7573862d0..d758e314d41 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -1,43 +1,7 @@
+- breadcrumb_title "Milestones"
- page_title "Milestones"
-- header_title group_title(@group, "Milestones", group_milestones_path(@group))
%h3.page-title
New Milestone
-%p.light
- This will create milestone in every selected project
-%hr
-
-= form_for @milestone, url: group_milestones_path(@group), html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
- .row
- - if @milestone.errors.any?
- #error_explanation
- .alert.alert-danger
- %ul
- - @milestone.errors.full_messages.each do |msg|
- %li
- = msg
-
- .col-md-6
- .form-group
- = f.label :title, "Title", class: "control-label"
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
- .form-group.milestone-description
- = f.label :description, "Description", class: "control-label"
- .col-sm-10
- = render layout: 'projects/md_preview', locals: { url: '' } do
- = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
- .clearfix
- .error-alert
- .form-group
- = f.label :projects, "Projects", class: "control-label"
- .col-sm-10
- = f.collection_select :project_ids, @group.projects.non_archived, :id, :name,
- { selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2'
-
- = render "shared/milestones/form_dates", f: f
-
- .form-actions
- = f.submit 'Create milestone', class: "btn-create btn"
- = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+= render "form"
diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml
index 33e68bc766e..23b1a22240f 100644
--- a/app/views/groups/milestones/show.html.haml
+++ b/app/views/groups/milestones/show.html.haml
@@ -1,4 +1,4 @@
= render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group
-= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true
+= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.legacy_group_milestone?
= render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 000c7af2326..e9daac95ca1 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -1,3 +1,6 @@
+- @breadcrumb_link = dashboard_groups_path
+- breadcrumb_title "Groups"
+- @hide_top_links = true
- page_title 'New Group'
- header_title "Groups", dashboard_groups_path
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 62ad47972b9..ef181b425bc 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -1,4 +1,4 @@
-= render "groups/settings_head"
+- breadcrumb_title "Projects"
.panel.panel-default.prepend-top-default
.panel-heading
@@ -14,14 +14,14 @@
.list-item-name
%span{ class: visibility_level_color(project.visibility_level) }
= visibility_level_icon(project.visibility_level)
- %strong= link_to project.name_with_namespace, project
+ %strong= link_to project.full_name, project
.pull-right
- if project.archived
%span.label.label-warning archived
%span.badge
= storage_counter(project.statistics.storage_size)
- = link_to 'Members', namespace_project_project_members_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
- = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
+ = link_to 'Members', project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
+ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
= link_to 'Remove', project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-sm btn-remove"
- if @projects.blank?
.nothing-here-block This group has no projects yet
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
new file mode 100644
index 00000000000..dd82922ec55
--- /dev/null
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -0,0 +1,11 @@
+- breadcrumb_title "CI / CD Settings"
+- page_title "CI / CD"
+
+%h4
+ = _('Secret variables')
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer'
+
+%p
+ = render "ci/variables/content"
+
+= render 'ci/variables/index', save_endpoint: group_variables_path
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 80a8ba4a755..7f9486d08d9 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,19 +1,45 @@
- @no_container = true
+- breadcrumb_title "Details"
+- can_create_subgroups = can?(current_user, :create_subgroup, @group)
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
-= render 'groups/head'
= render 'groups/home_panel'
.groups-header{ class: container_class }
- .top-area
- = render 'groups/show_nav'
- .nav-controls
- = render 'shared/projects/search_form'
- = render 'shared/projects/dropdown'
+ .group-nav-container
+ .nav-controls.clearfix
+ = render "shared/groups/search_form"
+ = render "shared/groups/dropdown", show_archive_options: true
- if can? current_user, :create_projects, @group
- = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
- New Project
+ - new_project_label = _("New project")
+ - new_subgroup_label = _("New subgroup")
+ - if can_create_subgroups
+ .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+ %input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
+ %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
+ = icon("caret-down", class: "dropdown-btn-icon")
+ %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+ %li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_project_label
+ %span= s_("GroupsTree|Create a project in this group.")
+ %li.divider.droplap-item-ignore
+ %li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_subgroup_label
+ %span= s_("GroupsTree|Create a subgroup in this group.")
+ - else
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
- = render "projects", projects: @projects
+ - if params[:filter].blank? && !@has_children
+ = render "shared/groups/empty_state"
+ - else
+ = render "children", children: @children, group: @group
diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml
deleted file mode 100644
index 8f0724c0677..00000000000
--- a/app/views/groups/subgroups.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- @no_container = true
-
-= render 'head'
-= render 'groups/home_panel'
-
-.groups-header{ class: container_class }
- .top-area
- = render 'groups/show_nav'
- .nav-controls
- = form_tag request.path, method: :get do |f|
- = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
- - if can?(current_user, :create_subgroup, @group)
- = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
- New Subgroup
-
- - if @nested_groups.present?
- %ul.content-list
- = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false }
- - else
- .nothing-here-block
- There are no subgroups to show.
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 331d1181220..29b23ae2e52 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -17,20 +17,17 @@
%th Global Shortcuts
%tr
%td.shortcut
- .key n
- %td Main Navigation
- %tr
- %td.shortcut
.key s
%td Focus Search
%tr
%td.shortcut
.key f
%td Focus Filter
- %tr
- %td.shortcut
- .key p b
- %td Show/hide the Performance Bar
+ - if performance_bar_enabled?
+ %tr
+ %td.shortcut
+ .key p b
+ %td Show/hide the Performance Bar
%tr
%td.shortcut
.key ?
@@ -120,7 +117,7 @@
.key g
.key p
%td
- Go to the project's home page
+ Go to the project's overview page
%tr
%td.shortcut
.key g
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index c25eae63eec..bf2725dc328 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -1,16 +1,18 @@
%div
-- if current_application_settings.help_page_text.present?
- = markdown_field(current_application_settings, :help_page_text)
+- if Gitlab::CurrentSettings.help_page_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text)
%hr
-- unless current_application_settings.help_page_hide_commercial_content?
- %h1
- GitLab
- Community Edition
- - if user_signed_in?
- %span= Gitlab::VERSION
- %small= link_to Gitlab::REVISION, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', 'gitlab-ce', Gitlab::REVISION)
- = version_status_badge
+%h1
+ GitLab
+ Community Edition
+ - if user_signed_in?
+ %span= Gitlab::VERSION
+ %small= link_to Gitlab::REVISION, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', 'gitlab-ce', Gitlab::REVISION)
+ = version_status_badge
+ %hr
+
+- unless Gitlab::CurrentSettings.help_page_hide_commercial_content?
%p.slead
GitLab is open source software to collaborate on code.
%br
@@ -23,11 +25,12 @@
Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises.
%br
Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}.
+ %p= link_to 'Check the current instance configuration ', help_instance_configuration_url
%hr
.row.prepend-top-default
.col-md-8
- .documentation-index
+ .documentation-index.wiki
= markdown(@help_index)
.col-md-4
.panel.panel-default
@@ -35,8 +38,12 @@
Quick help
%ul.well-list
%li= link_to 'See our website for getting help', support_url
- %li= link_to 'Use the search bar on the top of this page', '#', onclick: 'Shortcuts.focusSearch(event)'
- %li= link_to 'Use shortcuts', '#', onclick: 'Shortcuts.toggleHelp()'
- - unless current_application_settings.help_page_hide_commercial_content?
+ %li
+ %button.btn-blank.btn-link.js-trigger-search-bar{ type: 'button' }
+ Use the search bar on the top of this page
+ %li
+ %button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' }
+ Use shortcuts
+ - unless Gitlab::CurrentSettings.help_page_hide_commercial_content?
%li= link_to 'Get a support subscription', 'https://about.gitlab.com/pricing/'
%li= link_to 'Compare GitLab editions', 'https://about.gitlab.com/features/#compare'
diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml
new file mode 100644
index 00000000000..f09e3825a4b
--- /dev/null
+++ b/app/views/help/instance_configuration.html.haml
@@ -0,0 +1,17 @@
+- page_title 'Instance Configuration'
+.wiki.documentation
+ %h1 Instance Configuration
+
+ %p
+ In this page you will find information about the settings that are used in your current instance.
+
+ = render 'help/instance_configuration/ssh_info'
+ = render 'help/instance_configuration/gitlab_pages'
+ = render 'help/instance_configuration/gitlab_ci'
+ %p
+ %strong Table of contents
+
+ %ul
+ = content_for :table_content
+
+ = content_for :settings_content
diff --git a/app/views/help/instance_configuration/_gitlab_ci.html.haml b/app/views/help/instance_configuration/_gitlab_ci.html.haml
new file mode 100644
index 00000000000..7fa8bd086d4
--- /dev/null
+++ b/app/views/help/instance_configuration/_gitlab_ci.html.haml
@@ -0,0 +1,24 @@
+- content_for :table_content do
+ %li= link_to 'GitLab CI', '#gitlab-ci'
+
+- content_for :settings_content do
+ %h2#gitlab-ci
+ GitLab CI
+
+ %p
+ Below are the current settings regarding
+ = succeed('.') { link_to('GitLab CI', 'https://about.gitlab.com/gitlab-ci', target: '_blank') }
+
+ .table-responsive
+ %table
+ %thead
+ %tr
+ %th Setting
+ %th= instance_configuration_host(@instance_configuration.settings[:host])
+ %th Default
+ %tbody
+ %tr
+ - artifacts_size = @instance_configuration.settings[:gitlab_ci][:artifacts_max_size]
+ %td Artifacts maximum size
+ %td= instance_configuration_human_size_cell(artifacts_size[:value])
+ %td= instance_configuration_human_size_cell(artifacts_size[:default])
diff --git a/app/views/help/instance_configuration/_gitlab_pages.html.haml b/app/views/help/instance_configuration/_gitlab_pages.html.haml
new file mode 100644
index 00000000000..bdd77730dcc
--- /dev/null
+++ b/app/views/help/instance_configuration/_gitlab_pages.html.haml
@@ -0,0 +1,35 @@
+- gitlab_pages = @instance_configuration.settings[:gitlab_pages]
+- content_for :table_content do
+ %li= link_to 'GitLab Pages', '#gitlab-pages'
+
+- content_for :settings_content do
+ %h2#gitlab-pages
+ GitLab Pages
+
+ %p
+ Below are the settings for
+ = succeed('.') { link_to('Gitlab Pages', gitlab_pages[:url], target: '_blank') }
+ .table-responsive
+ %table
+ %thead
+ %tr
+ %th Setting
+ %th= instance_configuration_host(@instance_configuration.settings[:host])
+ %tbody
+ %tr
+ %td Domain Name
+ %td
+ %code= instance_configuration_cell_html(gitlab_pages[:host])
+ %tr
+ %td IP Address
+ %td
+ %code= instance_configuration_cell_html(gitlab_pages[:ip_address])
+ %tr
+ %td Port
+ %td
+ %code= instance_configuration_cell_html(gitlab_pages[:port])
+ %br
+
+ %p
+ The maximum size of your Pages site is regulated by the artifacts maximum
+ size which is part of #{succeed('.') { link_to('GitLab CI', '#gitlab-ci') }}
diff --git a/app/views/help/instance_configuration/_ssh_info.html.haml b/app/views/help/instance_configuration/_ssh_info.html.haml
new file mode 100644
index 00000000000..987cc61b3f6
--- /dev/null
+++ b/app/views/help/instance_configuration/_ssh_info.html.haml
@@ -0,0 +1,27 @@
+- ssh_info = @instance_configuration.settings[:ssh_algorithms_hashes]
+- if ssh_info.any?
+ - content_for :table_content do
+ %li= link_to 'SSH host keys fingerprints', '#ssh-host-keys-fingerprints'
+
+ - content_for :settings_content do
+ %h2#ssh-host-keys-fingerprints
+ SSH host keys fingerprints
+
+ %p
+ Below are the fingerprints for the current instance SSH host keys.
+
+ .table-responsive
+ %table
+ %thead
+ %tr
+ %th Algorithm
+ %th MD5
+ %th SHA256
+ %tbody
+ - ssh_info.each do |algorithm|
+ %tr
+ %td= algorithm[:name]
+ %td
+ %code= instance_configuration_cell_html(algorithm[:md5])
+ %td
+ %code= instance_configuration_cell_html(algorithm[:sha256])
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 615dd56afbd..ce09b44fbb2 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -66,7 +66,7 @@
.example
.cover-block
.avatar-holder
- = image_tag avatar_icon('admin@example.com', 90), class: "avatar s90", alt: ''
+ = image_tag avatar_icon_for_email('admin@example.com', 90), class: "avatar s90", alt: ''
.cover-title
John Smith
@@ -187,7 +187,7 @@
= icon('chevron-down')
%ul.dropdown-menu
%li
- %a Sort by date
+ = link_to 'Sort by date', '#'
= link_to 'New issue', '#', class: 'btn btn-new btn-inverted'
@@ -407,29 +407,6 @@
.dropdown-content
.dropdown-loading
= icon('spinner spin')
- :javascript
- $('#js-project-dropdown').glDropdown({
- data: function (term, callback) {
- Api.projects(term, { order_by: 'last_activity_at' }, function (data) {
- callback(data);
- });
- },
- text: function (project) {
- return project.name_with_namespace || project.name;
- },
- selectable: true,
- fieldName: "author_id",
- filterable: true,
- search: {
- fields: ['name_with_namespace']
- },
- id: function (data) {
- return data.id;
- },
- isSelected: function (data) {
- return data.id === 2;
- }
- })
.example
%div
@@ -525,7 +502,7 @@
%h4
%code .file-holder
- - blob = Snippet.new(content: "Wow\nSuch\nFile")
+ - blob = Snippet.new(content: "Wow\nSuch\nFile").blob
.example
.file-holder
.js-file-title.file-title
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
new file mode 100644
index 00000000000..e0e8fe548d0
--- /dev/null
+++ b/app/views/ide/index.html.haml
@@ -0,0 +1,12 @@
+- @body_class = 'ide'
+- page_title 'IDE'
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'ide', force_same_domain: true
+
+#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
+ "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
+ "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } }
+ .text-center
+ = icon('spinner spin 2x')
+ %h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 0e7f0b5ed4f..638c8b5a672 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -2,11 +2,11 @@
- provider_title = Gitlab::ImportSources.title(provider)
%p.light
- Select projects you want to import.
+ = import_githubish_choose_repository_message
%hr
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
- Import all projects
+ = import_all_githubish_repositories_button_label
= icon("spinner spin", class: "loading-icon")
.table-responsive
@@ -16,24 +16,26 @@
%colgroup.import-jobs-status-col
%thead
%tr
- %th From #{provider_title}
- %th To GitLab
- %th Status
+ %th= _('From %{provider_title}') % { provider_title: provider_title }
+ %th= _('To GitLab')
+ %th= _('Status')
%tbody
- @already_added_projects.each do |project|
%tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
%td
= provider_project_link(provider, project.import_source)
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
%i.fa.fa-check
- done
+ = _('Done')
- elsif project.import_status == 'started'
%i.fa.fa-spinner.fa-spin
- started
+ = _('Started')
+ - elsif project.import_status == 'failed'
+ = _('Failed')
- else
= project.human_import_status_name
@@ -55,7 +57,9 @@
= text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
%td.import-actions.job-status
= button_tag class: "btn btn-import js-add-to-import" do
- Import
+ = has_ci_cd_only_params? ? _('Connect') : _('Import')
= icon("spinner spin", class: "loading-icon")
-.js-importer-status{ data: { jobs_import_path: "#{url_for([:jobs, :import, provider])}", import_path: "#{url_for([:import, provider])}" } }
+.js-importer-status{ data: { jobs_import_path: "#{url_for([:jobs, :import, provider])}",
+ import_path: "#{url_for([:import, provider])}",
+ ci_cd_only: "#{has_ci_cd_only_params?}" } }
diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml
deleted file mode 100644
index 57e8c3ca1e1..00000000000
--- a/app/views/import/base/create.js.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-- if @project.persisted?
- :plain
- job = $("tr#repo_#{@repo_id}")
- job.attr("id", "project_#{@project.id}")
- target_field = job.find(".import-target")
- target_field.empty()
- target_field.append('#{link_to @project.path_with_namespace, namespace_project_path(@project.namespace, @project)}')
- $("table.import-jobs tbody").prepend(job)
- job.addClass("active").find(".import-actions").html("<i class='fa fa-spinner fa-spin'></i> started")
-- else
- :plain
- job = $("tr#repo_#{@repo_id}")
- job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(h(@project.errors.full_messages.join(',')))}")
diff --git a/app/views/import/base/unauthorized.js.haml b/app/views/import/base/unauthorized.js.haml
deleted file mode 100644
index ada5f99f4e2..00000000000
--- a/app/views/import/base/unauthorized.js.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-:plain
- tr = $("tr#repo_#{@repo_id}")
- target_field = tr.find(".import-target")
- import_button = tr.find(".btn-import")
- origin_target = target_field.text()
- project_name = "#{@project_name}"
- origin_namespace = "#{@target_namespace.full_path}"
- target_field.empty()
- target_field.append("<p class='alert alert-danger'>This namespace has already been taken! Please choose another one.</p>")
- target_field.append("<input type='text' name='target_namespace' />")
- target_field.append("/" + project_name)
- target_field.data("project_name", project_name)
- target_field.find('input').prop("value", origin_namespace)
- import_button.enable().removeClass('is-loading')
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index e6058617ac9..9589e0956f4 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -35,7 +35,7 @@
%td
= link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer'
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index c52a515226e..84e0009487f 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -10,7 +10,7 @@
Customize how FogBugz email addresses and usernames are imported into GitLab.
In the next step, you'll be able to select the projects you want to import.
%p
- The user map is a mapping of the FogBugz users that participated on your projects to the way their email address and usernames wil be imported into GitLab. You can change this by populating the table below.
+ The user map is a mapping of the FogBugz users that participated on your projects to the way their email address and usernames will be imported into GitLab. You can change this by populating the table below.
%ul
%li
%strong Default: Map a FogBugz account ID to a full name
diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml
index 5de5da5e6a2..7b832c6a23a 100644
--- a/app/views/import/fogbugz/status.html.haml
+++ b/app/views/import/fogbugz/status.html.haml
@@ -33,7 +33,7 @@
%td
= project.import_source
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 9c2da3a3eec..54ef51b30e3 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -1,43 +1,31 @@
-- page_title "GitHub Import"
+- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import')
+- page_title title
+- breadcrumb_title title
- header_title "Projects", root_path
%h3.page-title
- = icon 'github', text: 'Import Projects from GitHub'
+ = icon 'github', text: import_github_title
- if github_import_configured?
%p
- To import a GitHub project, you first need to authorize GitLab to access
- the list of your GitHub repositories:
+ = import_github_authorize_message
- = link_to 'List your GitHub repositories', status_import_github_path, class: 'btn btn-success'
+ = link_to _('List your GitHub repositories'), status_import_github_path(ci_cd_only: params[:ci_cd_only]), class: 'btn btn-success'
%hr
%p
- - if github_import_configured?
- Alternatively,
- - else
- To import a GitHub project,
- you can use a
- = succeed '.' do
- = link_to 'Personal Access Token', 'https://github.com/settings/tokens'
- When you create your Personal Access Token,
- you will need to select the <code>repo</code> scope, so we can display a
- list of your public and private repositories which are available for import.
+ = import_github_personal_access_token_message
= form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do
.form-group
- = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: "Personal Access Token", size: 40
- = submit_tag 'List your GitHub repositories', class: 'btn btn-success'
+ = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('Personal Access Token'), size: 40
+ = submit_tag _('List your GitHub repositories'), class: 'btn btn-success'
+
+ -# EE-specific start
+ -# EE-specific end
- unless github_import_configured?
%hr
%p
- Note:
- - if current_user.admin?
- As an administrator you may like to configure
- - else
- Consider asking your GitLab administrator to configure
- = link_to 'GitHub integration', help_page_path("integration/github")
- which will allow login via GitHub and allow importing projects without
- generating a Personal Access Token.
+ = import_configure_github_admin_message
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 0fe578a0036..b00b972d9c9 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -1,6 +1,8 @@
-- page_title "GitHub Import"
+- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import')
+- page_title title
+- breadcrumb_title title
- header_title "Projects", root_path
%h3.page-title
- = icon 'github', text: 'Import Projects from GitHub'
+ = icon 'github', text: import_github_title
= render 'import/githubish_status', provider: 'github'
diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml
index 7456799ca0e..37734414835 100644
--- a/app/views/import/gitlab/status.html.haml
+++ b/app/views/import/gitlab/status.html.haml
@@ -28,7 +28,7 @@
%td
= link_to project.import_source, "https://gitlab.com/#{project.import_source}", target: "_blank"
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 767dffb5589..dec85368d10 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -1,25 +1,40 @@
- page_title "GitLab Import"
- header_title "Projects", root_path
+
%h3.page-title
= icon('gitlab')
Import an exported GitLab project
%hr
-= form_tag import_gitlab_project_path, class: 'form-horizontal', multipart: true do
- %p
- Project will be imported as
- %strong
- #{@namespace.name}/#{@path}
+= form_tag import_gitlab_project_path, class: 'new_project', multipart: true do
+ .row
+ .form-group.col-xs-12.col-sm-6
+ = label_tag :namespace_id, 'Project path', class: 'label-light'
+ .form-group
+ .input-group
+ - if current_user.can_select_namespace?
+ .input-group-addon.has-tooltip{ title: root_url }
+ = root_url
+ = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1
- %p
- To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.
- .form-group
- = hidden_field_tag :namespace_id, @namespace.id
- = hidden_field_tag :path, @path
- = label_tag :file, class: 'control-label' do
- %span GitLab project export
- .col-sm-10
- = file_field_tag :file, class: ''
+ - else
+ .input-group-addon.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
+ #{user_url(current_user.username)}/
+ = hidden_field_tag :namespace_id, value: current_user.namespace_id
+ .form-group.col-xs-12.col-sm-6.project-path
+ = label_tag :path, 'Project name', class: 'label-light'
+ = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, autofocus: true, required: true
- .form-actions
- = submit_tag 'Import project', class: 'btn btn-create'
+ .row
+ .form-group.col-md-12
+ To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.
+ .row
+ .form-group.col-sm-12
+ = hidden_field_tag :namespace_id, @namespace.id
+ = label_tag :file, 'GitLab project export', class: 'label-light'
+ .form-group
+ = file_field_tag :file, class: ''
+ .row
+ .form-actions
+ = submit_tag 'Import project', class: 'btn btn-create'
+ = link_to 'Cancel', new_project_path, class: 'btn btn-cancel'
diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml
index 60de6bfe816..bc61aeece72 100644
--- a/app/views/import/google_code/status.html.haml
+++ b/app/views/import/google_code/status.html.haml
@@ -38,7 +38,7 @@
%td
= link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank", rel: 'noopener noreferrer'
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 882fdf1317d..c2bb1216c5f 100644
--- a/app/views/invites/show.html.haml
+++ b/app/views/invites/show.html.haml
@@ -12,7 +12,7 @@
- project = @member.source
project
%strong
- = link_to project.name_with_namespace, namespace_project_url(project.namespace, project)
+ = link_to project.full_name, project_url(project)
- when Group
- group = @member.source
group
diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder
index 2ed78bb3b65..21cf6d0dd65 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -1,9 +1,9 @@
xml.entry do
- xml.id namespace_project_issue_url(issue.project.namespace, issue.project, issue)
- xml.link href: namespace_project_issue_url(issue.project.namespace, issue.project, issue)
+ xml.id project_issue_url(issue.project, issue)
+ xml.link href: project_issue_url(issue.project, issue)
xml.title truncate(issue.title, length: 80)
xml.updated issue.updated_at.xmlschema
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(issue.author_email))
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issue.author))
xml.author do
xml.name issue.author_name
diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml
new file mode 100644
index 00000000000..250029c4475
--- /dev/null
+++ b/app/views/kaminari/gitlab/_without_count.html.haml
@@ -0,0 +1,8 @@
+.gl-pagination
+ %ul.pagination.clearfix
+ - if previous_path
+ %li.prev
+ = link_to(t('views.pagination.previous'), previous_path, rel: 'prev')
+ - if next_path
+ %li.next
+ = link_to(t('views.pagination.next'), next_path, rel: 'next')
diff --git a/app/views/koding/index.html.haml b/app/views/koding/index.html.haml
index 04e2d4b63e6..bb7f9ba7ae4 100644
--- a/app/views/koding/index.html.haml
+++ b/app/views/koding/index.html.haml
@@ -3,4 +3,4 @@
= icon('circle', class: 'cgreen')
Integration is active for
= link_to koding_project_url, target: '_blank', rel: 'noopener noreferrer' do
- #{current_application_settings.koding_url}
+ #{Gitlab::CurrentSettings.koding_url}
diff --git a/app/views/layouts/_bootlint.haml b/app/views/layouts/_bootlint.haml
deleted file mode 100644
index 69280687a9d..00000000000
--- a/app/views/layouts/_bootlint.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:javascript
- jQuery(document).ready(function() {
- javascript:(function(){var s=document.createElement("script");s.onload=function(){bootlint.showLintReportForCurrentDocument([], {hasProblems: false, problemFree: false});};s.src="https://maxcdn.bootstrapcdn.com/bootlint/latest/bootlint.min.js";document.body.appendChild(s)})();
- });
diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml
index bcd2f03e83c..e2dbdcbb939 100644
--- a/app/views/layouts/_broadcast.html.haml
+++ b/app/views/layouts/_broadcast.html.haml
@@ -1,2 +1,2 @@
-- BroadcastMessage.current.each do |message|
+- BroadcastMessage.current&.each do |message|
= broadcast_message(message)
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index baa8036de10..05ddd0ec733 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,10 +1,8 @@
.flash-container.flash-container-page
- - if alert
- .flash-alert
- %div{ class: (container_class) }
- %span= alert
-
- - elsif notice
- .flash-notice
- %div{ class: (container_class) }
- %span= notice
+ -# We currently only support `alert`, `notice`, `success`
+ - flash.each do |key, value|
+ -# Don't show a flash message if the message is nil
+ - if value
+ %div{ class: "flash-#{key}" }
+ %div{ class: (container_class) }
+ %span= value
diff --git a/app/views/layouts/_google_analytics.html.haml b/app/views/layouts/_google_analytics.html.haml
index 81e03c7eff2..98ea96b0b77 100644
--- a/app/views/layouts/_google_analytics.html.haml
+++ b/app/views/layouts/_google_analytics.html.haml
@@ -1,3 +1,4 @@
+-# haml-lint:disable InlineJavaScript
:javascript
var _gaq = _gaq || [];
_gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']);
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index f7a1d7e8844..b981b5fdafa 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -11,6 +11,8 @@
%meta{ property: 'og:title', content: page_title }
%meta{ property: 'og:description', content: page_description }
%meta{ property: 'og:image', content: page_image }
+ %meta{ property: 'og:image:width', content: '64' }
+ %meta{ property: 'og:image:height', content: '64' }
%meta{ property: 'og:url', content: request.base_url + request.fullpath }
-# Twitter Card - https://dev.twitter.com/cards/types/summary
@@ -28,24 +30,24 @@
= stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
- = stylesheet_link_tag 'peek' if peek_enabled?
-
- - if show_new_nav?
- = stylesheet_link_tag "new_nav", media: "all"
+ = stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= Gon::Base.render_data
- = webpack_bundle_tag "runtime"
+ - if content_for?(:library_javascripts)
+ = yield :library_javascripts
+
+ = javascript_include_tag locale_path unless I18n.locale == :en
+ = webpack_bundle_tag "webpack_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?
- = webpack_bundle_tag 'peek' if peek_enabled?
+ = webpack_bundle_tag "raven" if Gitlab::CurrentSettings.clientside_sentry_enabled
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
+ = webpack_controller_bundle_tags
+
= yield :project_javascripts
= csrf_meta_tags
@@ -70,4 +72,3 @@
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id')
= render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id')
- = render 'layouts/bootlint' if Rails.env.development?
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 6caaba240bb..4276e6ee4bb 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -2,13 +2,15 @@
- noteable_type = @noteable.class if @noteable.present?
- if project
+ -# haml-lint:disable InlineJavaScript
:javascript
+ gl = window.gl || {};
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)}",
- mergeRequests: "#{merge_requests_namespace_project_autocomplete_sources_path(project.namespace, project)}",
- labels: "#{labels_namespace_project_autocomplete_sources_path(project.namespace, project)}",
- 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])}"
+ members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
+ issues: "#{issues_project_autocomplete_sources_path(project)}",
+ mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}",
+ labels: "#{labels_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
+ milestones: "#{milestones_project_autocomplete_sources_path(project)}",
+ commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
};
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index 983ed22a506..ddc1cdb24b5 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -10,6 +10,10 @@
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; }
+ .hidden {
+ display: none !important;
+ visibility: hidden !important;
+ }
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
@@ -63,12 +67,8 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/
%div
- %a{ href: profile_notifications_url, style: "color:#3777b0;text-decoration:none;" } Manage all notifications
- &middot;
- %a{ href: help_url, style: "color:#3777b0;text-decoration:none;" } Help
- %div
- You're receiving this email because of your account on
- = succeed "." do
- %a{ href: root_url, style: "color:#3777b0;text-decoration:none;" }= Gitlab.config.gitlab.host
+ - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;")
+ - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;")
+ = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
= yield :additional_footer
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index b7df11681d3..f67a8878c80 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -1,15 +1,16 @@
-.page-with-sidebar{ class: page_gutter_class }
+.layout-page{ class: page_with_sidebar_class }
- if defined?(nav) && nav
- .layout-nav
- .container-fluid
- = render "layouts/nav/#{nav}"
- - if content_for?(:sub_nav)
- = yield :sub_nav
- .content-wrapper{ class: layout_nav_class }
+ = render "layouts/nav/sidebar/#{nav}"
+ .content-wrapper{ class: "#{@content_wrapper_class}" }
+ = render 'shared/outdated_browser'
+ .mobile-overlay
.alert-wrapper
= render "layouts/broadcast"
- = render "layouts/flash"
+ = render 'layouts/header/read_only_banner'
= yield :flash_message
+ - unless @hide_breadcrumbs
+ = render "layouts/nav/breadcrumbs"
+ = render "layouts/flash"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= yield
diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml
index 259b4f7cdfc..a888e8ae187 100644
--- a/app/views/layouts/_piwik.html.haml
+++ b/app/views/layouts/_piwik.html.haml
@@ -1,4 +1,5 @@
<!-- Piwik -->
+-# haml-lint:disable InlineJavaScript
:javascript
var _paq = _paq || [];
_paq.push(['trackPageView']);
diff --git a/app/views/layouts/_recaptcha_verification.html.haml b/app/views/layouts/_recaptcha_verification.html.haml
index 77c77dc6754..e6f87ddd383 100644
--- a/app/views/layouts/_recaptcha_verification.html.haml
+++ b/app/views/layouts/_recaptcha_verification.html.haml
@@ -1,5 +1,4 @@
- humanized_resource_name = spammable.class.model_name.human.downcase
-- resource_name = spammable.class.model_name.singular
%h3.page-title
Anti-spam verification
@@ -8,16 +7,4 @@
%p
#{"We detected potential spam in the #{humanized_resource_name}. Please solve the reCAPTCHA to proceed."}
-= form_for form do |f|
- .recaptcha
- - params[resource_name].each do |field, value|
- = hidden_field(resource_name, field, value: value)
- = hidden_field_tag(:spam_log_id, spammable.spam_log.id)
- = hidden_field_tag(:recaptcha_verification, true)
- = recaptcha_tags
-
- -# Yields a block with given extra params.
- = yield
-
- .row-content-block.footer-block
- = f.submit "Submit #{humanized_resource_name}", class: 'btn btn-create'
+= render 'shared/recaptcha_form', spammable: spammable
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index b689991bb6d..52587760ba4 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -5,7 +5,7 @@
- if @group && @group.persisted? && @group.path
- group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) }
- if @project && @project.persisted?
- - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: namespace_project_issues_path(@project.namespace, @project), mr_path: namespace_project_merge_requests_path(@project.namespace, @project) }
+ - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? }
.search.search-form{ class: "#{'has-location-badge' if label.present?}" }
= form_tag search_path, method: :get, class: 'navbar-form' do |f|
.search-input-container
@@ -13,16 +13,24 @@
.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 }, aria: { label: 'Search' }
+ = 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: { issues_path: issues_dashboard_path,
+ mr_path: merge_requests_dashboard_path },
+ aria: { label: 'Search' }
+ %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
.dropdown-menu.dropdown-select
= dropdown_content do
%ul
- %li
- %a.is-focused.dropdown-menu-empty-link
+ %li.dropdown-menu-empty-item
+ %a
Loading...
= dropdown_loading
- %i.search-icon
- %i.clear-icon.js-clear-input
+ = sprite_icon('search', size: 16, css_class: 'search-icon')
+ = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input')
= hidden_field_tag :group_id, @group.try(:id), class: 'js-search-group-options', data: group_data_attrs
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 87064cc9b3f..8595157a997 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -1,5 +1,6 @@
- page_title "Admin Area"
- header_title "Admin Area", admin_root_path
-- nav "admin"
+- nav "admin"
+- @left_sidebar = true
= render template: "layouts/application"
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index d879df8fc82..0ca34b276a7 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,13 +1,10 @@
!!! 5
-%html{ lang: I18n.locale, class: "#{page_class}" }
+%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
- %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } }
+ %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } }
= render "layouts/init_auto_complete" if @gfm_form
= render 'peek/bar'
- - if show_new_nav?
- = render "layouts/header/new"
- - else
- = render "layouts/header/default", title: header_title
+ = render "layouts/header/default"
= render 'layouts/page', sidebar: sidebar, nav: nav
= yield :scripts_body
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 52fb46eb8c9..257f7326409 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,10 +1,11 @@
!!! 5
%html.devise-layout-html
= render "layouts/head"
- %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } }
+ %body.ui_indigo.login-page.application.navless{ data: { page: body_data_page } }
.page-wrap
= render "layouts/header/empty"
- = render "layouts/broadcast"
+ .login-page-broadcast
+ = render "layouts/broadcast"
.container.navless-container
.content
= render "layouts/flash"
@@ -14,8 +15,8 @@
.col-sm-7.brand-holder.pull-left
%h1
= brand_title
- - if brand_item
- = brand_image
+ = brand_image
+ - if brand_item&.description?
= brand_text
- else
%h3 Open source software to collaborate on code
@@ -25,8 +26,8 @@
Perform code reviews and enhance collaboration with merge requests.
Each project can also have an issue tracker and a wiki.
- - if current_application_settings.sign_in_text.present?
- = markdown_field(current_application_settings, :sign_in_text)
+ - if Gitlab::CurrentSettings.sign_in_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
%hr.footer-fixed
.container.footer-container
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index ed6731bde95..8718bb3db1a 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -1,7 +1,7 @@
!!! 5
%html{ lang: "en" }
= render "layouts/head"
- %body.ui_charcoal.login-page.application.navless
+ %body.ui_indigo.login-page.application.navless
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml
index 6d9ec043590..9382ee8715e 100644
--- a/app/views/layouts/errors.html.haml
+++ b/app/views/layouts/errors.html.haml
@@ -15,7 +15,7 @@
h1 {
font-size: 56px;
line-height: 100px;
- font-weight: normal;
+ font-weight: 400;
color: #456;
}
@@ -28,7 +28,7 @@
h3 {
color: #456;
font-size: 20px;
- font-weight: normal;
+ font-weight: 400;
line-height: 28px;
}
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index f06acc98ca1..bfbfeee7c4b 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -1,6 +1,13 @@
- page_title @group.name
- page_description @group.description unless page_description
- header_title group_title(@group) unless header_title
-- nav "group"
+- nav "group"
+- @left_sidebar = true
+
+- content_for :page_specific_javascripts do
+ - if current_user
+ -# haml-lint:disable InlineJavaScript
+ :javascript
+ window.uploads_path = "#{group_uploads_path(@group)}";
= render template: "layouts/application"
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index f056c0af968..e6238c0dddb 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,68 +1,57 @@
-%header.navbar.navbar-gitlab{ class: nav_header_class }
- .navbar-border
+%header.navbar.navbar-gitlab.qa-navbar
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
- .dropdown.global-dropdown
- %button.global-dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.sr-only Toggle navigation
- = icon('bars')
- .dropdown-menu-nav.global-dropdown-menu
- - if current_user
- = render 'layouts/nav/dashboard'
- - else
- = render 'layouts/nav/explore'
-
- .header-logo
- = link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do
- = brand_header_logo
-
.title-container
- %h1.title{ class: ('initializing' if @has_group_title) }= title
+ %h1.title
+ = link_to root_path, title: 'Dashboard', id: 'logo' do
+ = brand_header_logo
+ - logo_text = brand_header_logo_type
+ - if logo_text.present?
+ %span.logo-text.hidden-xs
+ = logo_text
+
+ - if current_user
+ = render "layouts/nav/dashboard"
+ - else
+ = render "layouts/nav/explore"
.navbar-collapse.collapse
%ul.nav.navbar-nav
- %li.hidden-sm.hidden-xs
- = render 'layouts/search' unless current_controller?(:search)
- %li.visible-sm-inline-block.visible-xs-inline-block
- = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('search')
- if current_user
- - if session[:impersonator_id]
- %li.impersonation
- = link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = icon('user-secret fw')
- - if current_user.admin?
- %li
- = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('wrench fw')
= render 'layouts/header/new_dropdown'
- - if Gitlab::Sherlock.enabled?
- %li
- = link_to sherlock_transactions_path, title: 'Sherlock Transactions',
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('tachometer fw')
- %li
- = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('hashtag fw')
+ - if header_link?(:search)
+ %li.hidden-sm.hidden-xs
+ = render 'layouts/search' unless current_controller?(:search)
+ %li.visible-sm-inline-block.visible-xs-inline-block
+ = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('search', size: 16)
+
+ - if header_link?(:issues)
+ = nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
+ = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('issues', size: 16)
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
- %li
- = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = custom_icon('mr_bold')
+ - if header_link?(:merge_requests)
+ = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do
+ = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('git-merge', size: 16)
- merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
= number_with_delimiter(merge_requests_count)
- %li
+ - if header_link?(:todos)
+ = nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('check-circle fw')
+ = sprite_icon('todo-done', size: 16)
%span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
+ - if header_link?(:user_dropdown)
%li.header-user.dropdown
- = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do
- = image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar"
- = icon('caret-down')
+ = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
+ = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
+ = sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
%li.current-user
@@ -74,26 +63,22 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
- - if can_toggle_new_nav?
+ - if current_user
%li
- = link_to "Turn on new nav", profile_preferences_path(anchor: "new-navigation")
+ = link_to "Help", help_path
%li.divider
%li
- = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- - else
+ = link_to "Sign out", destroy_user_session_path, class: "sign-out-link"
+ - if header_link?(:admin_impersonation)
+ %li.impersonation
+ = link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
+ = icon('user-secret')
+ - if header_link?(:sign_in)
%li
%div
- = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in btn-success'
+ = link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in'
- %button.navbar-toggle{ type: 'button' }
+ %button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' }
%span.sr-only Toggle navigation
- = icon('ellipsis-v')
-
- = yield :header_content
-
-= render 'shared/outdated_browser'
-
-- if @project && !@project.empty_repo?
- - if ref = @ref || @project.repository.root_ref
- :javascript
- var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, ref)}";
+ = sprite_icon('more', size: 12, css_class: 'more-icon js-navbar-toggle-right')
+ = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml
deleted file mode 100644
index c0833c64911..00000000000
--- a/app/views/layouts/header/_new.html.haml
+++ /dev/null
@@ -1,93 +0,0 @@
-%header.navbar.navbar-gitlab.navbar-gitlab-new{ class: nav_header_class }
- %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
- .container-fluid
- .header-content
- .title-container
- %h1.title
- = link_to root_path, title: 'Dashboard' do
- = brand_header_logo
- %span.hidden-xs
- GitLab
-
- - if current_user
- = render "layouts/nav/new_dashboard"
- - else
- = render "layouts/nav/new_explore"
-
- .navbar-collapse.collapse
- %ul.nav.navbar-nav
- %li.hidden-sm.hidden-xs
- = render 'layouts/search' unless current_controller?(:search)
- %li.visible-sm-inline-block.visible-xs-inline-block
- = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('search')
- - if current_user
- - if session[:impersonator_id]
- %li.impersonation
- = link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = icon('user-secret fw')
- - if current_user.admin?
- %li
- = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('wrench fw')
- = render 'layouts/header/new_dropdown'
- - if Gitlab::Sherlock.enabled?
- %li
- = link_to sherlock_transactions_path, title: 'Sherlock Transactions',
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('tachometer fw')
- %li
- = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('hashtag fw')
- - issues_count = assigned_issuables_count(:issues)
- %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
- = number_with_delimiter(issues_count)
- %li
- = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = custom_icon('mr_bold')
- - merge_requests_count = assigned_issuables_count(:merge_requests)
- %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
- = number_with_delimiter(merge_requests_count)
- %li
- = link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('check-circle fw')
- %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
- = todos_count_format(todos_pending_count)
- %li.header-user.dropdown
- = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do
- = image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar"
- = icon('chevron-down')
- .dropdown-menu-nav.dropdown-menu-align-right
- %ul
- %li.current-user
- .user-name.bold
- = current_user.name
- @#{current_user.username}
- %li.divider
- %li
- = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
- %li
- = link_to "Settings", profile_path
- %li
- = link_to "Turn off new nav", profile_preferences_path(anchor: "new-navigation")
- %li.divider
- %li
- = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- - else
- %li
- %div
- = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in btn-success'
-
- %button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' }
- %span.sr-only Toggle navigation
- = icon('ellipsis-v', class: 'js-navbar-toggle-right')
- = icon('times', class: 'js-navbar-toggle-left', style: 'display: none;')
-
- = yield :header_content
-
-= render 'shared/outdated_browser'
-
-- if @project && !@project.empty_repo?
- - if ref = @ref || @project.repository.root_ref
- :javascript
- var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, ref)}";
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 9ff1164f2ee..eb32f393310 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -1,11 +1,7 @@
%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
- - if show_new_nav?
- = icon('plus')
- = icon('chevron-down')
- - else
- = icon('plus fw')
- = icon('caret-down')
+ = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
+ = sprite_icon('plus-square', size: 16)
+ = sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
- if @group&.persisted?
@@ -30,13 +26,13 @@
%li.dropdown-bold-header This project
- if create_project_issue
%li
- = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project)
+ = link_to 'New issue', new_project_issue_path(@project)
- if merge_project
%li
- = link_to 'New merge request', new_namespace_project_merge_request_path(merge_project.namespace, merge_project)
+ = link_to 'New merge request', project_new_merge_request_path(merge_project)
- if create_project_snippet
%li.header-new-project-snippet
- = link_to 'New snippet', new_namespace_project_snippet_path(@project.namespace, @project)
+ = link_to 'New snippet', new_project_snippet_path(@project)
%li.divider
%li.dropdown-bold-header GitLab
- if current_user.can_create_project?
diff --git a/app/views/layouts/header/_read_only_banner.html.haml b/app/views/layouts/header/_read_only_banner.html.haml
new file mode 100644
index 00000000000..f3d563c362f
--- /dev/null
+++ b/app/views/layouts/header/_read_only_banner.html.haml
@@ -0,0 +1,7 @@
+- message = read_only_message
+- if message
+ .flash-container.flash-container-page
+ .flash-notice
+ %div{ class: (container_class) }
+ %span
+ = message
diff --git a/app/views/layouts/help.html.haml b/app/views/layouts/help.html.haml
index 224b24befbe..78927bfffcd 100644
--- a/app/views/layouts/help.html.haml
+++ b/app/views/layouts/help.html.haml
@@ -1,3 +1,4 @@
+- @breadcrumb_title = "Help"
- page_title "Help"
- header_title "Help", help_path
diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml
deleted file mode 100644
index 6df0adfd742..00000000000
--- a/app/views/layouts/nav/_admin.html.haml
+++ /dev/null
@@ -1,40 +0,0 @@
-= render 'layouts/nav/admin_settings'
-.scrolling-tabs-container{ class: nav_control_class }
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.scrolling-tabs
- = 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(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, :hook_logs]) do
- = link_to admin_hooks_path, title: 'Hooks' do
- %span
- System Hooks
-
- = nav_link(controller: :applications) do
- = link_to admin_applications_path, title: 'Applications' do
- %span
- Applications
-
- = nav_link(controller: :abuse_reports) do
- = link_to admin_abuse_reports_path, title: "Abuse Reports" do
- %span
- Abuse Reports
- %span.badge.count= number_with_delimiter(AbuseReport.count(:all))
-
- - if akismet_enabled?
- = nav_link(controller: :spam_logs) do
- = link_to admin_spam_logs_path, title: "Spam Logs" do
- %span
- Spam Logs
diff --git a/app/views/layouts/nav/_admin_settings.html.haml b/app/views/layouts/nav/_admin_settings.html.haml
deleted file mode 100644
index 9de0e12a826..00000000000
--- a/app/views/layouts/nav/_admin_settings.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-.controls
- .dropdown.admin-settings-dropdown
- %a.dropdown-new.btn.btn-default{ href: '#', 'data-toggle' => 'dropdown' }
- = icon('cog')
- = icon('caret-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- = nav_link(controller: :deploy_keys) do
- = link_to admin_deploy_keys_path, title: 'Deploy Keys' do
- %span
- Deploy Keys
-
- = nav_link(controller: :services) do
- = link_to admin_application_settings_services_path, title: 'Service Templates' do
- %span
- Service Templates
-
- = nav_link(controller: :labels) do
- = link_to admin_labels_path, title: 'Labels' do
- %span
- Labels
-
- = nav_link(controller: :appearances) do
- = link_to admin_appearances_path, title: 'Appearances' do
- %span
- Appearance
-
- %li.divider
- = nav_link(controller: :application_settings) do
- = link_to admin_application_settings_path, title: 'Settings' do
- %span
- Settings
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
new file mode 100644
index 00000000000..002922e13f1
--- /dev/null
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -0,0 +1,20 @@
+- container = @no_breadcrumb_container ? 'container-fluid' : container_class
+- hide_top_links = @hide_top_links || false
+
+%nav.breadcrumbs{ role: "navigation", class: [container, @content_class] }
+ .breadcrumbs-container
+ - if defined?(@left_sidebar)
+ = button_tag class: 'toggle-mobile-nav', type: 'button' do
+ %span.sr-only Open sidebar
+ = icon ('bars')
+ .breadcrumbs-links.js-title-container
+ %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
+ - unless hide_top_links
+ = header_title
+ - if @breadcrumbs_extra_links
+ - @breadcrumbs_extra_links.each do |extra|
+ = breadcrumb_list_item link_to(extra[:text], extra[:link])
+ = render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after
+ %li
+ %h2.breadcrumbs-sub-title= link_to @breadcrumb_title, breadcrumb_title_link
+ = yield :header_content
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index ac222ad8c82..f773bd0832d 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,67 +1,79 @@
-%ul
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do
- = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- P
- %span
+%ul.list-unstyled.navbar-sub-nav
+ - if dashboard_nav_link?(:projects)
+ = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
+ %a{ href: "#", data: { toggle: "dropdown" } }
Projects
- = nav_link(path: 'dashboard#activity') do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- A
- %span
- Activity
- - if koding_enabled?
- = nav_link(controller: :koding) do
- = link_to koding_path, title: 'Koding' do
- %span
- Koding
- = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
- = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- G
- %span
+ = sprite_icon('angle-down', css_class: 'caret-down')
+ .dropdown-menu.projects-dropdown-menu
+ = render "layouts/nav/projects_dropdown/show"
+
+ - if dashboard_nav_link?(:groups)
+ = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do
Groups
- = nav_link(controller: 'dashboard/milestones') do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- L
- %span
+
+ - if dashboard_nav_link?(:activity)
+ = nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
+ = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
+ Activity
+
+ - if dashboard_nav_link?(:milestones)
+ = nav_link(controller: 'dashboard/milestones', html_options: { class: "visible-lg" }) do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
Milestones
- = nav_link(path: 'dashboard#issues') do
- = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- I
- %span
- Issues
- .badge= number_with_delimiter(assigned_issuables_count(:issues))
- = nav_link(path: 'dashboard#merge_requests') do
- = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- M
- %span
- Merge Requests
- .badge= number_with_delimiter(assigned_issuables_count(:merge_requests))
- = nav_link(controller: 'dashboard/snippets') do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- S
- %span
+
+ - if dashboard_nav_link?(:snippets)
+ = nav_link(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
Snippets
- %li.divider
- %li
- = link_to "Help", help_path, title: 'About GitLab CE', class: 'about-gitlab'
+
+ - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
+ %li.header-more.dropdown.hidden-lg
+ %a{ href: "#", data: { toggle: "dropdown" } }
+ More
+ = sprite_icon('angle-down', css_class: 'caret-down')
+ .dropdown-menu
+ %ul
+ - if dashboard_nav_link?(:groups)
+ = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "visible-xs" }) do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
+ Groups
+
+ - if dashboard_nav_link?(:activity)
+ = nav_link(path: 'dashboard#activity') do
+ = link_to activity_dashboard_path, title: 'Activity' do
+ Activity
+
+ - if dashboard_nav_link?(:milestones)
+ = nav_link(controller: 'dashboard/milestones') do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
+ Milestones
+
+ - if dashboard_nav_link?(:snippets)
+ = nav_link(controller: 'dashboard/snippets') do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
+ Snippets
+
+ -# Shortcut to Dashboard > Projects
+ - if dashboard_nav_link?(:projects)
+ %li.hidden
+ = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ Projects
+
+ - if current_controller?('ide')
+ %li.line-separator.hidden-xs
+ = nav_link(controller: 'ide') do
+ = link_to '#', class: 'dashboard-shortcuts-web-ide', title: 'Web IDE' do
+ Web IDE
+
+ - if current_user.admin? || Gitlab::Sherlock.enabled?
+ %li.line-separator.hidden-xs
+ - if current_user.admin?
+ = nav_link(controller: 'admin/dashboard') do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('admin', size: 18)
+ - if Gitlab::Sherlock.enabled?
+ %li
+ = link_to sherlock_transactions_path, class: 'admin-icon', title: 'Sherlock Transactions',
+ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = icon('tachometer fw')
diff --git a/app/views/layouts/nav/_explore.html.haml b/app/views/layouts/nav/_explore.html.haml
index 0cb367452f7..50bde9d1754 100644
--- a/app/views/layouts/nav/_explore.html.haml
+++ b/app/views/layouts/nav/_explore.html.haml
@@ -1,30 +1,15 @@
-%ul
- = nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
- = link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- P
- %span
+%ul.list-unstyled.navbar-sub-nav
+ - if explore_nav_link?(:projects)
+ = nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
+ = link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
- = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
- = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- G
- %span
+ - if explore_nav_link?(:groups)
+ = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
+ = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
Groups
- = nav_link(controller: :snippets) do
- = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
- .shortcut-mappings
- .key
- = icon('arrow-up', 'aria-label' => 'hidden')
- S
- %span
+ - if explore_nav_link?(:snippets)
+ = nav_link(controller: :snippets) do
+ = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
Snippets
- %li.divider
- = nav_link(controller: :help) do
- = link_to help_path, title: 'Help' do
- %span
- Help
+ %li
+ = link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml
deleted file mode 100644
index 8605380848d..00000000000
--- a/app/views/layouts/nav/_group.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-.scrolling-tabs-container{ class: nav_control_class }
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.scrolling-tabs
- = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to group_path(@group), title: 'Home' do
- %span
- Group
- = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do
- = link_to issues_group_path(@group), title: 'Issues' do
- %span
- Issues
- - issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
- %span.badge.count= number_with_delimiter(issues.count)
- = nav_link(path: 'groups#merge_requests') do
- = link_to merge_requests_group_path(@group), title: 'Merge Requests' do
- %span
- Merge Requests
- - merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
- %span.badge.count= number_with_delimiter(merge_requests.count)
- = nav_link(path: 'group_members#index') do
- = link_to group_group_members_path(@group), title: 'Members' do
- %span
- Members
- - if current_user && can?(current_user, :admin_group, @group)
- = nav_link(path: %w[groups#projects groups#edit]) do
- = link_to edit_group_path(@group), title: 'Settings' do
- %span
- Settings
diff --git a/app/views/layouts/nav/_new_dashboard.html.haml b/app/views/layouts/nav/_new_dashboard.html.haml
deleted file mode 100644
index 7109baa4dad..00000000000
--- a/app/views/layouts/nav/_new_dashboard.html.haml
+++ /dev/null
@@ -1,33 +0,0 @@
-%ul.list-unstyled.navbar-sub-nav
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do
- = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
- Projects
-
- = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
- = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
- Groups
-
- = nav_link(path: 'dashboard#activity', html_options: { class: "hidden-xs hidden-sm" }) do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
- Activity
-
- %li.dropdown
- %a{ href: "#", data: { toggle: "dropdown" } }
- More
- = icon("chevron-down", class: "dropdown-chevron")
- .dropdown-menu
- %ul
- = nav_link(path: 'dashboard#activity', html_options: { class: "visible-xs visible-sm" }) do
- = link_to activity_dashboard_path, title: 'Activity' do
- Activity
-
- = nav_link(controller: 'dashboard/milestones') do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
- Milestones
-
- = nav_link(controller: 'dashboard/snippets') do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
- Snippets
- %li.divider
- %li
- = link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/_new_explore.html.haml b/app/views/layouts/nav/_new_explore.html.haml
deleted file mode 100644
index 40385f251e3..00000000000
--- a/app/views/layouts/nav/_new_explore.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-%ul.list-unstyled.navbar-sub-nav
- = nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
- = link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
- Projects
- = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
- = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
- Groups
- %li.dropdown
- %a{ href: "#", data: { toggle: "dropdown" } }
- More
- = icon("chevron-down", class: "dropdown-chevron")
- .dropdown-menu
- %ul
- = nav_link(controller: :snippets) do
- = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
- Snippets
- %li.divider
- %li
- = link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
deleted file mode 100644
index ae1e1361f0f..00000000000
--- a/app/views/layouts/nav/_profile.html.haml
+++ /dev/null
@@ -1,53 +0,0 @@
-.scrolling-tabs-container
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.scrolling-tabs
- = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
- = link_to profile_path, title: 'Profile Settings' do
- %span
- Profile
- = nav_link(controller: [:accounts, :two_factor_auths]) do
- = link_to profile_account_path, title: 'Account' do
- %span
- Account
- - if current_application_settings.user_oauth_applications?
- = nav_link(controller: 'oauth/applications') do
- = link_to applications_profile_path, title: 'Applications' do
- %span
- Applications
- = nav_link(controller: :chat_names) do
- = link_to profile_chat_names_path, title: 'Chat' do
- %span
- Chat
- = nav_link(controller: :personal_access_tokens) do
- = link_to profile_personal_access_tokens_path, title: 'Access Tokens' do
- %span
- Access Tokens
- = nav_link(controller: :emails) do
- = link_to profile_emails_path, title: 'Emails' do
- %span
- Emails
- - unless current_user.ldap_user?
- = nav_link(controller: :passwords) do
- = link_to edit_profile_password_path, title: 'Password' do
- %span
- Password
- = nav_link(controller: :notifications) do
- = link_to profile_notifications_path, title: 'Notifications' do
- %span
- Notifications
-
- = nav_link(controller: :keys) do
- = link_to profile_keys_path, title: 'SSH Keys' do
- %span
- SSH Keys
- = nav_link(controller: :preferences) do
- = link_to profile_preferences_path, title: 'Preferences' do
- %span
- Preferences
- = nav_link(path: 'profiles#audit_log') do
- = link_to audit_log_profile_path, title: 'Authentication log' do
- %span
- Authentication log
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
deleted file mode 100644
index 29658da7792..00000000000
--- a/app/views/layouts/nav/_project.html.haml
+++ /dev/null
@@ -1,106 +0,0 @@
-- can_edit = can?(current_user, :admin_project, @project)
-.scrolling-tabs-container{ class: nav_control_class }
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.scrolling-tabs
- = nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do
- = link_to project_path(@project), title: 'Project', class: 'shortcuts-project' do
- %span
- Project
-
- - if project_nav_tab? :files
- = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do
- = link_to project_files_path(@project), title: 'Repository', class: 'shortcuts-tree' do
- %span
- Repository
-
- - if project_nav_tab? :container_registry
- = nav_link(controller: %w[projects/registry/repositories]) do
- = link_to project_container_registry_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
- %span
- Registry
-
- - if project_nav_tab? :issues
- = nav_link(controller: @project.default_issues_tracker? ? [:issues, :labels, :milestones, :boards] : :issues) do
- = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do
- %span
- Issues
- - if @project.default_issues_tracker?
- %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count)
-
- - if project_nav_tab? :merge_requests
- = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
- = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
- %span
- Merge Requests
- %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count)
-
- - if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do
- = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
- %span
- Pipelines
-
- - if project_nav_tab? :wiki
- = nav_link(controller: :wikis) do
- = link_to get_project_wiki_path(@project), title: 'Wiki', class: 'shortcuts-wiki' do
- %span
- Wiki
-
- - if project_nav_tab? :snippets
- = nav_link(controller: :snippets) do
- = link_to namespace_project_snippets_path(@project.namespace, @project), title: 'Snippets', class: 'shortcuts-snippets' do
- %span
- Snippets
-
- - if project_nav_tab? :settings
- = nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do
- = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do
- %span
- Settings
- - else
- = nav_link(path: %w[members#show]) do
- = link_to namespace_project_settings_members_path(@project.namespace, @project), title: 'Settings', class: 'shortcuts-tree' do
- %span
- Settings
-
- -# Shortcut to Project > Activity
- %li.hidden
- = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
- %span
- Activity
-
- -# Shortcut to Repository > Graph (formerly, Network)
- - if project_nav_tab? :network
- %li.hidden
- = link_to namespace_project_network_path(@project.namespace, @project, current_ref), title: 'Network', class: 'shortcuts-network' do
- Graph
-
- -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
- - unless @project.empty_repo?
- %li.hidden
- = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
- Charts
-
- -# Shortcut to Issues > New Issue
- %li.hidden
- = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'shortcuts-new-issue' do
- Create a new issue
-
- -# Shortcut to Pipelines > Jobs
- - if project_nav_tab? :builds
- %li.hidden
- = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
- Jobs
-
- -# Shortcut to commits page
- - if project_nav_tab? :commits
- %li.hidden
- = link_to project_commits_path(@project), title: 'Commits', class: 'shortcuts-commits' do
- Commits
-
- -# Shortcut to issue boards
- %li.hidden
- = link_to 'Issue Boards', namespace_project_boards_path(@project.namespace, @project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
new file mode 100644
index 00000000000..ad0d51d28f9
--- /dev/null
+++ b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
@@ -0,0 +1,11 @@
+- dropdown_location = local_assigns.fetch(:location, nil)
+- button_tooltip = local_assigns.fetch(:title, _("Show parent pages"))
+- if defined?(@breadcrumb_dropdown_links) && @breadcrumb_dropdown_links.key?(dropdown_location)
+ %li.dropdown
+ %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { toggle: "dropdown", container: "body" }, "aria-label": button_tooltip, title: button_tooltip }
+ = icon("ellipsis-h")
+ = sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle")
+ .dropdown-menu
+ %ul
+ - @breadcrumb_dropdown_links[dropdown_location].each_with_index do |link, index|
+ %li{ style: "text-indent: #{[index * 16, 60].min}px;" }= link
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
new file mode 100644
index 00000000000..5809d6f7fea
--- /dev/null
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -0,0 +1,15 @@
+- project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
+.projects-dropdown-container
+ .project-dropdown-sidebar.qa-projects-dropdown-sidebar
+ %ul
+ = nav_link(path: 'dashboard/projects#index') do
+ = link_to dashboard_projects_path, class: 'qa-your-projects-link' do
+ = _('Your projects')
+ = nav_link(path: 'projects#starred') do
+ = link_to starred_dashboard_projects_path do
+ = _('Starred projects')
+ = nav_link(path: 'projects#trending') do
+ = link_to explore_root_path do
+ = _('Explore projects')
+ .project-dropdown-content
+ #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
new file mode 100644
index 00000000000..dd086f70641
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -0,0 +1,211 @@
+.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ .context-header
+ = link_to admin_root_path, title: 'Admin Overview' do
+ .avatar-container.s40.settings-avatar
+ = sprite_icon('admin', size: 24)
+ .sidebar-context-title Admin Area
+ %ul.sidebar-top-level-items
+ = nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts conversational_development_index), html_options: {class: 'home'}) do
+ = link_to admin_root_path, class: 'shortcuts-tree' do
+ .nav-icon-container
+ = sprite_icon('overview')
+ %span.nav-item-name
+ Overview
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts conversational_development_index), html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_root_path do
+ %strong.fly-out-top-item-name
+ #{ _('Overview') }
+ %li.divider.fly-out-top-item
+ = nav_link(controller: :dashboard, html_options: {class: 'home'}) do
+ = link_to admin_root_path, title: 'Overview' do
+ %span
+ Dashboard
+ = nav_link(controller: [:admin, :projects]) do
+ = link_to admin_projects_path, title: 'Projects' do
+ %span
+ Projects
+ = nav_link(controller: :users) do
+ = link_to admin_users_path, title: 'Users' do
+ %span
+ Users
+ = nav_link(controller: :groups) do
+ = link_to admin_groups_path, title: 'Groups' do
+ %span
+ Groups
+ = nav_link path: 'jobs#index' do
+ = link_to admin_jobs_path, title: 'Jobs' do
+ %span
+ Jobs
+ = nav_link path: ['runners#index', 'runners#show'] do
+ = link_to admin_runners_path, title: 'Runners' do
+ %span
+ Runners
+ = nav_link path: 'cohorts#index' do
+ = link_to admin_cohorts_path, title: 'Cohorts' do
+ %span
+ Cohorts
+ = nav_link(controller: :conversational_development_index) do
+ = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
+ %span
+ ConvDev Index
+
+ = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
+ = link_to admin_system_info_path do
+ .nav-icon-container
+ = sprite_icon('monitor')
+ %span.nav-item-name
+ Monitoring
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_system_info_path do
+ %strong.fly-out-top-item-name
+ #{ _('Monitoring') }
+ %li.divider.fly-out-top-item
+ = nav_link(controller: :system_info) do
+ = link_to admin_system_info_path, title: 'System Info' do
+ %span
+ System Info
+ = nav_link(controller: :background_jobs) do
+ = link_to admin_background_jobs_path, title: 'Background Jobs' do
+ %span
+ Background Jobs
+ = nav_link(controller: :logs) do
+ = link_to admin_logs_path, title: 'Logs' do
+ %span
+ Logs
+ = nav_link(controller: :health_check) do
+ = link_to admin_health_check_path, title: 'Health Check' do
+ %span
+ Health Check
+ = nav_link(controller: :requests_profiles) do
+ = link_to admin_requests_profiles_path, title: 'Requests Profiles' do
+ %span
+ Requests Profiles
+
+ = nav_link(controller: :broadcast_messages) do
+ = link_to admin_broadcast_messages_path do
+ .nav-icon-container
+ = sprite_icon('messages')
+ %span.nav-item-name
+ Messages
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :broadcast_messages, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_broadcast_messages_path do
+ %strong.fly-out-top-item-name
+ #{ _('Messages') }
+
+ = nav_link(controller: [:hooks, :hook_logs]) do
+ = link_to admin_hooks_path do
+ .nav-icon-container
+ = sprite_icon('hook')
+ %span.nav-item-name
+ System Hooks
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: [:hooks, :hook_logs], html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_hooks_path do
+ %strong.fly-out-top-item-name
+ #{ _('System Hooks') }
+
+ = nav_link(controller: :applications) do
+ = link_to admin_applications_path do
+ .nav-icon-container
+ = sprite_icon('applications')
+ %span.nav-item-name
+ Applications
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :applications, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_applications_path do
+ %strong.fly-out-top-item-name
+ #{ _('Applications') }
+
+ = nav_link(controller: :abuse_reports) do
+ = link_to admin_abuse_reports_path do
+ .nav-icon-container
+ = sprite_icon('slight-frown')
+ %span.nav-item-name
+ Abuse Reports
+ %span.badge.count= number_with_delimiter(AbuseReport.count(:all))
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_abuse_reports_path do
+ %strong.fly-out-top-item-name
+ #{ _('Abuse Reports') }
+ %span.badge.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(AbuseReport.count(:all))
+
+ - if akismet_enabled?
+ = nav_link(controller: :spam_logs) do
+ = link_to admin_spam_logs_path do
+ .nav-icon-container
+ = sprite_icon('spam')
+ %span.nav-item-name
+ Spam Logs
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :spam_logs, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_spam_logs_path do
+ %strong.fly-out-top-item-name
+ #{ _('Spam Logs') }
+
+ = nav_link(controller: :deploy_keys) do
+ = link_to admin_deploy_keys_path do
+ .nav-icon-container
+ = sprite_icon('key')
+ %span.nav-item-name
+ Deploy Keys
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :deploy_keys, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_deploy_keys_path do
+ %strong.fly-out-top-item-name
+ #{ _('Deploy Keys') }
+
+ = nav_link(controller: :services) do
+ = link_to admin_application_settings_services_path do
+ .nav-icon-container
+ = sprite_icon('template')
+ %span.nav-item-name
+ Service Templates
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :services, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_application_settings_services_path do
+ %strong.fly-out-top-item-name
+ #{ _('Service Templates') }
+
+ = nav_link(controller: :labels) do
+ = link_to admin_labels_path do
+ .nav-icon-container
+ = sprite_icon('labels')
+ %span.nav-item-name
+ Labels
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :labels, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_labels_path do
+ %strong.fly-out-top-item-name
+ #{ _('Labels') }
+
+ = nav_link(controller: :appearances) do
+ = link_to admin_appearances_path do
+ .nav-icon-container
+ = sprite_icon('appearance')
+ %span.nav-item-name
+ Appearance
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :appearances, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_appearances_path do
+ %strong.fly-out-top-item-name
+ #{ _('Appearance') }
+
+ = nav_link(controller: :application_settings) do
+ = link_to admin_application_settings_path do
+ .nav-icon-container
+ = sprite_icon('settings')
+ %span.nav-item-name
+ Settings
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_application_settings_path do
+ %strong.fly-out-top-item-name
+ #{ _('Settings') }
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
new file mode 100644
index 00000000000..5ea19c9882d
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -0,0 +1,135 @@
+- issues_count = group_issues_count(state: 'opened')
+- merge_requests_count = group_merge_requests_count(state: 'opened')
+- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
+
+.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ .context-header
+ = link_to group_path(@group), title: @group.name do
+ .avatar-container.s40.group-avatar
+ = group_icon(@group, class: "avatar s40 avatar-tile")
+ .sidebar-context-title
+ = @group.name
+ %ul.sidebar-top-level-items
+ - if group_sidebar_link?(:overview)
+ = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups', 'analytics#show'], html_options: { class: 'home' }) do
+ = link_to group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('project')
+ %span.nav-item-name
+ Overview
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Overview') }
+ %li.divider.fly-out-top-item
+ = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to group_path(@group), title: 'Group details' do
+ %span
+ Details
+
+ - if group_sidebar_link?(:activity)
+ = nav_link(path: 'groups#activity') do
+ = link_to activity_group_path(@group), title: 'Activity' do
+ %span
+ Activity
+
+ - if group_sidebar_link?(:issues)
+ = nav_link(path: issues_sub_menu_items) do
+ = link_to issues_group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('issues')
+ %span.nav-item-name
+ Issues
+ %span.badge.count= number_with_delimiter(issues_count)
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do
+ = link_to issues_group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Issues') }
+ %span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count)
+
+ %li.divider.fly-out-top-item
+ = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
+ = link_to issues_group_path(@group), title: 'List' do
+ %span
+ List
+
+ - if group_sidebar_link?(:boards)
+ = nav_link(path: ['boards#index', 'boards#show']) do
+ = link_to group_boards_path(@group), title: boards_link_text do
+ %span
+ = boards_link_text
+
+ - if group_sidebar_link?(:labels)
+ = nav_link(path: 'labels#index') do
+ = link_to group_labels_path(@group), title: 'Labels' do
+ %span
+ Labels
+
+ - if group_sidebar_link?(:milestones)
+ = nav_link(path: 'milestones#index') do
+ = link_to group_milestones_path(@group), title: 'Milestones' do
+ %span
+ Milestones
+
+ - if group_sidebar_link?(:merge_requests)
+ = nav_link(path: 'groups#merge_requests') do
+ = link_to merge_requests_group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('git-merge')
+ %span.nav-item-name
+ Merge Requests
+ %span.badge.count= number_with_delimiter(merge_requests_count)
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'groups#merge_requests', html_options: { class: "fly-out-top-item" } ) do
+ = link_to merge_requests_group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Merge Requests') }
+ %span.badge.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count)
+
+ - if group_sidebar_link?(:group_members)
+ = nav_link(path: 'group_members#index') do
+ = link_to group_group_members_path(@group) do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name
+ Members
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_group_members_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Members') }
+
+ - if group_sidebar_link?(:settings)
+ = nav_link(path: group_nav_link_paths) do
+ = link_to edit_group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('settings')
+ %span.nav-item-name
+ Settings
+ %ul.sidebar-sub-level-items
+ = nav_link(path: %w[groups#projects groups#edit ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
+ = link_to edit_group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Settings') }
+ %li.divider.fly-out-top-item
+ = nav_link(path: 'groups#edit') do
+ = link_to edit_group_path(@group), title: 'General' do
+ %span
+ General
+
+ = nav_link(path: 'groups#projects') do
+ = link_to projects_group_path(@group), title: 'Projects' do
+ %span
+ Projects
+
+ = nav_link(controller: :ci_cd) do
+ = link_to group_settings_ci_cd_path(@group), title: 'CI / CD' do
+ %span
+ CI / CD
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
new file mode 100644
index 00000000000..c878fcf2808
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -0,0 +1,144 @@
+.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ .context-header
+ = link_to profile_path, title: 'Profile Settings' do
+ .avatar-container.s40.settings-avatar
+ = sprite_icon('user', size: 24)
+ .sidebar-context-title User Settings
+ %ul.sidebar-top-level-items
+ = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
+ = link_to profile_path do
+ .nav-icon-container
+ = sprite_icon('profile')
+ %span.nav-item-name
+ Profile
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'profiles#show', html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_path do
+ %strong.fly-out-top-item-name
+ #{ _('Profile') }
+ = nav_link(controller: [:accounts, :two_factor_auths]) do
+ = link_to profile_account_path do
+ .nav-icon-container
+ = sprite_icon('account')
+ %span.nav-item-name
+ Account
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: [:accounts, :two_factor_auths], html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_account_path do
+ %strong.fly-out-top-item-name
+ #{ _('Account') }
+ - if Gitlab::CurrentSettings.user_oauth_applications?
+ = nav_link(controller: 'oauth/applications') do
+ = link_to applications_profile_path do
+ .nav-icon-container
+ = sprite_icon('applications')
+ %span.nav-item-name
+ Applications
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: 'oauth/applications', html_options: { class: "fly-out-top-item" } ) do
+ = link_to applications_profile_path do
+ %strong.fly-out-top-item-name
+ #{ _('Applications') }
+ = nav_link(controller: :chat_names) do
+ = link_to profile_chat_names_path do
+ .nav-icon-container
+ = sprite_icon('comment')
+ %span.nav-item-name
+ Chat
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :chat_names, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_chat_names_path do
+ %strong.fly-out-top-item-name
+ #{ _('Chat') }
+ = nav_link(controller: :personal_access_tokens) do
+ = link_to profile_personal_access_tokens_path do
+ .nav-icon-container
+ = sprite_icon('token')
+ %span.nav-item-name
+ Access Tokens
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :personal_access_tokens, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_personal_access_tokens_path do
+ %strong.fly-out-top-item-name
+ #{ _('Access Tokens') }
+ = nav_link(controller: :emails) do
+ = link_to profile_emails_path do
+ .nav-icon-container
+ = sprite_icon('mail')
+ %span.nav-item-name
+ Emails
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :emails, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_emails_path do
+ %strong.fly-out-top-item-name
+ #{ _('Emails') }
+ - if current_user.allow_password_authentication?
+ = nav_link(controller: :passwords) do
+ = link_to edit_profile_password_path do
+ .nav-icon-container
+ = sprite_icon('lock')
+ %span.nav-item-name
+ Password
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :passwords, html_options: { class: "fly-out-top-item" } ) do
+ = link_to edit_profile_password_path do
+ %strong.fly-out-top-item-name
+ #{ _('Password') }
+ = nav_link(controller: :notifications) do
+ = link_to profile_notifications_path do
+ .nav-icon-container
+ = sprite_icon('notifications')
+ %span.nav-item-name
+ Notifications
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :notifications, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_notifications_path do
+ %strong.fly-out-top-item-name
+ #{ _('Notifications') }
+ = nav_link(controller: :keys) do
+ = link_to profile_keys_path do
+ .nav-icon-container
+ = sprite_icon('key')
+ %span.nav-item-name
+ SSH Keys
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :keys, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_keys_path do
+ %strong.fly-out-top-item-name
+ #{ _('SSH Keys') }
+ = nav_link(controller: :gpg_keys) do
+ = link_to profile_gpg_keys_path do
+ .nav-icon-container
+ = sprite_icon('key-2')
+ %span.nav-item-name
+ GPG Keys
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :gpg_keys, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_gpg_keys_path do
+ %strong.fly-out-top-item-name
+ #{ _('GPG Keys') }
+ = nav_link(controller: :preferences) do
+ = link_to profile_preferences_path do
+ .nav-icon-container
+ = sprite_icon('preferences')
+ %span.nav-item-name
+ Preferences
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :preferences, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_preferences_path do
+ %strong.fly-out-top-item-name
+ #{ _('Preferences') }
+ = nav_link(path: 'profiles#audit_log') do
+ = link_to audit_log_profile_path do
+ .nav-icon-container
+ = sprite_icon('log')
+ %span.nav-item-name
+ Authentication log
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'profiles#audit_log', html_options: { class: "fly-out-top-item" } ) do
+ = link_to audit_log_profile_path do
+ %strong.fly-out-top-item-name
+ #{ _('Authentication Log') }
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
new file mode 100644
index 00000000000..059571f795f
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -0,0 +1,358 @@
+.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ - can_edit = can?(current_user, :admin_project, @project)
+ .context-header
+ = link_to project_path(@project), title: @project.name do
+ .avatar-container.s40.project-avatar
+ = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile')
+ .sidebar-context-title
+ = @project.name
+ %ul.sidebar-top-level-items
+ = nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do
+ = link_to project_path(@project), class: 'shortcuts-project' do
+ .nav-icon-container
+ = sprite_icon('project')
+ %span.nav-item-name
+ Overview
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Overview') }
+ %li.divider.fly-out-top-item
+ = nav_link(path: 'projects#show') do
+ = link_to project_path(@project), title: _('Project details'), class: 'shortcuts-project' do
+ %span= _('Details')
+
+ = nav_link(path: 'projects#activity') do
+ = 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')
+
+ - if project_nav_tab? :files
+ = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do
+ = link_to project_tree_path(@project), class: 'shortcuts-tree' do
+ .nav-icon-container
+ = sprite_icon('doc_text')
+ %span.nav-item-name
+ Repository
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network), html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_tree_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Repository') }
+ %li.divider.fly-out-top-item
+ = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do
+ = link_to project_tree_path(@project) do
+ #{ _('Files') }
+
+ = nav_link(controller: [:commit, :commits]) do
+ = link_to project_commits_path(@project, current_ref) do
+ #{ _('Commits') }
+
+ = nav_link(html_options: {class: branches_tab_class}) do
+ = link_to project_branches_path(@project) do
+ #{ _('Branches') }
+
+ = nav_link(controller: [:tags, :releases]) do
+ = link_to project_tags_path(@project) do
+ #{ _('Tags') }
+
+ = nav_link(path: 'graphs#show') do
+ = link_to project_graph_path(@project, current_ref) do
+ #{ _('Contributors') }
+
+ = nav_link(controller: %w(network)) do
+ = link_to project_network_path(@project, current_ref) do
+ #{ s_('ProjectNetworkGraph|Graph') }
+
+ = nav_link(controller: :compare) do
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do
+ #{ _('Compare') }
+
+ = nav_link(path: 'graphs#charts') do
+ = link_to charts_project_graph_path(@project, current_ref) do
+ #{ _('Charts') }
+
+ - if project_nav_tab? :container_registry
+ = nav_link(controller: %w[projects/registry/repositories]) do
+ = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do
+ .nav-icon-container
+ = sprite_icon('disk')
+ %span.nav-item-name
+ Registry
+
+ - if project_nav_tab? :issues
+ = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do
+ = link_to project_issues_path(@project), class: 'shortcuts-issues' do
+ .nav-icon-container
+ = sprite_icon('issues')
+ %span.nav-item-name
+ Issues
+ - if @project.issues_enabled?
+ %span.badge.count.issue_counter
+ = number_with_delimiter(@project.open_issues_count)
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: :issues, html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_issues_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Issues') }
+ - if @project.issues_enabled?
+ %span.badge.count.issue_counter.fly-out-badge
+ = number_with_delimiter(@project.open_issues_count)
+ %li.divider.fly-out-top-item
+ = nav_link(controller: :issues, action: :index) do
+ = link_to project_issues_path(@project), title: 'Issues' do
+ %span
+ List
+
+ = nav_link(controller: :boards) do
+ = link_to project_boards_path(@project), title: boards_link_text do
+ %span
+ = boards_link_text
+
+ = nav_link(controller: :labels) do
+ = link_to project_labels_path(@project), title: 'Labels' do
+ %span
+ Labels
+
+ = nav_link(controller: :milestones) do
+ = link_to project_milestones_path(@project), title: 'Milestones' do
+ %span
+ Milestones
+ - if project_nav_tab? :external_issue_tracker
+ = nav_link do
+ - issue_tracker = @project.external_issue_tracker
+ = link_to issue_tracker.issue_tracker_path, class: 'shortcuts-external_tracker' do
+ .nav-icon-container
+ = sprite_icon('issue-external')
+ %span.nav-item-name
+ = issue_tracker.title
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to issue_tracker.issue_tracker_path do
+ %strong.fly-out-top-item-name
+ = issue_tracker.title
+
+ - if project_nav_tab? :merge_requests
+ = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
+ = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests' do
+ .nav-icon-container
+ = sprite_icon('git-merge')
+ %span.nav-item-name
+ Merge Requests
+ %span.badge.count.merge_counter.js-merge-counter
+ = number_with_delimiter(@project.open_merge_requests_count)
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :merge_requests, html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_merge_requests_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Merge Requests') }
+ %span.badge.count.merge_counter.js-merge-counter.fly-out-badge
+ = number_with_delimiter(@project.open_merge_requests_count)
+
+ - if project_nav_tab? :pipelines
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts, :clusters, :user, :gcp]) do
+ = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines' do
+ .nav-icon-container
+ = sprite_icon('pipeline')
+ %span.nav-item-name
+ CI / CD
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts, :clusters, :user, :gcp], html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_pipelines_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('CI / CD') }
+ %li.divider.fly-out-top-item
+ - if project_nav_tab? :pipelines
+ = nav_link(path: ['pipelines#index', 'pipelines#show']) do
+ = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
+ %span
+ Pipelines
+
+ - if project_nav_tab? :builds
+ = nav_link(controller: [:jobs, :artifacts]) do
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ %span
+ Jobs
+
+ - if project_nav_tab? :pipelines
+ = nav_link(controller: :pipeline_schedules) do
+ = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do
+ %span
+ Schedules
+
+ - if project_nav_tab? :environments
+ = nav_link(controller: :environments) do
+ = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do
+ %span
+ Environments
+
+ - if project_nav_tab? :clusters
+ - show_cluster_hint = show_gke_cluster_integration_callout?(@project)
+ = nav_link(controller: [:clusters, :user, :gcp]) do
+ = link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-cluster' do
+ %span
+ = _('Kubernetes')
+ - if show_cluster_hint
+ .feature-highlight.js-feature-highlight{ disabled: true,
+ data: { trigger: 'manual',
+ container: 'body',
+ toggle: 'popover',
+ placement: 'right',
+ highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION,
+ highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION],
+ dismiss_endpoint: user_callouts_path } }
+ - if show_cluster_hint
+ .feature-highlight-popover-content
+ = image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration'
+ .feature-highlight-popover-sub-content
+ %p= _('Allows you to add and manage Kubernetes clusters.')
+ %p
+ = _('Protip:')
+ = link_to 'Auto DevOps', help_page_path('topics/autodevops/index.md')
+ %span= _('uses Kubernetes clusters to deploy your code!')
+ %hr
+ %button.btn.btn-create.btn-xs.dismiss-feature-highlight{ type: 'button' }
+ %span= _("Got it!")
+ = sprite_icon('thumb-up')
+
+ - if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
+ = nav_link(path: 'pipelines#charts') do
+ = link_to charts_project_pipelines_path(@project), title: 'Charts', class: 'shortcuts-pipelines-charts' do
+ %span
+ Charts
+
+ - if project_nav_tab? :wiki
+ = nav_link(controller: :wikis) do
+ = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do
+ .nav-icon-container
+ = sprite_icon('book')
+ %span.nav-item-name
+ Wiki
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do
+ = link_to get_project_wiki_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Wiki') }
+
+ - if project_nav_tab? :snippets
+ = nav_link(controller: :snippets) do
+ = link_to project_snippets_path(@project), class: 'shortcuts-snippets' do
+ .nav-icon-container
+ = sprite_icon('snippet')
+ %span.nav-item-name
+ Snippets
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :snippets, html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_snippets_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Snippets') }
+
+ - if project_nav_tab? :settings
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do
+ = link_to edit_project_path(@project), class: 'shortcuts-tree' do
+ .nav-icon-container
+ = sprite_icon('settings')
+ %span.nav-item-name.qa-settings-item
+ Settings
+
+ %ul.sidebar-sub-level-items
+ - can_edit = can?(current_user, :admin_project, @project)
+ - if can_edit
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show], html_options: { class: "fly-out-top-item" } ) do
+ = link_to edit_project_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Settings') }
+ %li.divider.fly-out-top-item
+ = nav_link(path: %w[projects#edit]) do
+ = link_to edit_project_path(@project), title: 'General' do
+ %span
+ General
+ = nav_link(controller: :project_members) do
+ = link_to project_project_members_path(@project), title: 'Members' do
+ %span
+ Members
+ - if can_edit
+ = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
+ = link_to project_settings_integrations_path(@project), title: 'Integrations' do
+ %span
+ Integrations
+ = nav_link(controller: :repository) do
+ = link_to project_settings_repository_path(@project), title: 'Repository' do
+ %span
+ Repository
+ - if @project.feature_available?(:builds, current_user)
+ = nav_link(controller: :ci_cd) do
+ = link_to project_settings_ci_cd_path(@project), title: 'CI / CD' do
+ %span
+ CI / CD
+ - if @project.pages_available?
+ = nav_link(controller: :pages) do
+ = link_to project_pages_path(@project), title: 'Pages' do
+ %span
+ Pages
+
+ - else
+ = nav_link(controller: :project_members) do
+ = link_to project_settings_members_path(@project), title: 'Members', class: 'shortcuts-tree' do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name
+ Members
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_project_members_path(@project) do
+ %strong.fly-out-top-item-name
+ #{ _('Members') }
+
+ = render 'shared/sidebar_toggle_button'
+
+ -# Shortcut to Project > Activity
+ %li.hidden
+ = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
+ %span
+ Activity
+
+ -# Shortcut to Repository > Graph (formerly, Network)
+ - if project_nav_tab? :network
+ %li.hidden
+ = link_to project_network_path(@project, current_ref), title: 'Network', class: 'shortcuts-network' do
+ Graph
+
+ -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
+ - unless @project.empty_repo?
+ %li.hidden
+ = link_to charts_project_graph_path(@project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
+ Charts
+
+ -# Shortcut to Issues > New Issue
+ - if project_nav_tab?(:issues)
+ %li.hidden
+ = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
+ Create a new issue
+
+ -# Shortcut to Pipelines > Jobs
+ - if project_nav_tab? :builds
+ %li.hidden
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ Jobs
+
+ -# Shortcut to commits page
+ - if project_nav_tab? :commits
+ %li.hidden
+ = link_to project_commits_path(@project), title: 'Commits', class: 'shortcuts-commits' do
+ Commits
+
+ -# Shortcut to issue boards
+ - if project_nav_tab?(:issues)
+ %li.hidden
+ = link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
diff --git a/app/views/layouts/nav_only.html.haml b/app/views/layouts/nav_only.html.haml
new file mode 100644
index 00000000000..0811211f7b2
--- /dev/null
+++ b/app/views/layouts/nav_only.html.haml
@@ -0,0 +1,14 @@
+!!! 5
+%html{ lang: I18n.locale, class: page_class }
+ = render "layouts/head"
+ %body{ class: "#{user_application_theme} #{@body_class} nav-only", data: { page: body_data_page } }
+ = render 'peek/bar'
+ = render "layouts/header/default"
+ = render 'shared/outdated_browser'
+ .mobile-overlay
+ .alert-wrapper
+ = render "layouts/broadcast"
+ = yield :flash_message
+ = render "layouts/flash"
+ .content{ id: "content-body" }
+ = yield
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index 40bf45cece7..ab8b1271212 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -20,7 +20,7 @@
#{link_to "View it on GitLab", @target_url}.
%br
-# Don't link the host in the line below, one link in the email is easier to quickly click than two.
- You're receiving this email because of your account on #{Gitlab.config.gitlab.host}.
+ You're receiving this email because #{notification_reason_text(@reason)}.
If you'd like to receive fewer emails, you can
- if @labels_url
adjust your #{link_to 'label subscriptions', @labels_url}.
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index b4ce02eead8..de48f548a1b 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -9,4 +9,4 @@
<% end -%>
<% end -%>
-You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
+<%= "You're receiving this email because #{notification_reason_text(@reason)}." %>
diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml
index 34bcd2a8b3a..03b387f8181 100644
--- a/app/views/layouts/oauth_error.html.haml
+++ b/app/views/layouts/oauth_error.html.haml
@@ -19,7 +19,7 @@
h3 {
color: #456;
font-size: 22px;
- font-weight: bold;
+ font-weight: 600;
margin-bottom: 6px;
}
diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml
index 0ee8a57dbd4..67aa05b655c 100644
--- a/app/views/layouts/profile.html.haml
+++ b/app/views/layouts/profile.html.haml
@@ -2,5 +2,6 @@
- header_title "User Settings", profile_path unless header_title
- sidebar "dashboard"
- nav "profile"
+- @left_sidebar = true
= render template: "layouts/application"
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 3f5b0c54e50..6b51483810e 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -1,20 +1,14 @@
-- page_title @project.name_with_namespace
+- page_title @project.full_name
- page_description @project.description unless page_description
- header_title project_title(@project) unless header_title
- nav "project"
+- @left_sidebar = true
- content_for :project_javascripts do
- project = @target_project || @project
- if current_user
+ -# haml-lint:disable InlineJavaScript
:javascript
- window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
-
-- content_for :header_content do
- .js-dropdown-menu-projects
- .dropdown-menu.dropdown-select.dropdown-menu-projects
- = dropdown_title("Go to a project")
- = dropdown_filter("Search your projects")
- = dropdown_content
- = dropdown_loading
+ window.uploads_path = "#{project_uploads_path(project)}";
= render template: "layouts/application"
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 57971205e0e..849075a0ba5 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -2,6 +2,7 @@
- content_for :page_specific_javascripts do
- if @snippet && current_user
+ -# haml-lint:disable InlineJavaScript
:javascript
window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index a80518f7986..94bd6f96dbc 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -1,10 +1,15 @@
- discussion = @note.discussion if @note.part_of_discussion?
+- diff_discussion = discussion&.diff_discussion?
+- on_image = discussion.on_image? if diff_discussion
+
- if discussion
+ - phrase_end_char = on_image ? "." : ":"
+
%p.details
- = succeed ':' do
+ = succeed phrase_end_char do
= link_to @note.author_name, user_url(@note.author)
- - if discussion.diff_discussion?
+ - if diff_discussion
- if discussion.new_discussion?
started a new discussion
- else
@@ -17,11 +22,11 @@
- else
commented on a #{link_to 'discussion', @target_url}
-- elsif current_application_settings.email_author_in_body
+- elsif Gitlab::CurrentSettings.email_author_in_body
%p.details
#{link_to @note.author_name, user_url(@note.author)} commented:
-- if discussion&.diff_discussion?
+- if diff_discussion && !on_image
= content_for :head do
= stylesheet_link_tag 'mailers/highlighted_diff_email'
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index cb2e7fab6d5..c319cb55e87 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -12,7 +12,7 @@
<%= ":" -%>
-<% elsif current_application_settings.email_author_in_body -%>
+<% elsif Gitlab::CurrentSettings.email_author_in_body -%>
<%= "#{@note.author_name} commented:" -%>
diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml
index bc12e38675f..b35d4b7502d 100644
--- a/app/views/notify/closed_issue_email.text.haml
+++ b/app/views/notify/closed_issue_email.text.haml
@@ -1,3 +1,3 @@
Issue was closed by #{@updated_by.name}
-Issue ##{@issue.iid}: #{namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)}
+Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index d0c96b83976..c4e06cb3cb1 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name}
-Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)}
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml
index 40f7d61fe19..472c31e9a5e 100644
--- a/app/views/notify/issue_moved_email.html.haml
+++ b/app/views/notify/issue_moved_email.html.haml
@@ -2,5 +2,5 @@
Issue was moved to another project.
%p
New issue:
- = link_to namespace_project_issue_url(@new_project.namespace, @new_project, @new_issue) do
+ = link_to project_issue_url(@new_project, @new_issue) do
= @new_issue.title
diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb
index b3bd43c2055..66ede43635b 100644
--- a/app/views/notify/issue_moved_email.text.erb
+++ b/app/views/notify/issue_moved_email.text.erb
@@ -1,4 +1,4 @@
Issue was moved to another project.
New issue location:
-<%= namespace_project_issue_url(@new_project.namespace, @new_project, @new_issue) %>
+<%= project_issue_url(@new_project, @new_issue) %>
diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb
index e6ab3fcde77..4200881f7e8 100644
--- a/app/views/notify/issue_status_changed_email.text.erb
+++ b/app/views/notify/issue_status_changed_email.text.erb
@@ -1,4 +1,4 @@
Issue was <%= @issue_status %> by <%= @updated_by.name %>
-Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index 4c9719ba732..ae2a2933865 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name}
-Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)}
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 46c1c9dee0b..661c23bcbe2 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was merged
-Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)}
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/new_email_email.html.haml b/app/views/notify/new_email_email.html.haml
deleted file mode 100644
index 4a0448a573c..00000000000
--- a/app/views/notify/new_email_email.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%p
- Hi #{@user.name}!
-%p
- A new email was added to your account:
-%p
- email:
- %code= @email.email
-%p
- If this email was added in error, you can remove it here:
- = link_to "Emails", profile_emails_url
diff --git a/app/views/notify/new_email_email.text.erb b/app/views/notify/new_email_email.text.erb
deleted file mode 100644
index 51cba99ad0d..00000000000
--- a/app/views/notify/new_email_email.text.erb
+++ /dev/null
@@ -1,7 +0,0 @@
-Hi <%= @user.name %>!
-
-A new email was added to your account:
-
-email.................. <%= @email.email %>
-
-If this email was added in error, you can remove it here: <%= profile_emails_url %>
diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml
new file mode 100644
index 00000000000..4b9350c4e88
--- /dev/null
+++ b/app/views/notify/new_gpg_key_email.html.haml
@@ -0,0 +1,10 @@
+%p
+ Hi #{@user.name}!
+%p
+ A new GPG key was added to your account:
+%p
+ Fingerprint:
+ %code= @gpg_key.fingerprint
+%p
+ If this key was added in error, you can remove it under
+ = link_to "GPG Keys", profile_gpg_keys_url
diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb
new file mode 100644
index 00000000000..80b5a1fd7ff
--- /dev/null
+++ b/app/views/notify/new_gpg_key_email.text.erb
@@ -0,0 +1,7 @@
+Hi <%= @user.name %>!
+
+A new GPG key was added to your account:
+
+Fingerprint: <%= @gpg_key.fingerprint %>
+
+If this key was added in error, you can remove it at <%= profile_gpg_keys_url %>
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index eb5157ccac9..e6cdaf85c0d 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -1,4 +1,4 @@
-- if current_application_settings.email_author_in_body
+- if Gitlab::CurrentSettings.email_author_in_body
%p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index 13f1ac08e94..3c716f77296 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -1,6 +1,6 @@
New Issue was created.
-Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= @issue.author_name %>
Assignee: <%= @issue.assignee_list %>
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
index f19ac3adfc7..23213106c5b 100644
--- a/app/views/notify/new_mention_in_issue_email.text.erb
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -1,6 +1,6 @@
You have been mentioned in an issue.
-Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= @issue.author_name %>
Assignee: <%= @issue.assignee_list %>
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 5bf0282e097..6fcebb22fc4 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -1,6 +1,6 @@
You have been mentioned in Merge Request <%= @merge_request.to_reference %>
-<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %>
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 951c96bdb9c..0a9adc6f243 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -1,4 +1,4 @@
-- if current_application_settings.email_author_in_body
+- if Gitlab::CurrentSettings.email_author_in_body
%p.details
#{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index 3c8f178ac77..7d98400e6fe 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -1,6 +1,6 @@
New Merge Request <%= @merge_request.to_reference %>
-<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %>
diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml
index 6b9b42dcf37..db4424a01f9 100644
--- a/app/views/notify/new_user_email.html.haml
+++ b/app/views/notify/new_user_email.html.haml
@@ -1,7 +1,7 @@
%p
Hi #{@user['name']}!
%p
- - if Gitlab.config.gitlab.signup_enabled
+ - if Gitlab::CurrentSettings.allow_signup?
Your account has been created successfully.
- else
The Administrator created an account for you. Now you are a member of the company GitLab application.
diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml
new file mode 100644
index 00000000000..34ce4238a12
--- /dev/null
+++ b/app/views/notify/pages_domain_disabled_email.html.haml
@@ -0,0 +1,15 @@
+%p
+ Following a verification check, your GitLab Pages custom domain has been
+ %strong disabled.
+ This means that your content is no longer visible at #{link_to @domain.url, @domain.url}
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ If this domain has been disabled in error, please follow
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ to verify and re-enable your domain.
+%p
+ If you no longer wish to use this domain with GitLab Pages, please remove it
+ from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_disabled_email.text.haml b/app/views/notify/pages_domain_disabled_email.text.haml
new file mode 100644
index 00000000000..4e81b054b1f
--- /dev/null
+++ b/app/views/notify/pages_domain_disabled_email.text.haml
@@ -0,0 +1,13 @@
+Following a verification check, your GitLab Pages custom domain has been
+**disabled**. This means that your content is no longer visible at #{@domain.url}
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+If this domain has been disabled in error, please follow these instructions
+to verify and re-enable your domain:
+
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+
+If you no longer wish to use this domain with GitLab Pages, please remove it
+from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_enabled_email.html.haml b/app/views/notify/pages_domain_enabled_email.html.haml
new file mode 100644
index 00000000000..db09e503f65
--- /dev/null
+++ b/app/views/notify/pages_domain_enabled_email.html.haml
@@ -0,0 +1,11 @@
+%p
+ Following a verification check, your GitLab Pages custom domain has been
+ enabled. You should now be able to view your content at #{link_to @domain.url, @domain.url}
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ Please visit
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ for more information about custom domain verification.
diff --git a/app/views/notify/pages_domain_enabled_email.text.haml b/app/views/notify/pages_domain_enabled_email.text.haml
new file mode 100644
index 00000000000..1ed1dbb8315
--- /dev/null
+++ b/app/views/notify/pages_domain_enabled_email.text.haml
@@ -0,0 +1,9 @@
+Following a verification check, your GitLab Pages custom domain has been
+enabled. You should now be able to view your content at #{@domain.url}
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+Please visit
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+for more information about custom domain verification.
diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml
new file mode 100644
index 00000000000..0bb0eb09fd5
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_failed_email.html.haml
@@ -0,0 +1,17 @@
+%p
+ Verification has failed for one of your GitLab Pages custom domains!
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ Unless you take action, it will be disabled on
+ %strong= @domain.enabled_until.strftime('%F %T.')
+ Until then, you can view your content at #{link_to @domain.url, @domain.url}
+%p
+ Please visit
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ for more information about custom domain verification.
+%p
+ If you no longer wish to use this domain with GitLab Pages, please remove it
+ from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_verification_failed_email.text.haml b/app/views/notify/pages_domain_verification_failed_email.text.haml
new file mode 100644
index 00000000000..c14e0e0c24d
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_failed_email.text.haml
@@ -0,0 +1,14 @@
+Verification has failed for one of your GitLab Pages custom domains!
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+Unless you take action, it will be disabled on *#{@domain.enabled_until.strftime('%F %T')}*.
+Until then, you can view your content at #{@domain.url}
+
+Please visit
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+for more information about custom domain verification.
+
+If you no longer wish to use this domain with GitLab Pages, please remove it
+from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_verification_succeeded_email.html.haml b/app/views/notify/pages_domain_verification_succeeded_email.html.haml
new file mode 100644
index 00000000000..2ead3187b10
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_succeeded_email.html.haml
@@ -0,0 +1,13 @@
+%p
+ One of your GitLab Pages custom domains has been successfully verified!
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ This is a notification. No action is required on your part. You can view your
+ content at #{link_to @domain.url, @domain.url}
+%p
+ Please visit
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ for more information about custom domain verification.
diff --git a/app/views/notify/pages_domain_verification_succeeded_email.text.haml b/app/views/notify/pages_domain_verification_succeeded_email.text.haml
new file mode 100644
index 00000000000..e7cdbdee420
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_succeeded_email.text.haml
@@ -0,0 +1,10 @@
+One of your GitLab Pages custom domains has been successfully verified!
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+No action is required on your part. You can view your content at #{@domain.url}
+
+Please visit
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+for more information about custom domain verification.
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index b7a60938132..38dab104eb5 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -31,7 +31,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
= @pipeline.ref
@@ -42,7 +42,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "Commit icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a{ href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
= @pipeline.short_sha
@@ -60,7 +60,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.author, commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.committer, commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml
index 3f16885b8e3..7b06e8afa0b 100644
--- a/app/views/notify/pipeline_success_email.html.haml
+++ b/app/views/notify/pipeline_success_email.html.haml
@@ -31,7 +31,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
= @pipeline.ref
@@ -42,7 +42,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "Commit icon" }/
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a{ href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
= @pipeline.short_sha
@@ -60,7 +60,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.author, commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.committer, commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
@@ -109,7 +109,7 @@
API
%tr
%td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" }
- - job_count = @pipeline.statuses.latest.size
+ - job_count = @pipeline.total_size
- stage_count = @pipeline.stages_count
successfully completed
#{job_count} #{'job'.pluralize(job_count)}
diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb
index ddced2279e1..39622cf7f02 100644
--- a/app/views/notify/pipeline_success_email.text.erb
+++ b/app/views/notify/pipeline_success_email.text.erb
@@ -22,11 +22,11 @@ Committed by: <%= commit.committer_name %>
<% end -%>
<% end -%>
-<% build_count = @pipeline.statuses.latest.size -%>
+<% job_count = @pipeline.total_size -%>
<% stage_count = @pipeline.stages_count -%>
<% if @pipeline.user -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
-successfully completed <%= build_count %> <%= 'build'.pluralize(build_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>.
+successfully completed <%= job_count %> <%= 'job'.pluralize(job_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>.
diff --git a/app/views/notify/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml
index 3def26342a1..71c62f6be4e 100644
--- a/app/views/notify/project_was_exported_email.html.haml
+++ b/app/views/notify/project_was_exported_email.html.haml
@@ -2,7 +2,7 @@
Project #{@project.name} was exported successfully.
%p
The project export can be downloaded from:
- = link_to download_export_namespace_project_url(@project.namespace, @project), rel: 'nofollow', download: '' do
- = @project.name_with_namespace + " export"
+ = link_to download_export_project_url(@project), rel: 'nofollow', download: '' do
+ = @project.full_name + " export"
%p
The download link will expire in 24 hours.
diff --git a/app/views/notify/project_was_exported_email.text.erb b/app/views/notify/project_was_exported_email.text.erb
index 42c4d176876..cd3a1f7934f 100644
--- a/app/views/notify/project_was_exported_email.text.erb
+++ b/app/views/notify/project_was_exported_email.text.erb
@@ -1,6 +1,6 @@
Project <%= @project.name %> was exported successfully.
The project export can be downloaded from:
-<%= download_export_namespace_project_url(@project.namespace, @project) %>
+<%= download_export_project_url(@project) %>
The download link will expire in 24 hours.
diff --git a/app/views/notify/project_was_moved_email.html.haml b/app/views/notify/project_was_moved_email.html.haml
index 87b3ff7f0b3..1b6b1a81665 100644
--- a/app/views/notify/project_was_moved_email.html.haml
+++ b/app/views/notify/project_was_moved_email.html.haml
@@ -2,8 +2,8 @@
Project #{@old_path_with_namespace} was moved to another location
%p
The project is now located under
- = link_to namespace_project_url(@project.namespace, @project) do
- = @project.name_with_namespace
+ = link_to project_url(@project) do
+ = @project.full_name
%p
To update the remote url in your local repository run (for ssh):
%p{ style: "background: #f5f5f5; padding:10px; border:1px solid #ddd" }
diff --git a/app/views/notify/project_was_moved_email.text.erb b/app/views/notify/project_was_moved_email.text.erb
index b2c5f71e465..7c45163e0e8 100644
--- a/app/views/notify/project_was_moved_email.text.erb
+++ b/app/views/notify/project_was_moved_email.text.erb
@@ -1,7 +1,7 @@
Project <%= @old_path_with_namespace %> was moved to another location
The project is now located under
-<%= namespace_project_url(@project.namespace, @project) %>
+<%= project_url(@project) %>
To update the remote url in your local repository run (for ssh):
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index 546376aeed8..5c5520f4cb8 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -3,7 +3,7 @@
%h3
#{@message.author_name} #{@message.action_name} #{@message.ref_type} #{@message.ref_name}
- at #{link_to(@message.project_name_with_namespace, namespace_project_url(@message.project_namespace, @message.project))}
+ at #{link_to(@message.project_name_with_namespace, project_url(@message.project))}
- if @message.compare
- if @message.reverse_compare?
@@ -17,7 +17,7 @@
%ul
- @message.commits.each do |commit|
%li
- %strong= link_to(commit.short_id, namespace_project_commit_url(@message.project_namespace, @message.project, commit))
+ %strong= link_to(commit.short_id, project_commit_url(@message.project, commit))
%div
%span by #{commit.author_name}
%i at #{commit.committed_date.to_s(:iso8601)}
diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb
index b0d380af8fc..2881f3e699e 100644
--- a/app/views/notify/resolved_all_discussions_email.text.erb
+++ b/app/views/notify/resolved_all_discussions_email.text.erb
@@ -1,3 +1,3 @@
All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
-<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml
new file mode 100644
index 00000000000..b4d86e1601c
--- /dev/null
+++ b/app/views/peek/_bar.html.haml
@@ -0,0 +1,12 @@
+- return unless peek_enabled?
+
+#js-peek{ data: { env: Peek.env,
+ request_id: Peek.request_id,
+ peek_url: peek_routes.results_url,
+ profile_url: url_for(params.merge(lineprofiler: 'true')) },
+ class: Peek.env }
+
+#peek-view-performance-bar.hidden
+ = render_server_response_time
+ %span#serverstats
+ %ul.performance-bar
diff --git a/app/views/peek/views/_gc.html.haml b/app/views/peek/views/_gc.html.haml
new file mode 100644
index 00000000000..9fc83e56ee7
--- /dev/null
+++ b/app/views/peek/views/_gc.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%span.bold
+ %span{ title: 'Invoke Time', data: { defer_to: "#{view.defer_key}-gc_time" } }...
+ \/
+ %span{ title: 'Invoke Count', data: { defer_to: "#{view.defer_key}-invokes" } }...
+gc
diff --git a/app/views/peek/views/_mysql2.html.haml b/app/views/peek/views/_mysql2.html.haml
deleted file mode 100644
index ac811a10ef5..00000000000
--- a/app/views/peek/views/_mysql2.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- local_assigns.fetch(:view)
-
-= render 'peek/views/sql', view: view
-mysql
diff --git a/app/views/peek/views/_pg.html.haml b/app/views/peek/views/_pg.html.haml
deleted file mode 100644
index ee94c2f3274..00000000000
--- a/app/views/peek/views/_pg.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- local_assigns.fetch(:view)
-
-= render 'peek/views/sql', view: view
-pg
diff --git a/app/views/peek/views/_redis.html.haml b/app/views/peek/views/_redis.html.haml
new file mode 100644
index 00000000000..f7fba6c95fc
--- /dev/null
+++ b/app/views/peek/views/_redis.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%span.bold
+ %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
+ \/
+ %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
+redis
diff --git a/app/views/peek/views/_sidekiq.html.haml b/app/views/peek/views/_sidekiq.html.haml
new file mode 100644
index 00000000000..7efbc05890d
--- /dev/null
+++ b/app/views/peek/views/_sidekiq.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%span.bold
+ %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
+ \/
+ %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
+sidekiq
diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml
deleted file mode 100644
index 16fc010f66f..00000000000
--- a/app/views/peek/views/_sql.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-%strong
- %a#peek-show-queries{ href: '#' }
- %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
- \/
- %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
-#modal-peek-pg-queries.modal{ tabindex: -1 }
- .modal-dialog
- #modal-peek-pg-queries-content.modal-content
- .modal-header
- %a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h4
- SQL queries
- .modal-body{ data: { defer_to: "#{view.defer_key}-queries" } }...
diff --git a/app/views/profiles/_head.html.haml b/app/views/profiles/_head.html.haml
deleted file mode 100644
index 83ae9129807..00000000000
--- a/app/views/profiles/_head.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('profile')
diff --git a/app/views/profiles/accounts/_reset_token.html.haml b/app/views/profiles/accounts/_reset_token.html.haml
deleted file mode 100644
index c31a4a8ecd4..00000000000
--- a/app/views/profiles/accounts/_reset_token.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- 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 a319b18e507..02263095599 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,41 +1,21 @@
- page_title "Account"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
- if current_user.ldap_user?
.alert.alert-info
Some options are unavailable for LDAP accounts
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
- Private Tokens
- %p
- Keep these tokens secret, anyone with access to them can interact with
- GitLab as if they were you.
- .col-lg-9.private-tokens-reset
- = 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?
- = 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
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Two-Factor Authentication
%p
Increase your account's security by enabling Two-Factor Authentication (2FA).
- .col-lg-9
+ .col-lg-8
%p
Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'}
- if current_user.two_factor_enabled?
= link_to 'Manage two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-info'
- = link_to 'Disable', profile_two_factor_auth_path,
- method: :delete,
- data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
- class: 'btn btn-danger'
- else
.append-bottom-10
= link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success'
@@ -43,12 +23,12 @@
%hr
- if button_based_providers.any?
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Social sign-in
%p
Activate signin with one of the following services
- .col-lg-9
+ .col-lg-8
%label.label-light
Connected Accounts
%p Click on icon to activate signin with one of the following services
@@ -69,12 +49,14 @@
%hr
- if current_user.can_change_username?
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0.warning-title
Change username
%p
- Changing your username will change path to all personal projects!
- .col-lg-9
+ Changing your username can have unintended side effects.
+ = succeed '.' do
+ = link_to 'Learn more', help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank'
+ .col-lg-8
= form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f|
.form-group
= f.label :username, "Path", class: "label-light"
@@ -91,25 +73,31 @@
Update username
%hr
-- if signup_enabled?
- .row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0.danger-title
- Remove account
- .col-lg-9
- - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+.row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0.danger-title
+ = s_('Profiles|Delete account')
+ .col-lg-8
+ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ %p
+ = s_('Profiles|Deleting an account has the following effects:')
+ = render 'users/deletion_guidance', user: current_user
+
+ %button#delete-account-button.btn.btn-danger.disabled{ data: { toggle: 'modal',
+ target: '#delete-account-modal' } }
+ = s_('Profiles|Delete account')
+
+ #delete-account-modal{ data: { action_url: user_registration_path,
+ confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
+ username: current_user.username } }
+ - else
+ - if @user.solo_owned_groups.present?
+ %p
+ = s_('Profiles|Your account is currently an owner in these groups:')
+ %strong= @user.solo_owned_groups.map(&:name).join(', ')
%p
- Deleting an account has the following effects:
- = render 'users/deletion_guidance', user: current_user
- = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove"
+ = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
- else
- - if @user.solo_owned_groups.present?
- %p
- Your account is currently an owner in these groups:
- %strong= @user.solo_owned_groups.map(&:name).join(', ')
- %p
- You must transfer ownership or delete these groups before you can delete your account.
- - else
- %p
- You don't have access to delete this user.
+ %p
+ = s_("Profiles|You don't have access to delete this user.")
.append-bottom-default
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index a24b7fd101d..a924369050b 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,11 +1,11 @@
- page_title "Authentication log"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h3.prepend-top-0
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
= page_title
%p
This is a security log of important events involving your account.
- .col-lg-9
+ .col-lg-8
= render 'event_table', events: @events
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 1ec1e7c70e4..c7094800fb2 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -4,13 +4,13 @@
%td
%strong
- if can?(current_user, :read_project, project)
- = link_to project.name_with_namespace, project_path(project)
+ = link_to project.full_name, project_path(project)
- else
.light N/A
%td
%strong
- if can?(current_user, :admin_project, project)
- = link_to service.title, edit_namespace_project_service_path(project.namespace, project, service)
+ = link_to service.title, edit_project_service_path(project, service)
- else
= service.title
%td
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 20cc636b2da..4b6e419af50 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,14 +1,14 @@
- page_title 'Chat'
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
You can see your Chat accounts.
- .col-lg-9
+ .col-lg-8
%h5 Active chat names (#{@chat_names.size})
- if @chat_names.present?
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index f5a323dbaf8..e3c2bd1150e 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,13 +1,13 @@
- page_title "Emails"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
Control emails linked to your account
- .col-lg-9
+ .col-lg-8
%h4.prepend-top-0
Add email address
= form_for 'email', url: profile_emails_path do |f|
@@ -31,19 +31,25 @@
All email addresses will be used to identify your commits.
%ul.well-list
%li
- = @primary
+ = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? }
%span.pull-right
%span.label.label-success Primary email
- - if @primary === current_user.public_email
+ - if @primary_email === current_user.public_email
%span.label.label-info Public email
- - if @primary === current_user.notification_email
+ - if @primary_email === current_user.notification_email
%span.label.label-info Notification email
- @emails.each do |email|
%li
- = email.email
+ = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
%span.pull-right
- if email.email === current_user.public_email
%span.label.label-info Public email
- if email.email === current_user.notification_email
%span.label.label-info Notification email
- = link_to 'Remove', profile_email_path(email), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-warning prepend-left-10'
+ - unless email.confirmed?
+ - confirm_title = "#{email.confirmation_sent_at ? 'Resend' : 'Send'} confirmation email"
+ = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning prepend-left-10'
+
+ = link_to profile_email_path(email), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do
+ %span.sr-only Remove
+ = icon('trash')
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
new file mode 100644
index 00000000000..3fcf563d970
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -0,0 +1,10 @@
+%div
+ = form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f|
+ = form_errors(@gpg_key)
+
+ .form-group
+ = f.label :key, class: 'label-light'
+ = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'."
+
+ .prepend-top-default
+ = f.submit 'Add key', class: "btn btn-create"
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
new file mode 100644
index 00000000000..5ed517c1ef6
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -0,0 +1,25 @@
+%li.key-list-item
+ .pull-left.append-right-10
+ = icon 'key', class: "settings-list-icon hidden-xs"
+ .key-list-item-info
+ - key.emails_with_verified_status.map do |email, verified|
+ = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified }
+
+ .description
+ %code= key.fingerprint
+ - if key.subkeys.present?
+ .subkeys
+ %span.bold Subkeys:
+ %ul.subkeys-list
+ - key.subkeys.each do |subkey|
+ %li
+ %code= subkey.fingerprint
+ .pull-right
+ %span.key-created-at
+ created #{time_ago_with_tooltip(key.created_at)}
+ = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure? Removing this GPG key does not affect already signed commits.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only Remove
+ = icon('trash')
+ = link_to revoke_profile_gpg_key_path(key), data: { confirm: 'Are you sure? All commits that were signed with this GPG key will be unverified.' }, method: :put, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only Revoke
+ Revoke
diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml
new file mode 100644
index 00000000000..cabb92c5a24
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_key_table.html.haml
@@ -0,0 +1,11 @@
+- is_admin = local_assigns.fetch(:admin, false)
+
+- if @gpg_keys.any?
+ %ul.well-list
+ = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin }
+- else
+ %p.settings-message.text-center
+ - if is_admin
+ There are no GPG keys associated with this account.
+ - else
+ There are no GPG keys with access to your account.
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
new file mode 100644
index 00000000000..1d2e41cb437
--- /dev/null
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -0,0 +1,21 @@
+- page_title "GPG Keys"
+- @content_class = "limit-container-width" unless fluid_layout
+
+.row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ = page_title
+ %p
+ GPG keys allow you to verify signed commits.
+ .col-lg-8
+ %h5.prepend-top-0
+ Add a GPG key
+ %p.profile-settings-content
+ Before you can add a GPG key you need to
+ = link_to 'generate it.', help_page_path('user/project/repository/gpg_signed_commits/index.md')
+ = render 'form'
+ %hr
+ %h5
+ Your GPG keys (#{@gpg_keys.count})
+ .append-bottom-default
+ = render 'key_table'
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index d2a60ac2867..103446243e5 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -1,6 +1,12 @@
%li.key-list-item
.pull-left.append-right-10
- = icon 'key', class: "settings-list-icon hidden-xs"
+ - if key.valid?
+ = icon 'key', class: 'settings-list-icon hidden-xs'
+ - else
+ = icon 'exclamation-triangle', class: 'settings-list-icon hidden-xs has-tooltip',
+ title: key.errors.full_messages.join(', ')
+
+
.key-list-item-info
= link_to path_to_key(key, is_admin), class: "title" do
= key.title
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index d44603c638c..77521417f47 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -16,6 +16,7 @@
%strong= @key.last_used_at.try(:to_s, :medium) || 'N/A'
.col-md-8
+ = form_errors(@key, type: 'key') unless @key.valid?
%p
%span.light Fingerprint:
%code.key-fingerprint= @key.fingerprint
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 71b224a413b..1e206def7ee 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,18 +1,20 @@
- page_title "SSH Keys"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
SSH keys allow you to establish a secure connection between your computer and GitLab.
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Add an SSH key
%p.profile-settings-content
Before you can add an SSH key you need to
- = link_to "generate it.", help_page_path("ssh/README")
+ = link_to "generate one", help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair')
+ or use an
+ = link_to "existing key.", help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair')
= render 'form'
%hr
%h5
diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml
index 6283ceebf10..28be6172219 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs "SSH Keys", profile_keys_path
+- breadcrumb_title @key.title
- page_title @key.title, "SSH Keys"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
= render "key_details"
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 51c4e8e5a73..8f099aa6dd7 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,5 +1,5 @@
- page_title "Notifications"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
%div
- if @user.errors.any?
@@ -9,16 +9,16 @@
%li= msg
= hidden_field_tag :notification_type, 'global'
- .row
- .col-lg-3.profile-settings-sidebar
- %h4
+ .row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
= page_title
%p
You can specify notification level per group or per project.
%p
By default, all projects and groups will use the global notifications setting.
- .col-lg-9
- %h5
+ .col-lg-8
+ %h5.prepend-top-0
Global notification settings
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 243428b690e..c606b5a1e6c 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -1,12 +1,14 @@
+- breadcrumb_title "Edit Password"
- page_title "Password"
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
After a successful password update, you will be redirected to the login page where you can log in with your new password.
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Change your password
- unless @user.password_automatically_set?
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index c852107e69a..78848542810 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -1,8 +1,10 @@
+- breadcrumb_title "Access Tokens"
- page_title "Personal Access Tokens"
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
+
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
@@ -11,15 +13,14 @@
You can also use personal access tokens to authenticate against Git over HTTP.
They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
- .col-lg-9
-
- - if flash[:personal_access_token]
+ .col-lg-8
+ - if @new_personal_access_token
.created-personal-access-token-container
%h5.prepend-top-0
Your New Personal Access Token
.form-group
- = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block"
- = clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
+ = text_field_tag 'created-personal-access-token', @new_personal_access_token, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block"
+ = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left")
%span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again.
%hr
@@ -28,7 +29,39 @@
= render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens
-:javascript
- $("#created-personal-access-token").click(function() {
- this.select();
- });
+%hr
+.row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ RSS token
+ %p
+ Your RSS token is used to authenticate you when your RSS reader loads a personalized RSS feed, and is included in your personal RSS feed URLs.
+ %p
+ It cannot be used to access any other data.
+ .col-lg-8.rss-token-reset
+ = label_tag :rss_token, 'RSS token', class: "label-light"
+ = text_field_tag :rss_token, current_user.rss_token, class: 'form-control', readonly: true, onclick: 'this.select()'
+ %p.help-block
+ Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds as if they were you.
+ You should
+ = link_to 'reset it', [:reset, :rss_token, :profile], method: :put, data: { confirm: 'Are you sure? Any RSS URLs currently in use will stop working.' }
+ if that ever happens.
+
+- if incoming_email_token_enabled?
+ %hr
+ .row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ Incoming email token
+ %p
+ Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses.
+ %p
+ It cannot be used to access any other data.
+ .col-lg-8.incoming-email-token-reset
+ = 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
+ Keep this token secret. Anyone who gets ahold of it can create issues as if they were you.
+ You should
+ = link_to 'reset it', [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: 'Are you sure? Any issue email addresses currently in use will stop working.' }
+ if that ever happens.
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 0b5995415e9..6aefd97bb96 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,49 +1,52 @@
- page_title 'Preferences'
-= render 'profiles/head'
+- @content_class = "limit-container-width" unless fluid_layout
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.application-theme
+ %h4.prepend-top-0
+ GitLab navigation theme
+ %p Customize the appearance of the application header and navigation sidebar.
+ .col-lg-8.application-theme
+ - Gitlab::Themes.each do |theme|
+ = label_tag do
+ .preview{ class: theme.name.downcase }
+ .preview-row
+ .quadrant.one
+ .quadrant.two
+ .preview-row
+ .quadrant.three
+ .quadrant.four
+ = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id
+ = theme.name
+
+ .col-sm-12
+ %hr
+
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Syntax highlighting theme
%p
This setting allows you to customize the appearance of the syntax.
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
- .col-lg-9.syntax-theme
+ .col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
.preview= image_tag "#{scheme.css_class}-scheme-preview.png"
= f.radio_button :color_scheme_id, scheme.id
= scheme.name
- - if can_toggle_new_nav?
- .col-sm-12
- %hr
- .col-lg-3.profile-settings-sidebar#new-navigation
- %h4.prepend-top-0
- New Navigation
- %p
- This setting allows you to turn on or off the new upcoming navigation concept.
- = succeed '.' do
- = link_to 'Learn more', '', target: '_blank'
- .col-lg-9.syntax-theme
- = label_tag do
- .preview= image_tag "old_nav.png"
- %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? }
- Old
- = label_tag do
- .preview= image_tag "new_nav.png"
- %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? }
- New
+
.col-sm-12
%hr
- .col-lg-3.profile-settings-sidebar
+
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Behavior
%p
This setting allows you to customize the behavior of the system layout and default views.
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
- .col-lg-9
+ .col-lg-8
.form-group
= f.label :layout, class: 'label-light' do
Layout width
@@ -56,9 +59,9 @@
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
.form-group
= f.label :project_view, class: 'label-light' do
- Project home page content
+ Project overview 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 overview page
.form-group
= f.submit 'Save changes', class: 'btn btn-save'
diff --git a/app/views/profiles/preferences/update.js.erb b/app/views/profiles/preferences/update.js.erb
index 431ab9d052b..8966dd3fd86 100644
--- a/app/views/profiles/preferences/update.js.erb
+++ b/app/views/profiles/preferences/update.js.erb
@@ -1,3 +1,7 @@
+// Remove body class for any previous theme, re-add current one
+$('body').removeClass('<%= Gitlab::Themes.body_classes %>')
+$('body').addClass('<%= user_application_theme %>')
+
// Toggle container-fluid class
if ('<%= current_user.layout %>' === 'fluid') {
$('.content-wrapper .container-fluid').removeClass('container-limited')
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 819c98946ab..e497eab32e0 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,54 +1,55 @@
-= render 'profiles/head'
+- breadcrumb_title "Edit Profile"
+- @content_class = "limit-container-width" unless fluid_layout
-= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default' }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
= form_errors(@user)
.row
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Public Avatar
%p
- if @user.avatar?
You can change your avatar here
- if gravatar_enabled?
- or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host}
+ or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host}
- else
You can upload an avatar here
- if gravatar_enabled?
- or change it at #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host}
- .col-lg-9
+ or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host}
+ .col-lg-8
.clearfix.avatar-image.append-bottom-default
- = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = image_tag avatar_icon(@user, 160), alt: '', class: 'avatar s160'
- %h5.prepend-top-0
- Upload new avatar
+ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
+ = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
+ %h5.prepend-top-0= _("Upload new avatar")
.prepend-top-5.append-bottom-10
- %a.btn.js-choose-user-avatar-button
- Browse file...
- %span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen
+ %button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...")
+ %span.avatar-file-name.prepend-left-default.js-avatar-filename= _("No file chosen")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
- .help-block
- The maximum file size allowed is 200KB.
+ .help-block= _("The maximum file size allowed is 200KB.")
- if @user.avatar?
%hr
- = link_to 'Remove avatar', profile_avatar_path, data: { confirm: 'Avatar will be removed. Are you sure?' }, method: :delete, class: 'btn btn-gray'
+ = link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted'
%hr
.row
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Main settings
%p
This information will appear on your profile.
- if current_user.ldap_user?
Some options are unavailable for LDAP accounts
- .col-lg-9
+ .col-lg-8
.row
- = f.text_field :name, required: true, wrapper: { class: 'col-md-9' },
- help: 'Enter your name, so people you know can recognize you.'
+ - if @user.read_only_attribute?(:name)
+ = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
+ help: "Your name was automatically set based on your #{ attribute_provider_label(:name) } account, so people you know can recognize you."
+ - else
+ = f.text_field :name, required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you."
= f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- - if @user.external_email?
- = f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{email_provider_label} account."
+ - if @user.read_only_attribute?(:email)
+ = f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{ attribute_provider_label(:email) } account."
- else
= f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?),
help: user_email_help_text(@user)
@@ -62,7 +63,10 @@
= f.text_field :linkedin
= f.text_field :twitter
= f.text_field :website_url, label: 'Website'
- = f.text_field :location
+ - if @user.read_only_attribute?(:location)
+ = f.text_field :location, readonly: true, help: "Your location was automatically set based on your #{ attribute_provider_label(:location) } account."
+ - else
+ = f.text_field :location
= f.text_field :organization
= f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.'
.prepend-top-default.append-bottom-default
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 0ff05098cd7..1bd10018b40 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,98 +1,93 @@
- page_title 'Two-Factor Authentication', 'Account'
-- header_title "Two-Factor Authentication", profile_two_factor_auth_path
-= render 'profiles/head'
+- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
+- @content_class = "limit-container-width" unless fluid_layout
-- if inject_u2f_api?
- - content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('u2f')
-
-.row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0
- Register Two-Factor Authentication App
- %p
- Use an app on your mobile device to enable two-factor authentication (2FA).
- .col-lg-9
- - if current_user.two_factor_otp_enabled?
- = icon "check inverse", base: "circle", class: "text-success", text: "You've already enabled two-factor authentication using mobile authenticator applications. You can disable it from your account settings page."
- - else
+.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
+ .row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ Register Two-Factor Authentication App
%p
- Download the Google Authenticator application from App Store or Google Play Store and scan this code.
- More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}.
- .row.append-bottom-10
- .col-md-3
- = raw @qr_code
- .col-md-9
- .account-well
- %p.prepend-top-0.append-bottom-0
- Can't scan the code?
- %p.prepend-top-0.append-bottom-0
- To add the entry manually, provide the following details to the application on your phone.
- %p.prepend-top-0.append-bottom-0
- Account:
- = @account_string
- %p.prepend-top-0.append-bottom-0
- Key:
- = current_user.otp_secret.scan(/.{4}/).join(' ')
- %p.two-factor-new-manual-content
- Time based: Yes
- = form_tag profile_two_factor_auth_path, method: :post do |f|
- - if @error
- .alert.alert-danger
- = @error
- .form-group
- = label_tag :pin_code, nil, class: "label-light"
- = text_field_tag :pin_code, nil, class: "form-control", required: true
- .prepend-top-default
- = submit_tag 'Register with two-factor app', class: 'btn btn-success'
-
-%hr
+ Use an app on your mobile device to enable two-factor authentication (2FA).
+ .col-lg-8
+ - if current_user.two_factor_otp_enabled?
+ %p
+ You've already enabled two-factor authentication using mobile authenticator applications. In order to register a different device, you must first disable two-factor authentication.
+ = link_to 'Disable two-factor authentication', profile_two_factor_auth_path,
+ method: :delete,
+ data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
+ class: 'btn btn-danger'
+ - else
+ %p
+ Download the Google Authenticator application from App Store or Google Play Store and scan this code.
+ More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}.
+ .row.append-bottom-10
+ .col-md-4
+ = raw @qr_code
+ .col-md-8
+ .account-well
+ %p.prepend-top-0.append-bottom-0
+ Can't scan the code?
+ %p.prepend-top-0.append-bottom-0
+ To add the entry manually, provide the following details to the application on your phone.
+ %p.prepend-top-0.append-bottom-0
+ Account:
+ = @account_string
+ %p.prepend-top-0.append-bottom-0
+ Key:
+ = current_user.otp_secret.scan(/.{4}/).join(' ')
+ %p.two-factor-new-manual-content
+ Time based: Yes
+ = form_tag profile_two_factor_auth_path, method: :post do |f|
+ - if @error
+ .alert.alert-danger
+ = @error
+ .form-group
+ = label_tag :pin_code, nil, class: "label-light"
+ = text_field_tag :pin_code, nil, class: "form-control", required: true
+ .prepend-top-default
+ = submit_tag 'Register with two-factor app', class: 'btn btn-success'
-.row.prepend-top-default
+ %hr
- .col-lg-3
- %h4.prepend-top-0
- Register Universal Two-Factor (U2F) Device
- %p
- Use a hardware device to add the second factor of authentication.
- %p
- As U2F devices are only supported by a few browsers, we require that you set up a
- two-factor authentication app before a U2F device. That way you'll always be able to
- log in - even when you're using an unsupported browser.
- .col-lg-9
- - if @u2f_registration.errors.present?
- = form_errors(@u2f_registration)
- = render "u2f/register"
+ .row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ Register Universal Two-Factor (U2F) Device
+ %p
+ Use a hardware device to add the second factor of authentication.
+ %p
+ As U2F devices are only supported by a few browsers, we require that you set up a
+ two-factor authentication app before a U2F device. That way you'll always be able to
+ log in - even when you're using an unsupported browser.
+ .col-lg-8
+ - if @u2f_registration.errors.present?
+ = form_errors(@u2f_registration)
+ = render "u2f/register"
- %hr
+ %hr
- %h5 U2F Devices (#{@u2f_registrations.length})
+ %h5 U2F Devices (#{@u2f_registrations.length})
- - if @u2f_registrations.present?
- .table-responsive
- %table.table.table-bordered.u2f-registrations
- %colgroup
- %col{ width: "50%" }
- %col{ width: "30%" }
- %col{ width: "20%" }
- %thead
- %tr
- %th Name
- %th Registered On
- %th
- %tbody
- - @u2f_registrations.each do |registration|
+ - if @u2f_registrations.present?
+ .table-responsive
+ %table.table.table-bordered.u2f-registrations
+ %colgroup
+ %col{ width: "50%" }
+ %col{ width: "30%" }
+ %col{ width: "20%" }
+ %thead
%tr
- %td= registration.name.presence || "<no name set>"
- %td= registration.created_at.to_date.to_s(:medium)
- %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
-
- - else
- .settings-message.text-center
- You don't have any U2F devices registered yet.
-
+ %th Name
+ %th Registered On
+ %th
+ %tbody
+ - @u2f_registrations.each do |registration|
+ %tr
+ %td= registration.name.presence || "<no name set>"
+ %td= registration.created_at.to_date.to_s(:medium)
+ %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
-- if two_factor_skippable?
- :javascript
- var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
- $(".flash-alert").append(button);
+ - else
+ .settings-message.text-center
+ You don't have any U2F devices registered yet.
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index 10f581d751b..1f701f2aa1b 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -1,16 +1,10 @@
%div{ class: container_class }
.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
+ = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn rss-btn has-tooltip' do
= icon('rss')
= render 'shared/event_filter'
.content_list.project-activity{ :"data-href" => activity_project_path(@project) }
= spinner
-
-:javascript
- var activity = new gl.Activities();
- $(document).on('page:restore', function (event) {
- activity.reloadActivities()
- })
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index b55dc3dce5c..b387e38c1a6 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -3,6 +3,4 @@
= link_to 'Cancel', cancel_path,
class: 'btn btn-cancel', data: {confirm: leave_edit_message}
- - unless can?(current_user, :push_code, @project)
- .inline.prepend-left-10
- = commit_in_fork_help
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml
new file mode 100644
index 00000000000..4f3698f91e6
--- /dev/null
+++ b/app/views/projects/_deletion_failed.html.haml
@@ -0,0 +1,6 @@
+- project = local_assigns.fetch(:project)
+- return unless project.delete_error.present?
+
+.project-deletion-failed-message.alert.alert-warning
+ This project was scheduled for deletion, but failed with the following message:
+ = project.delete_error
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
new file mode 100644
index 00000000000..5dfe973f33c
--- /dev/null
+++ b/app/views/projects/_export.html.haml
@@ -0,0 +1,41 @@
+- return unless Gitlab::CurrentSettings.project_export_enabled?
+
+- project = local_assigns.fetch(:project)
+- expanded = Rails.env.test?
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Export project
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
+ .settings-content
+ .bs-callout.bs-callout-info
+ %p.append-bottom-0
+ %p
+ The following items will be exported:
+ %ul
+ %li Project and wiki repositories
+ %li Project uploads
+ %li Project configuration including web hooks and services
+ %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
+ %p
+ The following items will NOT be exported:
+ %ul
+ %li Job traces and artifacts
+ %li LFS objects
+ %li Container registry images
+ %li CI variables
+ %li Any encrypted tokens
+ %p
+ Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.
+ - if project.export_project_path
+ = link_to 'Download export', download_export_project_path(project),
+ rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
+ = link_to 'Generate new export', generate_new_export_project_path(project),
+ method: :post, class: "btn btn-default"
+ - else
+ = link_to 'Export project', export_project_path(project),
+ method: :post, class: "btn btn-default"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 426085b3e1c..79530e78154 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,14 +1,13 @@
- commit = local_assigns.fetch(:commit) { @repository.commit }
- ref = local_assigns.fetch(:ref) { current_ref }
- project = local_assigns.fetch(:project) { @project }
+- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) }
+
#tree-holder.tree-holder.clearfix
.nav-block
= render 'projects/tree/tree_header', 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 'shared/commit_well', commit: commit, ref: ref, project: project
- = render 'projects/tree/tree_content', tree: @tree
+ = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index cb4d2bbacf5..da1b2d7f9b6 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 shortcuts-find-file', rel: 'nofollow' do
+= link_to project_find_file_path(@project, @ref), class: 'btn shortcuts-find-file', rel: 'nofollow' do
= icon('search')
%span= _('Find file')
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
new file mode 100644
index 00000000000..0175b519867
--- /dev/null
+++ b/app/views/projects/_flash_messages.html.haml
@@ -0,0 +1,7 @@
+- project = local_assigns.fetch(:project)
+
+= content_for :flash_message do
+ = render partial: 'deletion_failed', locals: { project: project }
+ - if current_user && can?(current_user, :download_code, project)
+ = render 'shared/no_ssh'
+ = render 'shared/no_password'
diff --git a/app/views/projects/_head.html.haml b/app/views/projects/_head.html.haml
deleted file mode 100644
index dba84838a52..00000000000
--- a/app/views/projects/_head.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .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')
-
- = nav_link(path: 'projects#activity') do
- = 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')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 873b3045ea9..a2ecfddb163 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,9 +1,10 @@
- empty_repo = @project.empty_repo?
+- fork_network = @project.fork_network
.project-home-panel.text-center{ class: ("empty-project" if empty_repo) }
.limit-container-width{ class: container_class }
.avatar-container.s70.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile')
- %h1.project-title
+ %h1.project-title.qa-project-name
= @project.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false)
@@ -12,11 +13,21 @@
- if @project.description.present?
= markdown_field(@project, :description)
- - if forked_from_project = @project.forked_from_project
+ - if @project.forked?
%p
- #{ s_('ForkedFromProjectPath|Forked from') }
- = link_to project_path(forked_from_project) do
- = forked_from_project.namespace.try(:name)
+ - if @project.fork_source
+ #{ s_('ForkedFromProjectPath|Forked from') }
+ = link_to project_path(@project.fork_source) do
+ = fork_source_name(@project)
+ - else
+ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
+ = deleted_message % { project_name: fork_source_name(@project) }
+
+ .project-badges
+ - @project.badges.each do |badge|
+ - badge_link_url = badge.rendered_link_url(@project)
+ %a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' }
+ %img{ src: badge.rendered_image_url(@project), alt: badge_link_url }
.project-repo-buttons
.count-buttons
diff --git a/app/views/projects/_issuable_by_email.html.haml b/app/views/projects/_issuable_by_email.html.haml
new file mode 100644
index 00000000000..c137e38ed50
--- /dev/null
+++ b/app/views/projects/_issuable_by_email.html.haml
@@ -0,0 +1,37 @@
+- name = issuable_type == 'issue' ? 'issue' : 'merge request'
+
+.issuable-footer.text-center
+ %button.issuable-email-modal-btn{ type: "button", data: { toggle: "modal", target: "#issuable-email-modal" } }
+ Email a new #{name} to this project
+
+#issuable-email-modal.modal.fade{ tabindex: "-1", role: "dialog" }
+ .modal-dialog{ role: "document" }
+ .modal-content
+ .modal-header
+ %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } }
+ %span{ aria: { hidden: "true" } }= icon("times")
+ %h4.modal-title
+ Create new #{name} by email
+ .modal-body
+ %p
+ You can create a new #{name} inside this project by sending an email to the following email address:
+ .email-modal-input-group.input-group
+ = text_field_tag :issuable_email, email, class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-btn
+ = clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard btn-transparent hidden-xs')
+ = mail_to email, class: 'btn btn-clipboard btn-transparent',
+ subject: _("Enter the #{name} title"),
+ body: _("Enter the #{name} description"),
+ title: _('Send email'),
+ data: { toggle: 'tooltip', placement: 'bottom' } do
+ = sprite_icon('mail')
+
+ %p
+ = render 'by_email_description'
+ %p
+ This is a private email address, generated just for you.
+
+ Anyone who gets ahold of it can create issues or merge requests as if they were you.
+ You should
+ = link_to 'reset it', new_issuable_address_project_path(@project, issuable_type: issuable_type), class: 'incoming-email-token-reset'
+ if that ever happens.
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index f1ef50d2de2..6a1035d2dc7 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -3,16 +3,16 @@
.row-content-block.top-block.hidden-xs.white
.event-last-push
.event-last-push-text
- %span You pushed to
+ %span= s_("LastPushEvent|You pushed to")
%strong
- = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), class: 'ref-name'
+ = link_to event.ref_name, project_commits_path(event.project, event.ref_name), class: 'ref-name'
- if event.project != @project
- %span at
+ %span= s_("LastPushEvent|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
+ .flex-right
+ = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do
#{ _('Create merge request') }
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index d0698285f84..8212ab9a31e 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,32 +1,32 @@
- referenced_users = local_assigns.fetch(:referenced_users, nil)
+- if defined?(@merge_request) && @merge_request.discussion_locked?
+ .issuable-note-warning
+ = sprite_icon('lock', size: 16, css_class: 'icon')
+ %span
+ = _('This merge request is locked.')
+ = _('Only project members can comment.')
+
.md-area
.md-header
%ul.nav-links.clearfix
- %li.active
+ %li.md-header-tab.active
%a.js-md-write-button{ href: "#md-write-holder", tabindex: -1 }
Write
- %li
+ %li.md-header-tab
%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" })
- = markdown_toolbar_button({ icon: "italic fw", data: { "md-tag" => "*" }, title: "Add italic text" })
- = markdown_toolbar_button({ icon: "quote-right fw", data: { "md-tag" => "> ", "md-prepend" => true }, title: "Insert a quote" })
- = markdown_toolbar_button({ icon: "code fw", data: { "md-tag" => "`", "md-block" => "```" }, title: "Insert code" })
- = markdown_toolbar_button({ icon: "list-ul fw", data: { "md-tag" => "* ", "md-prepend" => true }, title: "Add a bullet list" })
- = markdown_toolbar_button({ icon: "list-ol fw", data: { "md-tag" => "1. ", "md-prepend" => true }, title: "Add a numbered list" })
- = markdown_toolbar_button({ icon: "check-square-o fw", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: "Add a task list" })
- .toolbar-group
- %button.toolbar-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, aria: { label: "Go full screen" }, title: "Go full screen", data: { container: "body" } }
- = icon("arrows-alt fw")
+ %li.md-header-toolbar.active
+ = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: "Add bold text" })
+ = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: "Add italic text" })
+ = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: "Insert a quote" })
+ = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: "Insert code" })
+ = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: "Add a bullet list" })
+ = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: "Add a numbered list" })
+ = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: "Add a task list" })
+ %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: "Go full screen", data: { container: "body" } }
+ = sprite_icon("screen-full")
.md-write-holder
= yield
diff --git a/app/views/projects/_merge_request_fast_forward_settings.html.haml b/app/views/projects/_merge_request_fast_forward_settings.html.haml
new file mode 100644
index 00000000000..f455522d17c
--- /dev/null
+++ b/app/views/projects/_merge_request_fast_forward_settings.html.haml
@@ -0,0 +1,13 @@
+- form = local_assigns.fetch(:form)
+- project = local_assigns.fetch(:project)
+
+.radio
+ = label_tag :project_merge_method_ff do
+ = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff"
+ %strong Fast-forward merge
+ %br
+ %span.descr
+ No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.
+ %br
+ %span.descr
+ When fast-forward merge is not possible, the user is given the option to rebase.
diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml
index 61420fd0fb6..f6e5712ce81 100644
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_settings.html.haml
@@ -1,19 +1,23 @@
- form = local_assigns.fetch(:form)
.form-group
- .checkbox.builds-feature
+ .checkbox.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
= form.label :only_allow_merge_if_pipeline_succeeds do
= form.check_box :only_allow_merge_if_pipeline_succeeds
%strong Only allow merge requests to be merged if the pipeline succeeds
%br
%span.descr
Pipelines need to be configured to enable this feature.
- = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')
+ = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank'
.checkbox
= form.label :only_allow_merge_if_all_discussions_are_resolved do
= form.check_box :only_allow_merge_if_all_discussions_are_resolved
%strong Only allow merge requests to be merged if all discussions are resolved
.checkbox
+ = form.label :resolve_outdated_diff_discussions do
+ = form.check_box :resolve_outdated_diff_discussions
+ %strong Automatically resolve merge request diff discussions when they become outdated
+ .checkbox
= form.label :printing_merge_request_link_enabled do
= form.check_box :printing_merge_request_link_enabled
%strong Show link to create/view merge request when pushing from the command line
diff --git a/app/views/projects/_merge_request_rebase_settings.html.haml b/app/views/projects/_merge_request_rebase_settings.html.haml
new file mode 100644
index 00000000000..54e0b73d24c
--- /dev/null
+++ b/app/views/projects/_merge_request_rebase_settings.html.haml
@@ -0,0 +1,13 @@
+- form = local_assigns.fetch(:form)
+
+.radio
+ = label_tag :project_merge_method_rebase_merge do
+ = form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio"
+ %strong Merge commit with semi-linear history
+ %br
+ %span.descr
+ A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible.
+ This way you could make sure that if this merge request would build, after merging to target branch it would also build.
+ %br
+ %span.descr
+ When fast-forward merge is not possible, the user is given the option to rebase.
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index 818010bc7d3..fd0c419cdac 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -1,8 +1,18 @@
- form = local_assigns.fetch(:form)
-%fieldset.features.merge-requests-feature.append-bottom-default
- %hr
- %h5.prepend-top-0
- Merge Requests
+.form-group
+ = label_tag :merge_method_merge, class: 'label-light' do
+ Merge method
+ .radio
+ = label_tag :project_merge_method_merge do
+ = form.radio_button :merge_method, :merge, class: "js-merge-method-radio"
+ %strong Merge commit
+ %br
+ %span.descr
+ A merge commit is created for every merge, and merging is allowed as long as there are no conflicts.
- = render 'projects/merge_request_merge_settings', form: form
+ = render 'merge_request_rebase_settings', form: form
+
+ = render 'merge_request_fast_forward_settings', project: @project, form: form
+
+= render 'projects/merge_request_merge_settings', form: form
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
new file mode 100644
index 00000000000..241bc3dbca0
--- /dev/null
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -0,0 +1,43 @@
+- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility
+- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
+
+.row{ id: project_name_id }
+ = f.hidden_field :ci_cd_only, value: ci_cd_only
+ .form-group.project-path.col-sm-6
+ = f.label :namespace_id, class: 'label-light' do
+ %span
+ Project path
+ .input-group
+ - if current_user.can_select_namespace?
+ .input-group-addon.has-tooltip{ title: root_url }
+ = root_url
+ = f.select :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), {}, { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1}
+
+ - else
+ .input-group-addon.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
+ #{user_url(current_user.username)}/
+ = f.hidden_field :namespace_id, value: current_user.namespace_id
+ .form-group.project-path.col-sm-6
+ = 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
+- if current_user.can_create_group?
+ .help-block
+ Want to house several dependent projects under the same namespace?
+ = link_to "Create a group", new_group_path
+
+.form-group
+ = f.label :description, class: 'label-light' do
+ Project description
+ %span (optional)
+ = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250
+
+.form-group.visibility-level-setting
+ = f.label :visibility_level, class: 'label-light' do
+ Visibility Level
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer'
+ = 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
+= link_to 'Cancel', dashboard_projects_path, class: 'btn btn-cancel'
diff --git a/app/views/projects/_new_project_push_tip.html.haml b/app/views/projects/_new_project_push_tip.html.haml
new file mode 100644
index 00000000000..9bc69211d12
--- /dev/null
+++ b/app/views/projects/_new_project_push_tip.html.haml
@@ -0,0 +1,11 @@
+.push-to-create-popover
+ %p
+ = label_tag(:push_to_create_tip, _("Private projects can be created in your personal namespace with:"), class: "weight-normal")
+
+ %p.input-group.project-tip-command
+ %span.input-group-btn
+ = text_field_tag :push_to_create_tip, push_to_create_project_command, class: "js-select-on-focus form-control monospace", readonly: true, aria: { label: _("Push project from command line") }
+ %span.input-group-btn
+ = clipboard_button(text: push_to_create_project_command, title: _("Copy command to clipboard"), placement: "right")
+ %p
+ = link_to("What does this command do?", help_page_path("gitlab-basics/create-project", anchor: "push-to-create-a-new-project"), target: "_blank")
diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml
new file mode 100644
index 00000000000..d50175727be
--- /dev/null
+++ b/app/views/projects/_project_templates.html.haml
@@ -0,0 +1,24 @@
+.project-templates-buttons.import-buttons
+ - Gitlab::ProjectTemplate.all.each do |template|
+ .template-option
+ = custom_icon(template.logo)
+ .template-title= template.title
+ .template-description= template.description
+ %label.btn.btn-success.template-button.choose-template.append-right-10{ for: template.name }
+ %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name }
+ %span Use template
+ %a.btn.btn-default{ href: template.preview, rel: 'noopener noreferrer', target: '_blank' } Preview
+
+ .project-fields-form
+ .form-group
+ %label.label-light
+ Template
+ .input-group.template-input-group
+ .input-group-addon
+ .selected-icon
+ - Gitlab::ProjectTemplate.all.each do |template|
+ = custom_icon(template.logo)
+ .selected-template
+ %button.btn.btn-default.change-template{ type: "button" } Change template
+
+ = render 'new_project_fields', f: f, project_name_id: "template-project-name"
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
new file mode 100644
index 00000000000..705338c083e
--- /dev/null
+++ b/app/views/projects/_readme.html.haml
@@ -0,0 +1,23 @@
+- if (readme = @repository.readme) && readme.rich_viewer
+ %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
+ .js-file-title.file-title
+ = blob_icon readme.mode, readme.name
+ = link_to project_blob_path(@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)
+
+- else
+ .row-content-block.second-block.center
+ %h4
+ 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.
+ GitLab will render it here instead of this message.
+ %p
+ = link_to "Add Readme", @project.add_readme_path, class: 'btn btn-new'
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
new file mode 100644
index 00000000000..a115b65938b
--- /dev/null
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -0,0 +1,8 @@
+- anchors = local_assigns.fetch(:anchors, [])
+
+- return unless anchors.any?
+%ul.nav
+ - anchors.each do |anchor|
+ %li
+ = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.enabled ? 'stat-link' : "btn btn-#{anchor.class_modifier || 'missing'}" do
+ %span.stat-text= anchor.label
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 2bab22e125d..a56c3503c77 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -14,5 +14,5 @@
Add a homepage to your wiki that contains information about your project
%p
We recommend you
- = link_to "add a homepage", namespace_project_wiki_path(@project.namespace, @project, :home)
+ = link_to "add a homepage", project_wiki_path(@project, :home)
to your project's wiki and GitLab will show it here instead of this message.
diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml
index ef8d8051cbf..b28a375e956 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -1,8 +1,8 @@
- @no_container = true
-- page_title "Activity"
-= render "projects/head"
+- page_title _("Activity")
-= render 'projects/last_push'
+%div{ class: container_class }
+ = render 'projects/last_push'
= render 'projects/activity'
diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml
index e2966ec33c2..1a9ce8d0508 100644
--- a/app/views/projects/artifacts/_tree_directory.html.haml
+++ b/app/views/projects/artifacts/_tree_directory.html.haml
@@ -1,8 +1,8 @@
-- path_to_directory = browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: directory.path)
+- path_to_directory = browse_project_job_artifacts_path(@project, @build, path: directory.path)
%tr.tree-item{ 'data-link' => path_to_directory }
%td.tree-item-file-name
= tree_icon('folder', '755', directory.name)
- = link_to path_to_directory do
- %span.str-truncated= directory.name
+ = link_to path_to_directory, class: 'str-truncated' do
+ %span= directory.name
%td
diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml
index ea0b43b85cf..cfb91568061 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -1,10 +1,17 @@
-- path_to_file = file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: file.path)
+- blob = file.blob
+- path_to_file = file_project_job_artifacts_path(@project, @build, path: file.path)
+- external_link = blob.external_link?(@build)
-%tr.tree-item{ 'data-link' => path_to_file }
- - blob = file.blob
+%tr.tree-item.js-artifact-tree-row{ data: { link: path_to_file, external_link: "#{external_link}" } }
%td.tree-item-file-name
= tree_icon('file', blob.mode, blob.name)
- = link_to path_to_file do
- %span.str-truncated= blob.name
+ - if external_link
+ = link_to path_to_file, class: 'tree-item-file-external-link js-artifact-tree-tooltip str-truncated',
+ target: '_blank', rel: 'noopener noreferrer', title: _('Opens in a new window') do
+ %span>= blob.name
+ = icon('external-link', class: 'js-artifact-tree-external-icon')
+ - else
+ = link_to path_to_file, class: 'str-truncated' do
+ %span= blob.name
%td
= number_to_human_size(blob.size, precision: 2)
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 961c805dc7c..7ff7466e561 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -1,22 +1,25 @@
+- breadcrumb_title _('Artifacts')
- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
-= render "projects/pipelines/head"
= render "projects/jobs/header", show_controls: false
+- add_to_breadcrumbs(s_('CICD|Jobs'), project_jobs_path(@project))
+- add_to_breadcrumbs("##{@build.id}", project_jobs_path(@project))
+
.tree-holder
.nav-block
- .tree-controls
- = 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_job_artifacts_path(@project.namespace, @project, @build)
+ = link_to 'Artifacts', browse_project_job_artifacts_path(@project, @build)
- path_breadcrumbs do |title, path|
%li
- = link_to truncate(title, length: 40), browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path)
+ = link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path)
+
+ .tree-controls
+ = link_to download_project_job_artifacts_path(@project, @build),
+ rel: 'nofollow', download: '', class: 'btn btn-default download' do
+ = sprite_icon('download')
+ Download artifacts archive
.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 b25c7c95196..2942d618a42 100644
--- a/app/views/projects/artifacts/file.html.haml
+++ b/app/views/projects/artifacts/file.html.haml
@@ -1,21 +1,20 @@
- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
-= render "projects/pipelines/head"
= render "projects/jobs/header", show_controls: false
-#tree-holder.tree-holder
+.tree-holder
.nav-block
%ul.breadcrumb.repo-breadcrumb
%li
- = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build)
+ = link_to 'Artifacts', browse_project_job_artifacts_path(@project, @build)
- path_breadcrumbs do |title, path|
- title = truncate(title, length: 40)
%li
- if path == @path
- = link_to file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) do
+ = link_to file_project_job_artifacts_path(@project, @build, path) do
%strong= title
- else
- = link_to title, browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path)
+ = link_to title, browse_project_job_artifacts_path(@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 3627f72f5e1..e45861ac08d 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
- project_duration = age_map_duration(@blame_groups, @project)
- page_title "Blame", @blob.path, @ref
-= render "projects/commits/head"
%div{ class: container_class }
#blob-content-holder.tree-holder
@@ -21,10 +20,10 @@
.commit
= author_avatar(commit, size: 36)
.commit-row-title
- %strong
- = link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark"
+ %span.item-title.str-truncated-100
+ = link_to_markdown commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title
.pull-right
- = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "commit-sha"
+ = link_to commit.short_id, project_commit_path(@project, commit), class: "commit-sha"
&nbsp;
.light
= commit_author_link(commit, avatar: false)
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 8bd336269ff..849716a679b 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -9,5 +9,5 @@
#blob-content-holder.blob-content-holder
%article.file-holder
- = render "projects/blob/header", blob: blob
+ = render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index 2c944b516a4..1c148de9678 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -6,16 +6,16 @@
%ul.breadcrumb.repo-breadcrumb
%li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
+ = link_to project_tree_path(@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
+ = link_to project_blob_path(@project, tree_join(@ref, path)) do
%strong= title
- else
- = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path))
+ = link_to title, project_tree_path(@project, tree_join(@ref, path))
.tree-controls
= render 'projects/find_file_link'
@@ -24,14 +24,14 @@
-# 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),
+ = link_to 'Normal view', project_blob_path(@project, @id),
class: 'btn'
- else
- = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id),
+ = link_to 'Blame', project_blame_path(@project, @id),
class: 'btn js-blob-blame-link' unless blob.empty?
- = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id),
+ = link_to 'History', project_commits_path(@project, @id),
class: 'btn'
- = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
+ = link_to 'Permalink', project_blob_path(@project,
tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url'
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 4b344b2edb9..c9fa90acd11 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -1,9 +1,9 @@
- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
-.file-holder.file.append-bottom-default
+.file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } }
.editor-ref
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
= ref
%span.editor-file-name
- if current_action?(:edit) || current_action?(:update)
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 0be15cc179f..1b150ec3e5c 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -2,7 +2,7 @@
.js-file-title.file-title-flex-parent
= render 'projects/blob/header_content', blob: blob
- .file-actions.hidden-xs
+ .file-actions
= render 'projects/blob/viewer_switcher', blob: blob unless blame
.btn-group{ role: "group" }<
@@ -11,7 +11,8 @@
= view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
- = edit_blob_link
+ = edit_blob_button
+ = ide_edit_button
- if current_user
= replace_blob_link
= delete_blob_link
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
index 98bedae650a..5d457a50c49 100644
--- a/app/views/projects/blob/_header_content.html.haml
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -8,3 +8,6 @@
%small
= number_to_human_size(blob.raw_size)
+
+ - if blob.stored_externally? && blob.external_storage == :lfs
+ %span.label.label-lfs.append-right-5 LFS
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 40978583e8b..48ff66900be 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -1,11 +1,11 @@
#modal-create-new-dir.modal
- .modal-dialog
+ .modal-dialog.modal-lg
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
%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_tag project_create_dir_path(@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'
.col-sm-10
@@ -17,9 +17,4 @@
= 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)
- .inline.prepend-left-10
- = commit_in_fork_help
-
-:javascript
- new NewCommitForm($('.js-create-dir-form'))
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml
index c8ca0406213..750bdef3308 100644
--- a/app/views/projects/blob/_remove.html.haml
+++ b/app/views/projects/blob/_remove.html.haml
@@ -6,13 +6,10 @@
%h3.page-title Delete #{@blob.name}
.modal-body
- = form_tag namespace_project_blob_path(@project.namespace, @project, @id), method: :delete, class: 'form-horizontal js-delete-blob-form js-quick-submit js-requires-input' do
+ = form_tag project_blob_path(@project, @id), method: :delete, class: 'form-horizontal js-delete-blob-form js-quick-submit js-requires-input' do
= render 'shared/new_commit_form', placeholder: "Delete #{@blob.name}"
.form-group
.col-sm-offset-2.col-sm-10
= button_tag 'Delete file', class: 'btn btn-remove btn-remove-file'
= link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
-
-:javascript
- new NewCommitForm($('.js-delete-blob-form'))
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index 2a178325041..5b092427496 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -3,15 +3,15 @@
Template
.template-selector-dropdowns-wrap
.template-type-selector.js-template-type-selector-wrap.hidden
- = dropdown_tag("Choose type", options: { toggle_class: 'btn js-template-type-selector', title: "Choose a template type" } )
+ = dropdown_tag("Choose type", options: { toggle_class: 'js-template-type-selector', title: "Choose a template type" } )
.license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a license template", options: { toggle_class: 'btn js-license-selector', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } )
+ = dropdown_tag("Apply a license template", options: { toggle_class: 'js-license-selector', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } )
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'btn js-gitignore-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } )
+ = dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'js-gitignore-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } )
.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'btn js-gitlab-ci-yml-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } )
+ = dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'js-gitlab-ci-yml-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } )
.dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'btn js-dockerfile-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } )
+ = dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } )
.template-selectors-undo-menu.hidden
%span.text-info Template applied
%button.btn.btn-sm.btn-info Undo
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index 32dbc1b3417..182d02376bf 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -1,8 +1,8 @@
#modal-upload-blob.modal
- .modal-dialog
+ .modal-dialog.modal-lg
.modal-content
.modal-header
- %a.close{ href: "#", "data-dismiss" => "modal" } ×
+ %a.close{ href: "#", "data-dismiss" => "modal" } &times;
%h3.page-title= title
.modal-body
= form_tag form_path, method: method, class: 'js-quick-submit js-upload-blob-form form-horizontal', data: { method: method } do
@@ -19,12 +19,9 @@
= render 'shared/new_commit_form', placeholder: placeholder
.form-actions
- = button_tag button_title, class: 'btn btn-small btn-create btn-upload-file', id: 'submit-all'
+ = button_tag class: 'btn btn-create btn-upload-file', id: 'submit-all', type: 'button' do
+ = icon('spin spinner', class: 'js-loading-icon hidden' )
+ = button_title
= link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- - unless can?(current_user, :push_code, @project)
- .inline.prepend-left-10
- = commit_in_fork_help
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('blob')
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index 013f1c267c8..3124443b4e4 100644
--- a/app/views/projects/blob/_viewer.html.haml
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -1,9 +1,10 @@
- hidden = local_assigns.fetch(:hidden, false)
- render_error = viewer.render_error
+- rich_type = viewer.type == :rich ? viewer.partial_name : nil
- load_async = local_assigns.fetch(:load_async, viewer.load_async? && render_error.nil?)
- 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) }
+.blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url }, class: ('hidden' if hidden) }
- if render_error
= render 'projects/blob/render_error', viewer: viewer
- elsif load_async
@@ -17,3 +18,4 @@
- viewer = BlobViewer::Download.new(viewer.blob) if viewer.binary_detected_after_load?
= render viewer.partial_path, viewer: viewer
+
diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml
index d1d448f0d4c..ea7a71792a3 100644
--- a/app/views/projects/blob/diff.html.haml
+++ b/app/views/projects/blob/diff.html.haml
@@ -5,25 +5,24 @@
= diff_match_line @form.since, @form.since, text: @match_line, view: diff_view
- @lines.each_with_index do |line, index|
- - line_new = index + @form.since
- - line_old = line_new - @form.offset
- - line_content = capture do
- %td.line_content.noteable_line{ class: line_class }==#{' ' * @form.indent}#{line}
- %tr.line_holder.diff-expanded{ id: line_old, class: line_class }
+ - line_number_new = index + @form.since
+ - line_number_old = line_number_new - @form.offset
+ - line[0, 0] = ' ' * @form.indent
+ %tr.line_holder.diff-expanded{ id: line_number_old, class: line_class }
- case diff_view
- when :inline
- %td.old_line.diff-line-num{ data: { linenumber: line_old } }
- %a{ href: "#", data: { linenumber: line_old }, disabled: true }
- %td.new_line.diff-line-num{ data: { linenumber: line_new } }
- %a{ href: "#", data: { linenumber: line_new }, disabled: true }
- = line_content
+ %td.old_line.diff-line-num{ data: { linenumber: line_number_old } }
+ %a{ href: "#", data: { linenumber: line_number_old }, disabled: true }
+ %td.new_line.diff-line-num{ data: { linenumber: line_number_new } }
+ %a{ href: "#", data: { linenumber: line_number_new }, disabled: true }
+ %td.line_content.noteable_line{ class: line_class }= line
- when :parallel
- %td.old_line.diff-line-num{ data: { linenumber: line_old } }
- %a{ href: "##{line_old}", data: { linenumber: line_old }, disabled: true }
- = line_content
- %td.new_line.diff-line-num{ data: { linenumber: line_new } }
- %a{ href: "##{line_new}", data: { linenumber: line_new }, disabled: true }
- = line_content
+ %td.old_line.diff-line-num{ data: { linenumber: line_number_old } }
+ %a{ href: "##{line_number_old}", data: { linenumber: line_number_old }, disabled: true }
+ %td.line_content.noteable_line.left-side{ class: line_class }= line
+ %td.new_line.diff-line-num{ data: { linenumber: line_number_new } }
+ %a{ href: "##{line_number_new}", data: { linenumber: line_number_new }, disabled: true }
+ %td.line_content.noteable_line.right-side{ class: line_class }= line
- if @form.unfold? && @form.bottom? && @form.to < @blob.lines.size
%tr.line_holder{ id: @form.to, class: line_class }
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 4af62461151..9d90251ab66 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -1,15 +1,14 @@
+- breadcrumb_title "Repository"
- @no_container = true
- page_title "Edit", @blob.path, @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
- = page_specific_javascript_bundle_tag('blob')
-= render "projects/commits/head"
%div{ class: container_class }
- if @conflict
.alert.alert-danger
Someone edited the file the same time you did. Please check out
- = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer'
+ = link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer'
and make sure your changes will not unintentionally remove theirs.
.editor-title-row
%h3.page-title.blob-edit-page-title
@@ -22,13 +21,13 @@
Write
%li
- = link_to '#preview', 'data-preview-url' => namespace_project_preview_blob_path(@project.namespace, @project, @id) do
+ = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do
= editing_preview_title(@blob.name)
- = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do
+ = form_tag(project_update_blob_path(@project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data
= render 'shared/new_commit_form', placeholder: "Update #{@blob.name}"
= hidden_field_tag 'last_commit_sha', @last_commit_sha
= hidden_field_tag 'content', '', id: "file-content"
= hidden_field_tag 'from_merge_request_iid', params[:from_merge_request_iid]
- = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id)
+ = render 'projects/commit_button', ref: @ref, cancel_path: project_blob_path(@project, @id)
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 2afb909572a..fa091d8f6ef 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,16 +1,16 @@
+- breadcrumb_title "Repository"
- page_title "New File", @path.presence, @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
- = page_specific_javascript_bundle_tag('blob')
.editor-title-row
%h3.page-title.blob-new-page-title
New file
= render 'template_selectors'
.file-editor
- = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do
+ = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref
= render 'shared/new_commit_form', placeholder: "Add new file"
= hidden_field_tag 'content', '', id: 'file-content'
= render 'projects/commit_button', ref: @ref,
- cancel_path: namespace_project_tree_path(@project.namespace, @project, @id)
+ cancel_path: project_tree_path(@project, @id)
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 41f75a491a5..efb8175398b 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -1,19 +1,16 @@
+- breadcrumb_title "Repository"
- @no_container = true
- page_title @blob.path, @ref
-= render "projects/commits/head"
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('blob')
-
-= render 'projects/last_push'
%div{ class: container_class }
+ = render 'projects/last_push'
+
#tree-holder.tree-holder
= render 'blob', blob: @blob
- - if can_modify_blob?(@blob)
- = render 'projects/blob/remove'
+ - if can_modify_blob?(@blob)
+ = render 'projects/blob/remove'
- title = "Replace #{@blob.name}"
- = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put
+ = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put
diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml
index 28670e7de97..b20106e8c3a 100644
--- a/app/views/projects/blob/viewers/_balsamiq.html.haml
+++ b/app/views/projects/blob/viewers/_balsamiq.html.haml
@@ -1,4 +1 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('balsamiq_viewer')
-
-.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } }
+.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml
index 53921e63b5f..46e3e7f798a 100644
--- a/app/views/projects/blob/viewers/_changelog.html.haml
+++ b/app/views/projects/blob/viewers/_changelog.html.haml
@@ -1,4 +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)
+ = link_to "the tags", project_tags_path(viewer.project)
diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml
index a0f0215a5ff..87aa7c1dbf8 100644
--- a/app/views/projects/blob/viewers/_dependency_manager.html.haml
+++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml
@@ -6,6 +6,6 @@
- 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_if viewer.package_url.present?, 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/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml
index 684240d02c7..f9b1da05a00 100644
--- a/app/views/projects/blob/viewers/_download.html.haml
+++ b/app/views/projects/blob/viewers/_download.html.haml
@@ -1,7 +1,7 @@
.file-content.blob_file.blob-no-preview
- .center
- = link_to blob_raw_url do
+ .center.render-error.vertical-center
+ = link_to blob_raw_path do
%h1.light
- = icon('download')
+ = sprite_icon('download')
%h4
Download (#{number_to_human_size(viewer.blob.raw_size)})
diff --git a/app/views/projects/blob/viewers/_image.html.haml b/app/views/projects/blob/viewers/_image.html.haml
index 640d59b3174..2a8cefac005 100644
--- a/app/views/projects/blob/viewers/_image.html.haml
+++ b/app/views/projects/blob/viewers/_image.html.haml
@@ -1,2 +1,3 @@
.file-content.image_file
- %img{ src: blob_raw_url, alt: viewer.blob.name }
+ -# Uses the full URL rather than the path, to prevent it from getting prefixed with the asset host.
+ = image_tag(blob_raw_url, alt: viewer.blob.name)
diff --git a/app/views/projects/blob/viewers/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml
index 2399fb16265..eb4ca1b9816 100644
--- a/app/views/projects/blob/viewers/_notebook.html.haml
+++ b/app/views/projects/blob/viewers/_notebook.html.haml
@@ -1,5 +1 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('notebook_viewer')
-
-.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_url } }
+.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/blob/viewers/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml
index 1dd179c4fdc..95d837a57dc 100644
--- a/app/views/projects/blob/viewers/_pdf.html.haml
+++ b/app/views/projects/blob/viewers/_pdf.html.haml
@@ -1,5 +1 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('pdf_viewer')
-
-.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_url } }
+.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml
index 334b33faf48..d8492abc638 100644
--- a/app/views/projects/blob/viewers/_readme.html.haml
+++ b/app/views/projects/blob/viewers/_readme.html.haml
@@ -1,4 +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)
+ = link_to "the wiki", get_project_wiki_path(viewer.project)
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
index d0fcd55f6c1..6d6bd79bc3c 100644
--- a/app/views/projects/blob/viewers/_route_map.html.haml
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -6,4 +6,4 @@
This Route Map is invalid:
= viewer.validation_message
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
index 2318cf82f58..a5f73fb0197 100644
--- a/app/views/projects/blob/viewers/_route_map_loading.html.haml
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -1,4 +1,4 @@
= icon('spinner spin fw')
Validating Route Map…
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml
index 49f716c2c59..b4b6492b92f 100644
--- a/app/views/projects/blob/viewers/_sketch.html.haml
+++ b/app/views/projects/blob/viewers/_sketch.html.haml
@@ -1,7 +1,3 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('sketch_viewer')
-
-.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_url } }
+.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } }
.js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' }
= icon('spinner spin 2x', 'aria-hidden' => 'true');
diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml
index e4e9d746176..55dd8cba7fe 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -1,8 +1,5 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('stl_viewer')
-
.file-content.is-stl-loading
- .text-center#js-stl-viewer{ data: { endpoint: blob_raw_url } }
+ .text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } }
= icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
.text-center.prepend-top-default.append-bottom-default.stl-controls
.btn-group
diff --git a/app/views/projects/blob/viewers/_video.html.haml b/app/views/projects/blob/viewers/_video.html.haml
index 595a890a27d..36039c08d52 100644
--- a/app/views/projects/blob/viewers/_video.html.haml
+++ b/app/views/projects/blob/viewers/_video.html.haml
@@ -1,2 +1,2 @@
.file-content.video
- %video{ src: blob_raw_url, controls: true, data: { setup: '{}' } }
+ %video{ src: blob_raw_path, controls: true, data: { setup: '{}' } }
diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml
deleted file mode 100644
index 6684ecfce81..00000000000
--- a/app/views/projects/boards/_show.html.haml
+++ /dev/null
@@ -1,38 +0,0 @@
-- @no_container = true
-- @content_class = "issue-boards-content"
-- page_title "Boards"
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
- = webpack_bundle_tag 'boards'
-
- %script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board"
- %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
-
-= render "projects/issues/head"
-
-.hidden-xs.hidden-sm
- = render 'shared/issuable/search_bar', type: :boards
-
-#board-app.boards-app{ "v-cloak" => true, data: board_data }
- .boards-list{ ":class" => "{ 'is-compact': detailIssueVisible }" }
- .boards-app-loading.text-center{ "v-if" => "loading" }
- = icon("spinner spin")
- %board{ "v-cloak" => true,
- "v-for" => "list in state.lists",
- "ref" => "board",
- ":list" => "list",
- ":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'),
- "new-issue-path" => new_namespace_project_issue_path(@project.namespace, @project),
- "milestone-path" => milestones_filter_dropdown_path,
- "label-path" => labels_filter_path,
- ":issue-link-base" => "issueLinkBase",
- ":root-path" => "rootPath",
- ":project-id" => @project.try(:id) }
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
deleted file mode 100644
index 539ee087b14..00000000000
--- a/app/views/projects/boards/components/_board.html.haml
+++ /dev/null
@@ -1,38 +0,0 @@
-.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 && 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 }}
- .issue-count-badge.pull-right.clearfix{ "v-if" => 'list.type !== "blank"' }
- %span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
- {{ list.issuesSize }}
- - if can?(current_user, :admin_issue, @project)
- %button.issue-count-badge-add-button.btn.btn-small.btn-default.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", class: "js-no-trigger-collapse")
- - if can?(current_user, :admin_list, @project)
- %board-delete{ "inline-template" => true,
- ":list" => "list",
- "v-if" => "!list.preset && list.id" }
- %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
- = icon("trash")
- %board-list{ "v-if" => 'list.type !== "blank"',
- ":list" => "list",
- ":issues" => "list.issues",
- ":loading" => "list.loading",
- ":disabled" => "disabled",
- ":issue-link-base" => "issueLinkBase",
- ":root-path" => "rootPath",
- "ref" => "board-list" }
- - if can?(current_user, :admin_list, @project)
- %board-blank-state{ "v-if" => 'list.id == "blank"' }
diff --git a/app/views/projects/boards/components/_sidebar.html.haml b/app/views/projects/boards/components/_sidebar.html.haml
deleted file mode 100644
index 24d76da6f06..00000000000
--- a/app/views/projects/boards/components/_sidebar.html.haml
+++ /dev/null
@@ -1,26 +0,0 @@
-%board-sidebar{ "inline-template" => true,
- ":current-user" => "#{current_user ? current_user.to_json(only: [:username, :id, :name], methods: [:avatar_url]) : {}}" }
- %transition{ name: "boards-sidebar-slide" }
- %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" }
- .issuable-sidebar
- .block.issuable-sidebar-header
- %span.issuable-header-text.hide-collapsed.pull-left
- %strong
- {{ issue.title }}
- %br/
- %span
- = precede "#" do
- {{ issue.id }}
- %a.gutter-toggle.pull-right{ role: "button",
- href: "#",
- "@click.prevent" => "closeSidebar",
- "aria-label" => "Toggle sidebar" }
- = custom_icon("icon_close", size: 15)
- .js-issuable-update
- = render "projects/boards/components/sidebar/assignee"
- = render "projects/boards/components/sidebar/milestone"
- = render "projects/boards/components/sidebar/due_date"
- = render "projects/boards/components/sidebar/labels"
- = render "projects/boards/components/sidebar/notifications"
- %remove-btn{ ":issue" => "issue",
- ":list" => "list" }
diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml
deleted file mode 100644
index e8db868f49b..00000000000
--- a/app/views/projects/boards/components/sidebar/_assignee.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-.block.assignee{ ref: "assigneeBlock" }
- %template{ "v-if" => "issue.assignees" }
- %assignee-title{ ":number-of-assignees" => "issue.assignees.length",
- ":loading" => "loadingAssignees",
- ":editable" => can?(current_user, :admin_issue, @project) }
- %assignees.value{ "root-path" => "#{root_url}",
- ":users" => "issue.assignees",
- ":editable" => can?(current_user, :admin_issue, @project),
- "@assign-self" => "assignSelf" }
-
- - if can?(current_user, :admin_issue, @project)
- .selectbox.hide-collapsed
- %input.js-vue{ type: "hidden",
- name: "issue[assignee_ids][]",
- ":value" => "assignee.id",
- "v-if" => "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",
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
- Select assignee
- = icon("chevron-down")
- .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
- = dropdown_title("Assign to")
- = dropdown_filter("Search users")
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/projects/boards/components/sidebar/_due_date.html.haml b/app/views/projects/boards/components/sidebar/_due_date.html.haml
deleted file mode 100644
index 1a3b88e28c5..00000000000
--- a/app/views/projects/boards/components/sidebar/_due_date.html.haml
+++ /dev/null
@@ -1,32 +0,0 @@
-.block.due_date
- .title
- Due date
- - if can?(current_user, :admin_issue, @project)
- = icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
- .value
- .value-content
- %span.no-value{ "v-if" => "!issue.dueDate" }
- No due date
- %span.bold{ "v-if" => "issue.dueDate" }
- {{ issue.dueDate | due-date }}
- - if can?(current_user, :admin_issue, @project)
- %span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
- \-
- %a.js-remove-due-date{ href: "#", role: "button" }
- remove due date
- - if can?(current_user, :admin_issue, @project)
- .selectbox
- %input{ type: "hidden",
- name: "issue[due_date]",
- ":value" => "issue.dueDate" }
- .dropdown
- %button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
- data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
- %span.dropdown-toggle-text Due date
- = icon('chevron-down')
- .dropdown-menu.dropdown-menu-due-date
- = dropdown_title('Due date')
- = dropdown_content do
- .js-due-date-calendar
diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml
deleted file mode 100644
index bee0f3dd065..00000000000
--- a/app/views/projects/boards/components/sidebar/_labels.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-.block.labels
- .title
- Labels
- - if can?(current_user, :admin_issue, @project)
- = icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
- .value.issuable-show-labels
- %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
- None
- %a{ href: "#",
- "v-for" => "label in issue.labels" }
- %span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
- {{ label.title }}
- - if can?(current_user, :admin_issue, @project)
- .selectbox
- %input{ type: "hidden",
- name: "issue[label_names][]",
- "v-for" => "label in issue.labels",
- ":value" => "label.id" }
- .dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
- data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) },
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
- %span.dropdown-toggle-text
- Label
- = icon('chevron-down')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
- = render partial: "shared/issuable/label_page_default"
- - if can? current_user, :admin_label, @project and @project
- = render partial: "shared/issuable/label_page_create"
diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml
deleted file mode 100644
index 4e46351bf8a..00000000000
--- a/app/views/projects/boards/components/sidebar/_milestone.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-.block.milestone
- .title
- Milestone
- - if can?(current_user, :admin_issue, @project)
- = icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
- .value
- %span.no-value{ "v-if" => "!issue.milestone" }
- None
- %span.bold.has-tooltip{ "v-if" => "issue.milestone" }
- {{ issue.milestone.title }}
- - if can?(current_user, :admin_issue, @project)
- .selectbox
- %input{ type: "hidden",
- ":value" => "issue.milestone.id",
- 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", 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
- = icon("chevron-down")
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- = dropdown_title("Assign milestone")
- = dropdown_filter("Search milestones")
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/projects/boards/components/sidebar/_notifications.html.haml b/app/views/projects/boards/components/sidebar/_notifications.html.haml
deleted file mode 100644
index a08c7f2af09..00000000000
--- a/app/views/projects/boards/components/sidebar/_notifications.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-- if current_user
- .block.light.subscription{ ":data-url" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '/toggle_subscription'" }
- %span.issuable-header-text.hide-collapsed.pull-left
- Notifications
- %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
- %span
- {{issue.subscribed ? 'Unsubscribe' : 'Subscribe'}}
diff --git a/app/views/projects/boards/index.html.haml b/app/views/projects/boards/index.html.haml
index 2a5b8b1441e..bb56769bd3f 100644
--- a/app/views/projects/boards/index.html.haml
+++ b/app/views/projects/boards/index.html.haml
@@ -1 +1 @@
-= render "show"
+= render "shared/boards/show", board: @boards.first
diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml
index 2a5b8b1441e..e5b5f6404bb 100644
--- a/app/views/projects/boards/show.html.haml
+++ b/app/views/projects/boards/show.html.haml
@@ -1 +1 @@
-= render "show"
+= render "shared/boards/show", board: @board
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 869633e016d..883dfb3e6c8 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,78 +1,86 @@
+- merged = local_assigns.fetch(:merged, false)
- commit = @repository.commit(branch.dereferenced_target)
- bar_graph_width_factor = @max_commits > 0 ? 100.0/@max_commits : 0
- diverging_commit_counts = @repository.diverging_commit_counts(branch)
- number_commits_behind = diverging_commit_counts[:behind]
- number_commits_ahead = diverging_commit_counts[:ahead]
- 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 ref-name' do
- = icon('code-fork')
- = branch.name
- &nbsp;
- - if branch.name == @repository.root_ref
- %span.label.label-primary default
- - elsif @repository.merged_to_root_ref? branch.name
- %span.label.label-info.has-tooltip{ title: "Merged into #{@repository.root_ref}" }
- merged
+%li{ class: "branch-item js-branch-#{branch.name}" }
+ .branch-info
+ .branch-title
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name' do
+ = sprite_icon('fork', size: 12)
+ = branch.name
+ &nbsp;
+ - if branch.name == @repository.root_ref
+ %span.label.label-primary default
+ - elsif merged
+ %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
+ = s_('Branches|merged')
- - if protected_branch?(@project, branch)
- %span.label.label-success
- protected
- .controls.hidden-xs<
- - if merge_project && create_mr_button?(@repository.root_ref, branch.name)
- = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
- Merge request
+ - if protected_branch?(@project, branch)
+ %span.label.label-success
+ = s_('Branches|protected')
- - if branch.name != @repository.root_ref
- = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do
- Compare
+ .block-truncated
+ - if commit
+ = render 'projects/branches/commit', commit: commit, project: @project
+ - else
+ = s_('Branches|Cant find HEAD commit for this branch')
- = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name]
+ - if branch.name != @repository.root_ref
+ .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind),
+ default_branch: @repository.root_ref,
+ number_commits_ahead: diverging_count_label(number_commits_ahead) } }
+ .graph-side
+ .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
+ %span.count.count-behind= diverging_count_label(number_commits_behind)
+ .graph-separator
+ .graph-side
+ .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" }
+ %span.count.count-ahead= diverging_count_label(number_commits_ahead)
- - if can?(current_user, :push_code, @project)
- - 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")
+ .controls.hidden-xs<
+ - if merge_project && create_mr_button?(@repository.root_ref, branch.name)
+ = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
+ = _('Merge request')
- if branch.name != @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
- .graph-separator
- .graph-side
- .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" }
- %span.count.count-ahead= number_commits_ahead
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
+ class: "btn btn-default #{'prepend-left-10' unless merge_project}",
+ method: :post,
+ title: s_('Branches|Compare') do
+ = s_('Branches|Compare')
+ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name]
- - if commit
- = render 'projects/branches/commit', commit: commit, project: @project
- - else
- %p
- Cant find HEAD commit for this branch
+ - if can?(current_user, :push_code, @project)
+ - if branch.name == @project.repository.root_ref
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: s_('Branches|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: s_('Branches|Delete protected branch'),
+ data: { toggle: "modal",
+ target: "#modal-delete-branch",
+ delete_path: project_branch_path(@project, branch.name),
+ branch_name: branch.name,
+ is_merged: ("true" if merged) } }
+ = icon("trash-o")
+ - else
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: s_('Branches|Only a project master or owner can delete a protected branch') }
+ = icon("trash-o")
+ - else
+ = link_to project_branch_path(@project, branch.name),
+ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
+ title: s_('Branches|Delete branch'),
+ method: :delete,
+ data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
+ remote: true,
+ 'aria-label' => s_('Branches|Delete branch') do
+ = icon("trash-o")
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index ad8f9da0621..7892019bb15 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,9 +1,9 @@
.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-sha"
+ = link_to commit.short_id, project_commit_path(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"
+ = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message"
&middot;
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml
index c5888afa54d..e0008e322a0 100644
--- a/app/views/projects/branches/_delete_protected_modal.html.haml
+++ b/app/views/projects/branches/_delete_protected_modal.html.haml
@@ -4,31 +4,38 @@
.modal-header
%button.close{ data: { dismiss: 'modal' } } ×
%h3.page-title
- Delete protected branch
- = surround "'", "'?" do
- %span.js-branch-name>[branch name]
+ - title_branch_name = capture do
+ %span.js-branch-name.ref-name>[branch name]
+ = s_("Branches|Delete protected branch '%{branch_name}'?").html_safe % { branch_name: title_branch_name }
.modal-body
%p
- You’re about to permanently delete the protected branch
- = succeed '.' do
- %strong.js-branch-name [branch name]
+ - branch_name = capture do
+ %strong.js-branch-name.ref-name>[branch name]
+ = s_('Branches|You’re about to permanently delete the protected branch %{branch_name}.').html_safe % { branch_name: branch_name }
+ %p.js-not-merged
+ - default_branch = capture do
+ %span.ref-name= @repository.root_ref
+ = s_('Branches|This branch hasn’t been merged into %{default_branch}.').html_safe % { default_branch: default_branch }
+ = s_('Branches|To avoid data loss, consider merging this branch before deleting it.')
%p
- Once you confirm and press
- = succeed ',' do
- %strong Delete protected branch
- it cannot be undone or recovered.
+ - delete_protected_branch = capture do
+ %strong
+ = s_('Branches|Delete protected branch')
+ = s_('Branches|Once you confirm and press %{delete_protected_branch}, it cannot be undone or recovered.').html_safe % { delete_protected_branch: delete_protected_branch }
%p
- %strong To confirm, type
- %kbd.js-branch-name [branch name]
+ - branch_name_confirmation = capture do
+ %kbd.js-branch-name [branch name]
+ %strong
+ = s_('Branches|To confirm, type %{branch_name_confirmation}:').html_safe % { branch_name_confirmation: branch_name_confirmation }
.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', '',
+ = link_to s_('Branches|Delete protected branch'), '',
class: "btn btn-danger js-delete-branch",
- title: 'Delete branch',
+ title: s_('Branches|Delete branch'),
method: :delete,
- "aria-label" => "Delete"
+ 'aria-label' => s_('Branches|Delete branch')
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
new file mode 100644
index 00000000000..12e5a8e8d69
--- /dev/null
+++ b/app/views/projects/branches/_panel.html.haml
@@ -0,0 +1,19 @@
+- branches = local_assigns.fetch(:branches)
+- state = local_assigns.fetch(:state)
+- panel_title = local_assigns.fetch(:panel_title)
+- show_more_text = local_assigns.fetch(:show_more_text)
+- project = local_assigns.fetch(:project)
+- overview_max_branches = local_assigns.fetch(:overview_max_branches)
+
+- return unless branches.any?
+
+.panel.panel-default.prepend-top-10
+ .panel-heading
+ %h4.panel-title
+ = panel_title
+ %ul.content-list.all-branches
+ - branches.first(overview_max_branches).each do |branch|
+ = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch)
+ - if branches.size > overview_max_branches
+ .panel-footer.text-center
+ = link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state }
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 4bade77a077..5dcc72d8263 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,41 +1,66 @@
- @no_container = true
-- page_title "Branches"
-= render "projects/commits/head"
+- page_title _('Branches')
%div{ class: container_class }
.top-area.adjust
- .nav-text
- Protected branches can be managed in
- = link_to 'project settings', namespace_project_protected_branches_path(@project.namespace, @project)
+ %ul.nav-links.issues-state-filters
+ %li{ class: active_when(@mode == 'overview') }>
+ = link_to s_('Branches|Overview'), project_branches_path(@project), title: s_('Branches|Show overview of the branches')
+
+ %li{ class: active_when(@mode == 'active') }>
+ = link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), title: s_('Branches|Show active branches')
+
+ %li{ class: active_when(@mode == 'stale') }>
+ = link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), title: s_('Branches|Show stale branches')
+
+ %li{ class: active_when(!%w[overview active stale].include?(@mode)) }>
+ = link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), title: s_('Branches|Show all branches')
.nav-controls
- = form_tag(filter_branches_path, method: :get) do
- = search_field_tag :search, params[:search], { placeholder: 'Filter by branch name', id: 'branch-search', class: 'form-control search-text-input input-short', spellcheck: false }
-
- .dropdown.inline>
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light
- = branches_sort_options_hash[@sort]
- = icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
- %li.dropdown-header
- Sort by
- - branches_sort_options_hash.each do |value, title|
- %li
- = link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value)
+ = form_tag(project_branches_filtered_path(@project, state: 'all'), method: :get) do
+ = search_field_tag :search, params[:search], { placeholder: s_('Branches|Filter by branch name'), id: 'branch-search', class: 'form-control search-text-input input-short', spellcheck: false }
+
+ - unless @mode == 'overview'
+ .dropdown.inline>
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %span.light
+ = branches_sort_options_hash[@sort]
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = s_('Branches|Sort by')
+ - branches_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, project_branches_filtered_path(@project, state: 'all', search: params[:search], sort: value), class: ("is-active" if @sort == value)
- if can? current_user, :push_code, @project
- = link_to namespace_project_merged_branches_path(@project.namespace, @project), class: 'btn btn-inverted btn-remove has-tooltip', title: "Delete all branches that are merged into '#{@project.repository.root_ref}'", method: :delete, data: { confirm: "Deleting the merged branches cannot be undone. Are you sure?", container: 'body' } do
- Delete merged branches
- = link_to new_namespace_project_branch_path(@project.namespace, @project), class: 'btn btn-create' do
- New branch
+ = link_to project_merged_branches_path(@project),
+ class: 'btn btn-inverted btn-remove has-tooltip',
+ title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref },
+ method: :delete,
+ data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
+ container: 'body' } do
+ = s_('Branches|Delete merged branches')
+ = link_to new_project_branch_path(@project), class: 'btn btn-create' do
+ = s_('Branches|New branch')
+
+ - if can?(current_user, :admin_project, @project)
+ - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
+ .row-content-block
+ %h5
+ = s_('Branches|Protected branches can be managed in %{project_settings_link}.').html_safe % { project_settings_link: project_settings_link }
+
+ - if @mode == 'overview' && (@active_branches.any? || @stale_branches.any?)
+ = render "projects/branches/panel", branches: @active_branches, state: 'active', panel_title: s_('Branches|Active branches'), show_more_text: s_('Branches|Show more active branches'), project: @project, overview_max_branches: @overview_max_branches
+ = render "projects/branches/panel", branches: @stale_branches, state: 'stale', panel_title: s_('Branches|Stale branches'), show_more_text: s_('Branches|Show more stale branches'), project: @project, overview_max_branches: @overview_max_branches
- - if @branches.any?
+ - elsif @branches.any?
%ul.content-list.all-branches
- @branches.each do |branch|
- = render "projects/branches/branch", branch: branch
+ = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name)
= paginate @branches, theme: 'gitlab'
- else
- .nothing-here-block No branches to show
+ .nothing-here-block
+ = s_('Branches|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 5a0eba3551f..c7fc5a98ca8 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -20,16 +20,13 @@
.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 git-revision-dropdown-toggle', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
+ = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide 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'
.help-block Existing branch name, tag, or commit SHA
.form-actions
= button_tag 'Create branch', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', namespace_project_branches_path(@project.namespace, @project), class: 'btn btn-cancel'
-
-:javascript
- var availableRefs = #{@project.repository.ref_names.to_json};
-
- new NewBranchForm($('.js-create-branch-form'), availableRefs)
+ = link_to 'Cancel', project_branches_path(@project), class: 'btn btn-cancel'
+-# haml-lint:disable InlineJavaScript
+%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index a73ddd5eb33..fa9a9bfc8f7 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -1,43 +1,36 @@
-- pipeline = local_assigns.fetch(:pipeline) { project.pipelines.latest_successful_for(ref) }
+- pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) }
- if !project.empty_repo? && can?(current_user, :download_code, project)
.project-action-button.dropdown.inline>
%button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') }
- = icon('download')
+ = sprite_icon('download')
= icon("caret-down")
%span.sr-only= _('Select Archive Format')
%ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
%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
+ = link_to archive_project_repository_path(project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
%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
+ = link_to archive_project_repository_path(project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
%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
+ = link_to archive_project_repository_path(project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
%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
+ = link_to archive_project_repository_path(project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
%span= _('Download tar')
- - if pipeline
- - artifacts = pipeline.builds.latest.with_artifacts
- - if artifacts.any?
- %li.dropdown-header Artifacts
- - unless pipeline.latest?
- - latest_pipeline = project.pipeline_for(ref)
- %li
- .unclickable= ci_status_for_statuseable(latest_pipeline)
- %li.dropdown-header Previous Artifacts
- - artifacts.each do |job|
- %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
- #{ s_('DownloadArtifacts|Download') } '#{job.name}'
+ - if pipeline && pipeline.latest_builds_with_artifacts.any?
+ %li.dropdown-header Artifacts
+ - unless pipeline.latest?
+ - latest_pipeline = project.pipeline_for(ref)
+ %li
+ .unclickable= ci_status_for_statuseable(latest_pipeline)
+ %li.dropdown-header Previous Artifacts
+ - pipeline.latest_builds_with_artifacts.each do |job|
+ %li
+ = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
+ %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 960b57a8008..18e948ce35a 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -6,54 +6,33 @@
%ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown
- can_create_issue = can?(current_user, :create_issue, @project)
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- - can_create_snippet = can?(current_user, :create_snippet, @project)
+ - can_create_project_snippet = can?(current_user, :create_project_snippet, @project)
+
+ - if can_create_issue || merge_project || can_create_project_snippet
+ %li.dropdown-header= _('This project')
- if can_create_issue
- %li
- = link_to new_namespace_project_issue_path(@project.namespace, @project) do
- = icon('exclamation-circle fw')
- #{ _('New issue') }
+ %li= link_to _('New issue'), new_project_issue_path(@project)
- if merge_project
- %li
- = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project) do
- = icon('tasks fw')
- #{ _('New merge request') }
+ %li= link_to _('New merge request'), project_new_merge_request_path(merge_project)
- - if can_create_snippet
- %li
- = link_to new_namespace_project_snippet_path(@project.namespace, @project) do
- = icon('file-text-o fw')
- #{ _('New snippet') }
+ - if can_create_project_snippet
+ %li= link_to _('New snippet'), new_project_snippet_path(@project)
- - if can_create_issue || merge_project || can_create_snippet
- %li.divider
+ - if can?(current_user, :push_code, @project)
+ %li.dropdown-header= _('This repository')
- if can?(current_user, :push_code, @project)
- %li
- = link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
- = icon('file fw')
- #{ _('New file') }
- %li
- = link_to new_namespace_project_branch_path(@project.namespace, @project) do
- = icon('code-fork fw')
- #{ _('New branch') }
- %li
- = link_to new_namespace_project_tag_path(@project.namespace, @project) do
- = icon('tags fw')
- #{ _('New tag') }
+ %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master')
+ - unless @project.empty_repo?
+ %li= link_to _('New branch'), new_project_branch_path(@project)
+ %li= link_to _('New tag'), new_project_tag_path(@project)
- 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') }
+ %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master')
- elsif can?(current_user, :fork_project, @project)
- %li
- - continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master'),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
- continue: continue_params)
- = link_to fork_path, method: :post do
- = icon('file fw')
- #{ _('New file') }
+ - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'),
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ %li= link_to _('New file'), fork_path, method: :post
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 42f8c75f57b..f880556a9f7 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -4,15 +4,14 @@
= 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= s_('GoToYourFork|Fork')
- - elsif !current_user.can_create_project?
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: _('You have reached your project limit'), class: 'btn has-tooltip disabled' do
- = custom_icon('icon_fork')
- %span= s_('CreateNewFork|Fork')
- else
- = link_to new_namespace_project_fork_path(@project.namespace, @project), class: 'btn' do
+ - can_create_fork = current_user.can?(:create_fork)
+ = link_to new_project_fork_path(@project),
+ class: "btn btn-default #{'has-tooltip disabled' unless can_create_fork}",
+ title: (_('You have reached your project limit') unless can_create_fork) do
= custom_icon('icon_fork')
%span= s_('CreateNewFork|Fork')
.count-with-arrow
%span.arrow
- = link_to namespace_project_forks_path(@project.namespace, @project), title: n_('Fork', 'Forks', @project.forks_count), class: 'count' do
+ = link_to project_forks_path(@project), title: n_('Fork', '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 de2d61d4aa3..e665ca61da8 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)
+- if koding_enabled? && current_user && @repository.koding_yml && @project.can_current_user_push_code?
= link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do
_('Run in IDE (Koding)')
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 58413e2fc52..d8b4266143e 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,10 +1,10 @@
- if current_user
- = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do
+ %button.btn.btn-default.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
- if current_user.starred?(@project)
- = icon('star')
- %span.starred= _('Unstar')
+ = sprite_icon('star')
+ %span.starred= _('Unstar')
- else
- = icon('star-o')
+ = sprite_icon('star-o')
%span= s_('StarProject|Star')
.count-with-arrow
%span.arrow
@@ -13,7 +13,7 @@
- else
= link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: _('You must sign in to star a project') do
- = icon('star')
+ = sprite_icon('star')
#{ s_('StarProject|Star') }
.count-with-arrow
%span.arrow
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index d9f28d66b66..9126476e79e 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_job_url(job.project.namespace, job.project, job) do
+ = link_to project_job_path(job.project, job) do
%span.build-link ##{job.id}
- else
%span.build-link ##{job.id}
@@ -22,7 +22,7 @@
- if ref
- if job.ref
.icon-container
- = job.tag? ? icon('tag') : icon('code-fork')
+ = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name"
- else
.light none
@@ -30,7 +30,7 @@
= 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-sha"
+ = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha"
- if job.stuck?
= icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
@@ -63,7 +63,7 @@
- if admin
%td
- if job.project
- = link_to job.project.name_with_namespace, admin_namespace_project_path(job.project.namespace, job.project)
+ = link_to job.project.full_name, admin_project_path(job.project)
%td
- if job.try(:runner)
= runner_link(job.runner)
@@ -95,16 +95,16 @@
%td
.pull-right
- if can?(current_user, :read_build, job) && job.artifacts?
- = 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')
+ = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
+ = sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
- = 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
+ = link_to cancel_project_job_path(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_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
+ = link_to play_project_job_path(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_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
+ = link_to retry_project_job_path(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/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml
new file mode 100644
index 00000000000..14979bee714
--- /dev/null
+++ b/app/views/projects/clusters/_advanced_settings.html.haml
@@ -0,0 +1,15 @@
+- if can?(current_user, :admin_cluster, @cluster)
+ - if @cluster.managed?
+ .append-bottom-20
+ %label.append-bottom-10
+ = s_('ClusterIntegration|Google Kubernetes Engine')
+ %p
+ - link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+
+ .well.form-group
+ %label.text-danger
+ = s_('ClusterIntegration|Remove Kubernetes cluster integration')
+ %p
+ = s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.")
+ = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: s_("ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster.")})
diff --git a/app/views/projects/clusters/_banner.html.haml b/app/views/projects/clusters/_banner.html.haml
new file mode 100644
index 00000000000..f18caa3f4ac
--- /dev/null
+++ b/app/views/projects/clusters/_banner.html.haml
@@ -0,0 +1,14 @@
+%h4= s_('ClusterIntegration|Kubernetes cluster integration')
+
+.settings-content
+ .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine')
+ %p.js-error-reason
+
+ .hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...')
+
+ .hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' }
+ = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details")
+
+ %p= s_('ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab')
diff --git a/app/views/projects/clusters/_cluster.html.haml b/app/views/projects/clusters/_cluster.html.haml
new file mode 100644
index 00000000000..2d7f7c6b1fb
--- /dev/null
+++ b/app/views/projects/clusters/_cluster.html.haml
@@ -0,0 +1,24 @@
+.gl-responsive-table-row
+ .table-section.section-30
+ .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
+ .table-mobile-content
+ = link_to cluster.name, namespace_project_cluster_path(@project.namespace, @project, cluster)
+ .table-section.section-30
+ .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment scope")
+ .table-mobile-content= cluster.environment_scope
+ .table-section.section-30
+ .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Project namespace")
+ .table-mobile-content= cluster.platform_kubernetes&.actual_namespace
+ .table-section.section-10
+ .table-mobile-header{ role: "rowheader" }
+ .table-mobile-content
+ %button.js-project-feature-toggle.project-feature-toggle{ type: "button",
+ class: "#{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
+ "aria-label": s_("ClusterIntegration|Toggle Kubernetes Cluster"),
+ disabled: !cluster.can_toggle_cluster?,
+ data: { endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
+ %input.js-project-feature-toggle-input{ type: "hidden", value: cluster.enabled? }
+ = icon("spinner spin", class: "loading-icon")
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
diff --git a/app/views/projects/clusters/_dropdown.html.haml b/app/views/projects/clusters/_dropdown.html.haml
new file mode 100644
index 00000000000..d55a9c60b64
--- /dev/null
+++ b/app/views/projects/clusters/_dropdown.html.haml
@@ -0,0 +1,12 @@
+%h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration')
+
+.dropdown.clusters-dropdown
+ %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false }
+ %span.dropdown-toggle-text
+ = dropdown_text
+ = icon('chevron-down')
+ %ul.dropdown-menu.clusters-dropdown-menu.dropdown-menu-full-width
+ %li
+ = link_to(s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project))
+ %li
+ = link_to(s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project))
diff --git a/app/views/projects/clusters/_empty_state.html.haml b/app/views/projects/clusters/_empty_state.html.haml
new file mode 100644
index 00000000000..112dde66ff7
--- /dev/null
+++ b/app/views/projects/clusters/_empty_state.html.haml
@@ -0,0 +1,11 @@
+.row.empty-state
+ .col-xs-12
+ .svg-content= image_tag 'illustrations/clusters_empty.svg'
+ .col-xs-12
+ .text-content
+ %h4.text-center= s_('ClusterIntegration|Integrate Kubernetes cluster automation')
+ - link_to_help_page = link_to(_('Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ %p= s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page}
+
+ .text-center
+ = link_to s_('ClusterIntegration|Add Kubernetes cluster'), new_project_cluster_path(@project), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml
new file mode 100644
index 00000000000..db97203a2aa
--- /dev/null
+++ b/app/views/projects/clusters/_integration_form.html.haml
@@ -0,0 +1,38 @@
+= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
+ = form_errors(@cluster)
+ .form-group.append-bottom-20
+ %h5= s_('ClusterIntegration|Integration status')
+ %p
+ - if @cluster.enabled?
+ - if can?(current_user, :update_cluster, @cluster)
+ = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab\'s connection to it.')
+ - else
+ = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project.')
+ - else
+ = s_('ClusterIntegration|Kubernetes cluster integration is disabled for this project.')
+ %label.append-bottom-10.js-cluster-enable-toggle-area
+ %button{ type: 'button',
+ class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
+ "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"),
+ disabled: !can?(current_user, :update_cluster, @cluster) }
+ = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
+
+ .form-group
+ %h5= s_('ClusterIntegration|Security')
+ %p
+ = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.")
+ = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications')
+
+ .form-group
+ %h5= s_('ClusterIntegration|Environment scope')
+ %p
+ = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.")
+ = link_to s_("ClusterIntegration|Learn more about environments"), help_page_path('ci/environments')
+ = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
+
+ - if can?(current_user, :update_cluster, @cluster)
+ .form-group
+ = field.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/_sidebar.html.haml b/app/views/projects/clusters/_sidebar.html.haml
new file mode 100644
index 00000000000..73cd7c50922
--- /dev/null
+++ b/app/views/projects/clusters/_sidebar.html.haml
@@ -0,0 +1,7 @@
+%h4.prepend-top-0
+ = s_('ClusterIntegration|Kubernetes cluster integration')
+%p
+ = s_('ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
+%p
+ - link = link_to(_('Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Learn more about %{link_to_documentation}').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml
new file mode 100644
index 00000000000..5739a57dcfe
--- /dev/null
+++ b/app/views/projects/clusters/gcp/_form.html.haml
@@ -0,0 +1,35 @@
+%p
+ - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
+
+= form_for @cluster, html: { class: 'prepend-top-20' }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field|
+ = form_errors(@cluster)
+ .form-group
+ = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
+ = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+ .form-group
+ = field.label :environment_scope, s_('ClusterIntegration|Environment scope')
+ = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
+
+ = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field|
+ .form-group
+ = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID')
+ = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer')
+ = provider_gcp_field.text_field :gcp_project_id, class: 'form-control', placeholder: s_('ClusterIntegration|Project ID')
+
+ .form-group
+ = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone')
+ = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
+ = provider_gcp_field.text_field :zone, class: 'form-control', placeholder: 'us-central1-a'
+
+ .form-group
+ = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes')
+ = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3'
+
+ .form-group
+ = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type')
+ = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer')
+ = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4'
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/gcp/_header.html.haml b/app/views/projects/clusters/gcp/_header.html.haml
new file mode 100644
index 00000000000..fa989943492
--- /dev/null
+++ b/app/views/projects/clusters/gcp/_header.html.haml
@@ -0,0 +1,14 @@
+%h4.prepend-top-20
+ = s_('ClusterIntegration|Enter the details for your Kubernetes cluster')
+%p
+ = s_('ClusterIntegration|Please make sure that your Google account meets the following requirements:')
+%ul
+ %li
+ - link_to_kubernetes_engine = link_to(s_('ClusterIntegration|access to Google Kubernetes Engine'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Your account must have %{link_to_kubernetes_engine}').html_safe % { link_to_kubernetes_engine: link_to_kubernetes_engine }
+ %li
+ - link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/kubernetes-engine/docs/quickstart?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters').html_safe % { link_to_requirements: link_to_requirements }
+ %li
+ - link_to_container_project = link_to(s_('ClusterIntegration|Google Kubernetes Engine project'), 'https://console.cloud.google.com/home/dashboard?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below').html_safe % { link_to_container_project: link_to_container_project }
diff --git a/app/views/projects/clusters/gcp/_show.html.haml b/app/views/projects/clusters/gcp/_show.html.haml
new file mode 100644
index 00000000000..78cd687ef93
--- /dev/null
+++ b/app/views/projects/clusters/gcp/_show.html.haml
@@ -0,0 +1,41 @@
+.form-group
+ %label.append-bottom-10{ for: 'cluster-name' }
+ = s_('ClusterIntegration|Kubernetes cluster name')
+ .input-group
+ %input.form-control.cluster-name.js-select-on-focus{ value: @cluster.name, readonly: true }
+ %span.input-group-btn
+ = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'btn-default')
+
+= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
+ = form_errors(@cluster)
+
+ = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
+ .form-group
+ = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL')
+ .input-group
+ = platform_kubernetes_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: true
+ %span.input-group-btn
+ = clipboard_button(text: @cluster.platform_kubernetes.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'btn-default')
+
+ .form-group
+ = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate')
+ .input-group
+ = platform_kubernetes_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: true
+ %span.input-group-addon.clipboard-addon
+ = clipboard_button(text: @cluster.platform_kubernetes.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'btn-blank')
+
+ .form-group
+ = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token')
+ .input-group
+ = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: true
+ %span.input-group-btn
+ %button.btn.btn-default.js-show-cluster-token{ type: 'button' }
+ = s_('ClusterIntegration|Show')
+ = clipboard_button(text: @cluster.platform_kubernetes.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default')
+
+ .form-group
+ = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
+ = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/gcp/login.html.haml b/app/views/projects/clusters/gcp/login.html.haml
new file mode 100644
index 00000000000..dada51f39da
--- /dev/null
+++ b/app/views/projects/clusters/gcp/login.html.haml
@@ -0,0 +1,19 @@
+- breadcrumb_title 'Kubernetes'
+- page_title _("Login")
+
+.row.prepend-top-default
+ .col-sm-4
+ = render 'projects/clusters/sidebar'
+ .col-sm-8
+ = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine')
+ = render 'header'
+.row
+ .col-sm-8.col-sm-offset-4.signin-with-google
+ - if @authorize_url
+ = link_to @authorize_url do
+ = image_tag('auth_buttons/signin_with_google.png', width: '191px')
+ = _('or')
+ = link_to('create a new Google account', 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral', target: '_blank', rel: 'noopener noreferrer')
+ - else
+ - link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer')
+ = s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/gcp/new.html.haml b/app/views/projects/clusters/gcp/new.html.haml
new file mode 100644
index 00000000000..ea78d66d883
--- /dev/null
+++ b/app/views/projects/clusters/gcp/new.html.haml
@@ -0,0 +1,10 @@
+- breadcrumb_title 'Kubernetes'
+- page_title _("New Kubernetes Cluster")
+
+.row.prepend-top-default
+ .col-sm-4
+ = render 'projects/clusters/sidebar'
+ .col-sm-8
+ = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine')
+ = render 'header'
+ = render 'form'
diff --git a/app/views/projects/clusters/index.html.haml b/app/views/projects/clusters/index.html.haml
new file mode 100644
index 00000000000..17b244f4bf7
--- /dev/null
+++ b/app/views/projects/clusters/index.html.haml
@@ -0,0 +1,22 @@
+- breadcrumb_title 'Kubernetes'
+- page_title "Kubernetes Clusters"
+
+.clusters-container
+ - if @clusters.empty?
+ = render "empty_state"
+ - else
+ .top-area.adjust
+ .nav-text
+ = s_("ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project")
+ .ci-table.js-clusters-list
+ .gl-responsive-table-row.table-row-header{ role: "row" }
+ .table-section.section-30{ role: "rowheader" }
+ = s_("ClusterIntegration|Kubernetes cluster")
+ .table-section.section-30{ role: "rowheader" }
+ = s_("ClusterIntegration|Environment scope")
+ .table-section.section-30{ role: "rowheader" }
+ = s_("ClusterIntegration|Project namespace")
+ .table-section.section-10{ role: "rowheader" }
+ - @clusters.each do |cluster|
+ = render "cluster", cluster: cluster.present(current_user: current_user)
+ = paginate @clusters, theme: "gitlab"
diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml
new file mode 100644
index 00000000000..ebb7d247125
--- /dev/null
+++ b/app/views/projects/clusters/new.html.haml
@@ -0,0 +1,13 @@
+- breadcrumb_title 'Kubernetes'
+- page_title _("Kubernetes Cluster")
+
+.row.prepend-top-default
+ .col-sm-4
+ = render 'sidebar'
+ .col-sm-8
+ %h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration')
+
+ %p= s_('ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab')
+ = link_to s_('ClusterIntegration|Create on GKE'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20'
+ %p= s_('ClusterIntegration|Enter the details for an existing Kubernetes cluster')
+ = link_to s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20'
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
new file mode 100644
index 00000000000..2ee0eafcf1a
--- /dev/null
+++ b/app/views/projects/clusters/show.html.haml
@@ -0,0 +1,50 @@
+- @content_class = "limit-container-width" unless fluid_layout
+- add_to_breadcrumbs "Kubernetes Clusters", project_clusters_path(@project)
+- breadcrumb_title @cluster.name
+- page_title _("Kubernetes Cluster")
+
+- expanded = Rails.env.test?
+
+- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster)
+.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
+ install_helm_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :helm),
+ install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress),
+ install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus),
+ install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner),
+ toggle_status: @cluster.enabled? ? 'true': 'false',
+ cluster_status: @cluster.status_name,
+ cluster_status_reason: @cluster.status_reason,
+ help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
+ ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'),
+ ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'),
+ manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } }
+
+ .js-cluster-application-notice
+ .flash-container
+
+ %section.settings.no-animate.expanded#cluster-integration
+ = render 'banner'
+ = render 'integration_form'
+
+ .cluster-applications-table#js-cluster-applications
+
+ %section.settings#js-cluster-details{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4= s_('ClusterIntegration|Kubernetes cluster details')
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster')
+ .settings-content
+ - if @cluster.managed?
+ = render 'projects/clusters/gcp/show'
+ - else
+ = render 'projects/clusters/user/show'
+
+ %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4= _('Advanced settings')
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration")
+ .settings-content
+ = render 'advanced_settings'
diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml
new file mode 100644
index 00000000000..2e92524ce8f
--- /dev/null
+++ b/app/views/projects/clusters/user/_form.html.haml
@@ -0,0 +1,28 @@
+= form_for @cluster, url: user_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field|
+ = form_errors(@cluster)
+ .form-group
+ = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
+ = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+ .form-group
+ = field.label :environment_scope, s_('ClusterIntegration|Environment scope')
+ = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
+
+ = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
+ .form-group
+ = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL')
+ = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL')
+
+ .form-group
+ = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate')
+ = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)')
+
+ .form-group
+ = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token')
+ = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off'
+
+ .form-group
+ = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
+ = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/user/_header.html.haml b/app/views/projects/clusters/user/_header.html.haml
new file mode 100644
index 00000000000..04c7ce96a4b
--- /dev/null
+++ b/app/views/projects/clusters/user/_header.html.haml
@@ -0,0 +1,5 @@
+%h4.prepend-top-20
+ = s_('ClusterIntegration|Enter the details for your Kubernetes cluster')
+%p
+ - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes').html_safe % { link_to_help_page: link_to_help_page }
diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/projects/clusters/user/_show.html.haml
new file mode 100644
index 00000000000..ebbf7e775c7
--- /dev/null
+++ b/app/views/projects/clusters/user/_show.html.haml
@@ -0,0 +1,29 @@
+= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
+ = form_errors(@cluster)
+ .form-group
+ = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
+ = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+
+ = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
+ .form-group
+ = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL')
+ = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL')
+
+ .form-group
+ = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate')
+ = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)')
+
+ .form-group
+ = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token')
+ .input-group
+ = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token', type: 'password', placeholder: s_('ClusterIntegration|Token'), autocomplete: 'off'
+ %span.input-group-addon.clipboard-addon
+ %button.js-show-cluster-token.btn-blank{ type: 'button' }
+ = s_('ClusterIntegration|Show')
+
+ .form-group
+ = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
+ = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/user/new.html.haml b/app/views/projects/clusters/user/new.html.haml
new file mode 100644
index 00000000000..7fb75cd9cc7
--- /dev/null
+++ b/app/views/projects/clusters/user/new.html.haml
@@ -0,0 +1,11 @@
+- breadcrumb_title 'Kubernetes'
+- page_title _("New Kubernetes cluster")
+
+.row.prepend-top-default
+ .col-sm-4
+ = render 'projects/clusters/sidebar'
+ .col-sm-8
+ = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Add an existing Kubernetes cluster')
+ = render 'header'
+ .prepend-top-20
+ = render 'form'
diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml
new file mode 100644
index 00000000000..36b28c731a1
--- /dev/null
+++ b/app/views/projects/commit/_ajax_signature.html.haml
@@ -0,0 +1,2 @@
+- if commit.has_signature?
+ %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } }
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index 2267f123e38..21e4664d4e4 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -4,6 +4,7 @@
- branch_label = s_('ChangeTypeActionLabel|Revert in branch')
- revert_merge_request = _('Revert this merge request')
- revert_commit = _('Revert this commit')
+ - description = s_('ChangeTypeAction|This will create a new commit in order to revert the existing changes.')
- title = commit.merged_merge_request(current_user) ? revert_merge_request : revert_commit
- when 'cherry-pick'
- label = s_('ChangeTypeAction|Cherry-pick')
@@ -17,12 +18,14 @@
%a.close{ href: "#", "data-dismiss" => "modal" } ×
%h3.page-title= title
.modal-body
+ - if description
+ %p.append-bottom-20= description
= form_tag [type.underscore, @project.namespace.becomes(Namespace), @project, commit], method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
.form-group.branch
= label_tag 'start_branch', branch_label, class: 'control-label'
.col-sm-10
= hidden_field_tag :start_branch, @project.default_branch, id: 'start_branch'
- = dropdown_tag(@project.default_branch, options: { title: s_("BranchSwitcherTitle|Switch branch"), filter: true, placeholder: s_("BranchSwitcherPlaceholder|Search branches"), toggle_class: 'js-project-refs-dropdown dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: namespace_project_branches_path(@project.namespace, @project), submit_form_on_click: false } })
+ = dropdown_tag(@project.default_branch, options: { title: s_("BranchSwitcherTitle|Switch branch"), filter: true, placeholder: s_("BranchSwitcherPlaceholder|Search branches"), toggle_class: 'js-project-refs-dropdown dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: project_branches_path(@project), submit_form_on_click: false } })
- if can?(current_user, :push_code, @project)
= render 'shared/new_merge_request_checkbox'
@@ -32,6 +35,4 @@
= submit_tag label, class: 'btn btn-create'
= link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- - unless can?(current_user, :push_code, @project)
- .inline.prepend-left-10
- = commit_in_fork_help
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml
index 8aed88da38b..7338468967f 100644
--- a/app/views/projects/commit/_ci_menu.html.haml
+++ b/app/views/projects/commit/_ci_menu.html.haml
@@ -1,10 +1,10 @@
%ul.nav-links.no-top.no-bottom.commit-ci-menu
= nav_link(path: 'commit#show') do
- = link_to namespace_project_commit_path(@project.namespace, @project, @commit.id) do
+ = link_to project_commit_path(@project, @commit.id) do
Changes
%span.badge= @diffs.size
- if can?(current_user, :read_pipeline, @project)
= nav_link(path: 'commit#pipelines') do
- = link_to pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id) do
+ = link_to pipelines_project_commit_path(@project, @commit.id) do
Pipelines
- %span.badge= @commit.pipelines.size
+ %span.badge.js-pipelines-mr-count= @commit.pipelines.size
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 7fe44816bae..461129a3e0e 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,5 +1,6 @@
-.page-content-header
+.page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
.header-main-content
+ = render partial: 'signature', object: @commit.signature
%strong
#{ s_('CommitBoxTitle|Commit') }
%span.commit-sha= @commit.short_id
@@ -21,7 +22,7 @@
%span.btn.disabled.btn-grouped.hidden-xs.append-right-10
= icon('comment')
= @notes_count
- = link_to namespace_project_tree_path(@project.namespace, @project, @commit), class: "btn btn-default append-right-10 hidden-xs hidden-sm" do
+ = link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 hidden-xs hidden-sm" do
#{ _('Browse files') }
.dropdown.inline
%a.btn.btn-default.dropdown-toggle{ data: { toggle: "dropdown" } }
@@ -29,24 +30,24 @@
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li.visible-xs-block.visible-sm-block
- = link_to namespace_project_tree_path(@project.namespace, @project, @commit) do
- _('Browse Files')
+ = link_to project_tree_path(@project, @commit) do
+ #{ _('Browse Files') }
- unless @commit.has_been_reverted?(current_user)
%li.clearfix
- = revert_commit_link(@commit, namespace_project_commit_path(@project.namespace, @project, @commit.id), has_tooltip: false)
+ = revert_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
%li.clearfix
- = cherry_pick_commit_link(@commit, namespace_project_commit_path(@project.namespace, @project, @commit.id), has_tooltip: false)
+ = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
- if can_collaborate_with_project?
%li.clearfix
- = link_to s_("CreateTag|Tag"), new_namespace_project_tag_path(@project.namespace, @project, ref: @commit)
+ = link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit)
%li.divider
%li.dropdown-header
#{ _('Download') }
- unless @commit.parents.length > 1
- %li= link_to s_("DownloadCommit|Email Patches"), namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch)
- %li= link_to s_("DownloadCommit|Plain Diff"), namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff)
+ %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch)
+ %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff)
-.commit-box
+.commit-box{ data: { project_path: project_path(@project) } }
%h3.commit-title
= markdown(@commit.title, pipeline: :single_line, author: @commit.author)
- if @commit.description.present?
@@ -59,25 +60,39 @@
= custom_icon("icon_commit")
%span.cgray= n_('parent', 'parents', @commit.parents.count)
- @commit.parents.each do |parent|
- = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "commit-sha"
- %span.commit-info.branches
+ = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
+ .commit-info.branches
%i.fa.fa-spinner.fa-spin
+ .well-segment.merge-request-info
+ .icon-container
+ = custom_icon('mr_bold')
+ %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
+ = icon('spinner spin')
+
- if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
- .status-icon-container{ class: "ci-status-icon-#{@commit.status}" }
- = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do
+ .status-icon-container
+ = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
= ci_icon_for_status(last_pipeline.status)
#{ _('Pipeline') }
- = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id)
+ = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)
= ci_label_for_status(last_pipeline.status)
- if last_pipeline.stages_count.nonzero?
#{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) }
.mr-widget-pipeline-graph
= render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph'
- in
- = time_interval_in_words last_pipeline.duration
+ - if last_pipeline.duration
+ in
+ = time_interval_in_words last_pipeline.duration
+
+ - if @merge_request
+ .well-segment
+ = icon('info-circle fw')
+
+ This commit is part of merge request
+ = succeed '.' do
+ = link_to @merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id)
-:javascript
- $(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}");
+ Comments created here will be created in the context of that merge request.
diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml
new file mode 100644
index 00000000000..a264f3517c4
--- /dev/null
+++ b/app/views/projects/commit/_limit_exceeded_message.html.haml
@@ -0,0 +1,8 @@
+.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} }
+ .limit-icon
+ - if objects == :branch
+ = sprite_icon('fork', size: 12)
+ - else
+ = icon('tag')
+ .limit-message
+ %span #{label_for_message.capitalize} unavailable
diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml
new file mode 100644
index 00000000000..d7bf2dc0cb6
--- /dev/null
+++ b/app/views/projects/commit/_other_user_signature_badge.html.haml
@@ -0,0 +1,6 @@
+- title = capture do
+ This commit was signed with a different user's verified signature.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
index 09e3a775d1c..68b35072f26 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -2,8 +2,7 @@
#commit-pipeline-table-view{ data: { disable_initialization: disable_initialization,
endpoint: endpoint,
"help-page-path" => help_page_path('ci/quick_start/README'),
+ "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
+ "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
+ "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
} }
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('commit_pipelines')
diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
new file mode 100644
index 00000000000..22ffd66ff8e
--- /dev/null
+++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
@@ -0,0 +1,7 @@
+- title = capture do
+ This commit was signed with a verified signature, but the committer email
+ is <strong>not verified</strong> to belong to the same user.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml
new file mode 100644
index 00000000000..145bc629380
--- /dev/null
+++ b/app/views/projects/commit/_signature.html.haml
@@ -0,0 +1,2 @@
+- if signature
+ = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature }
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
new file mode 100644
index 00000000000..aac020b42c5
--- /dev/null
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -0,0 +1,28 @@
+- signature = local_assigns.fetch(:signature)
+- title = local_assigns.fetch(:title)
+- label = local_assigns.fetch(:label)
+- css_class = local_assigns.fetch(:css_class)
+- icon = local_assigns.fetch(:icon)
+- show_user = local_assigns.fetch(:show_user, false)
+
+- css_classes = commit_signature_badge_classes(css_class)
+
+- title = capture do
+ .gpg-popover-status
+ .gpg-popover-icon{ class: css_class }
+ = sprite_icon(icon)
+ %div
+ = title
+
+- content = capture do
+ - if show_user
+ .clearfix
+ = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature }
+
+ GPG Key ID:
+ %span.monospace= signature.gpg_key_primary_keyid
+
+ = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
+
+%a{ href: 'javascript:void(0)', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } }
+ = label
diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml
new file mode 100644
index 00000000000..b20198e76db
--- /dev/null
+++ b/app/views/projects/commit/_signature_badge_user.html.haml
@@ -0,0 +1,21 @@
+- gpg_key = signature.gpg_key
+- user = gpg_key&.user
+- user_name = signature.gpg_key_user_name
+- user_email = signature.gpg_key_user_email
+
+- if user
+ = link_to user_path(user), class: 'gpg-popover-user-link' do
+ %div
+ = user_avatar_without_link(user: user, size: 32)
+
+ %div
+ %strong= user.name
+ %div= user.to_reference
+- else
+ = mail_to user_email do
+ %div
+ = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
+
+ %div
+ %strong= user_name
+ %div= user_email
diff --git a/app/views/projects/commit/_unknown_key_signature_badge.html.haml b/app/views/projects/commit/_unknown_key_signature_badge.html.haml
new file mode 100644
index 00000000000..75c5cf57bcc
--- /dev/null
+++ b/app/views/projects/commit/_unknown_key_signature_badge.html.haml
@@ -0,0 +1 @@
+= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
diff --git a/app/views/projects/commit/_unverified_key_signature_badge.html.haml b/app/views/projects/commit/_unverified_key_signature_badge.html.haml
new file mode 100644
index 00000000000..75c5cf57bcc
--- /dev/null
+++ b/app/views/projects/commit/_unverified_key_signature_badge.html.haml
@@ -0,0 +1 @@
+= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml
new file mode 100644
index 00000000000..00e1efe0582
--- /dev/null
+++ b/app/views/projects/commit/_unverified_signature_badge.html.haml
@@ -0,0 +1,6 @@
+- title = capture do
+ This commit was signed with an <strong>unverified</strong> signature.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless' }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml
new file mode 100644
index 00000000000..31408806be7
--- /dev/null
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -0,0 +1,7 @@
+- title = capture do
+ This commit was signed with a <strong>verified</strong> signature and the
+ committer email is verified to belong to the same user.
+
+- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'status_success_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml
index 911c9ddce06..8611129b356 100644
--- a/app/views/projects/commit/branches.html.haml
+++ b/app/views/projects/commit/branches.html.haml
@@ -1,15 +1,15 @@
-- if @branches.any? || @tags.any?
+- if @branches_limit_exceeded
+ = render 'limit_exceeded_message', objects: :branch, label_for_message: "branches"
+- elsif @branches.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_branch_link(project_ref_path(@project, branch), 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
- = commit_branches_links(@project, @branches) if @branches.any?
- = commit_tags_links(@project, @tags) if @tags.any?
+- if @branches.any? || @tags.any? || @tags_limit_exceeded
+ %span
+ = link_to "…", "#", class: "js-details-expand label label-gray"
+ %span.js-details-content.hide
+ = commit_branches_links(@project, @branches)
+ - if @tags_limit_exceeded
+ = render 'limit_exceeded_message', objects: :tag, label_for_message: "tags"
+ - else
+ = commit_tags_links(@project, @tags)
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index ac93eac41ac..c66ea873dba 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -2,4 +2,4 @@
= render 'commit_box'
= render 'ci_menu'
-= render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id)
+= render 'projects/commit/pipelines_list', endpoint: pipelines_project_commit_path(@project, @commit.id)
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index b778e8af121..abb292f8f27 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -1,9 +1,11 @@
- @no_container = true
+- add_to_breadcrumbs "Commits", project_commits_path(@project)
+- breadcrumb_title @commit.short_id
- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : ''
- limited_container_width = fluid_layout ? '' : 'limit-container-width'
+- @content_class = limited_container_width
- page_title "#{@commit.title} (#{@commit.short_id})", "Commits"
- page_description @commit.description
-= render "projects/commits/head"
.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
diff --git a/app/views/projects/commits/_commit.atom.builder b/app/views/projects/commits/_commit.atom.builder
index 1657fb46163..50f7e7a3a33 100644
--- a/app/views/projects/commits/_commit.atom.builder
+++ b/app/views/projects/commits/_commit.atom.builder
@@ -1,14 +1,14 @@
xml.entry do
- xml.id namespace_project_commit_url(@project.namespace, @project, id: commit.id)
- xml.link href: namespace_project_commit_url(@project.namespace, @project, id: commit.id)
- xml.title truncate(commit.title, length: 80)
+ xml.id project_commit_url(@project, id: commit.id)
+ xml.link href: project_commit_url(@project, id: commit.id)
+ xml.title truncate(commit.title, length: 80, escape: false)
xml.updated commit.committed_date.xmlschema
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(commit.author_email))
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_email(commit.author_email))
xml.author do |author|
xml.name commit.author_name
xml.email commit.author_email
end
- xml.summary markdown(commit.description, pipeline: :single_line)
+ xml.summary markdown(commit.description, pipeline: :single_line), type: 'html'
end
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 11de6915961..078bd0eee63 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -1,22 +1,28 @@
-- ref = local_assigns.fetch(:ref)
-- if @note_counts
- - note_count = @note_counts.fetch(commit.id, 0)
-- else
- - notes = commit.notes
- - note_count = notes.user.count
+- view_details = local_assigns.fetch(:view_details, false)
+- merge_request = local_assigns.fetch(:merge_request, nil)
+- project = local_assigns.fetch(:project) { merge_request&.project }
+- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
-- cache_key = [project.path_with_namespace, commit.id, current_application_settings, note_count]
-- cache_key.push(commit.status(ref)) if commit.status(ref)
+- link = commit_path(project, commit, merge_request: merge_request)
+- cache_key = [project.full_path,
+ commit.id,
+ Gitlab::CurrentSettings.current_application_settings,
+ @path.presence,
+ current_controller?(:commits),
+ merge_request&.iid,
+ view_details,
+ commit.status(ref),
+ I18n.locale].compact
= cache(cache_key, expires_in: 1.day) do
- %li.commit.flex-list.js-toggle-container{ id: "commit-#{commit.short_id}" }
+ %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" }
.avatar-cell.hidden-xs
= author_avatar(commit, size: 36)
- .commit-detail
+ .commit-detail.flex-list
.commit-content
- = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message item-title"
+ = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
%span.commit-row-message.visible-xs-inline
&middot;
= commit.short_id
@@ -28,17 +34,27 @@
- if commit.description?
%pre.commit-row-description.js-toggle-content
- = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author))
+ = preserve(markdown_field(commit, :description))
+
.commiter
- commit_author_link = commit_author_link(commit, avatar: false, size: 24)
- - commit_timeago = time_ago_with_tooltip(commit.committed_date)
- - commit_text = _('%{commit_author_link} committed %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago }
+ - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom')
+ - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago }
#{ commit_text.html_safe }
-
.commit-actions.flex-row.hidden-xs
+ - if request.xhr?
+ = render partial: 'projects/commit/signature', object: commit.signature
+ - else
+ = render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
+
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha btn btn-transparent"
+
+ .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id) } }
+ = link_to commit.short_id, link, class: "commit-sha btn btn-transparent btn-link"
= clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"))
= link_to_browse_code(project, commit)
+
+ - if view_details && merge_request
+ = link_to "View details", project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "btn btn-default"
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 93fd0789c11..ac6852751be 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -1,4 +1,7 @@
-- ref = local_assigns.fetch(:ref)
+- merge_request = local_assigns.fetch(:merge_request, nil)
+- project = local_assigns.fetch(:project) { merge_request&.project }
+- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
+
- commits, hidden = limited_commits(@commits)
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits|
@@ -7,9 +10,9 @@
%span.commits-count= n_("%d commit", "%d commits", commits.count) % commits.count
%li.commits-row{ data: { day: day } }
- %ul.content-list.commit-list
- = render commits, project: project, ref: ref
+ %ul.content-list.commit-list.flex-list
+ = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if hidden > 0
%li.alert.alert-warning
- = n_('%d additional commit has been omitted to prevent performance issues.', '%d additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
+ = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml
deleted file mode 100644
index ebeaab863bc..00000000000
--- a/app/views/projects/commits/_head.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %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') }
-
- = nav_link(controller: [:commit, :commits]) do
- = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- #{ _('Commits') }
-
- = nav_link(html_options: {class: branches_tab_class}) do
- = link_to namespace_project_branches_path(@project.namespace, @project) do
- #{ _('Branches') }
-
- = nav_link(controller: [:tags, :releases]) do
- = link_to namespace_project_tags_path(@project.namespace, @project) do
- #{ _('Tags') }
-
- = nav_link(path: 'graphs#show') do
- = link_to namespace_project_graph_path(@project.namespace, @project, current_ref) do
- #{ _('Contributors') }
-
- = nav_link(controller: %w(network)) do
- = link_to namespace_project_network_path(@project.namespace, @project, current_ref) do
- #{ 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') }
-
- = nav_link(path: 'graphs#charts') do
- = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref) do
- #{ _('Charts') }
diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml
index 5fb89935467..26385d2f534 100644
--- a/app/views/projects/commits/_inline_commit.html.haml
+++ b/app/views/projects/commits/_inline_commit.html.haml
@@ -1,8 +1,8 @@
%li.commit.inline-commit
.commit-row-title
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha"
+ = link_to commit.short_id, project_commit_path(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"
+ = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message")
.pull-right
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/commits/show.atom.builder b/app/views/projects/commits/show.atom.builder
index 9cf792e1721..a9b77631474 100644
--- a/app/views/projects/commits/show.atom.builder
+++ b/app/views/projects/commits/show.atom.builder
@@ -1,7 +1,7 @@
xml.title "#{@project.name}:#{@ref} commits"
-xml.link href: namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), rel: "self", type: "application/atom+xml"
-xml.link href: namespace_project_commits_url(@project.namespace, @project, @ref), rel: "alternate", type: "text/html"
-xml.id namespace_project_commits_url(@project.namespace, @project, @ref)
+xml.link href: project_commits_url(@project, @ref, rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: project_commits_url(@project, @ref), rel: "alternate", type: "text/html"
+xml.id project_commits_url(@project, @ref)
xml.updated @commits.first.committed_date.xmlschema if @commits.any?
xml << render(@commits) if @commits.any?
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index fabd825aec8..ab371521840 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -1,39 +1,36 @@
- @no_container = true
+- breadcrumb_title _("Commits")
- page_title _("Commits"), @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")
-
-= content_for :sub_nav do
- = render "head"
-
-%div{ class: container_class }
- .row-content-block.second-block.content-component-block.flex-container-block
- .tree-ref-holder
- = render 'shared/ref_switcher', destination: 'commits'
-
- %ul.breadcrumb.repo-breadcrumb
- = commits_breadcrumbs
-
- .block-controls.hidden-xs.hidden-sm
- - if @merge_request.present?
- .control
- = link_to _("View open merge request"), namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn'
- - elsif create_mr_button?(@repository.root_ref, @ref)
- .control
- = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
-
- .control
- = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do
- = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
- .control
- = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
- = icon("rss")
-
- %div{ id: dom_id(@project) }
- %ol#commits-list.list-unstyled.content_list
- = render 'commits', project: @project, ref: @ref
- = spinner
-
-:javascript
- CommitsList.init(#{@limit});
+ = auto_discovery_link_tag(:atom, project_commits_path(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
+
+.js-project-commits-show{ 'data-commits-limit' => @limit }
+ %div{ class: container_class }
+ .tree-holder
+ .nav-block
+ .tree-ref-container
+ .tree-ref-holder
+ = render 'shared/ref_switcher', destination: 'commits'
+
+ %ul.breadcrumb.repo-breadcrumb
+ = commits_breadcrumbs
+ .tree-controls.hidden-xs.hidden-sm
+ - if @merge_request.present?
+ .control
+ = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn'
+ - elsif create_mr_button?(@repository.root_ref, @ref)
+ .control
+ = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
+
+ .control
+ = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form', data: { 'signatures-path' => namespace_project_signatures_path }) do
+ = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
+ .control
+ = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
+ = icon("rss")
+
+ %div{ id: dom_id(@project) }
+ %ol#commits-list.list-unstyled.content_list
+ = render 'commits', project: @project, ref: @ref
+ = spinner
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index adb724c1b8d..d0c8a699608 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -1,26 +1,28 @@
-= form_tag namespace_project_compare_index_path(@project.namespace, @project), method: :post, class: 'form-inline js-requires-input' do
+= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input' do
.clearfix
- if params[:to] && params[:from]
.compare-switch-container
- = link_to icon('exchange'), {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Switch base of comparison'}
- .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown
+ = link_to icon('exchange'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Swap revisions'
+ .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.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 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'
+ %span.input-group-addon
+ = s_("CompareBranches|Source")
+ = hidden_field_tag :to, params[:to]
+ = 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_project_path(@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'
.compare-ellipsis.inline ...
- .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
+ .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown
.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 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'
+ %span.input-group-addon
+ = s_("CompareBranches|Target")
+ = hidden_field_tag :from, params[:from]
+ = 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_project_path(@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'
&nbsp;
- = button_tag "Compare", class: "btn btn-create commits-compare-btn"
+ = button_tag s_("CompareBranches|Compare"), class: "btn btn-create commits-compare-btn"
- if @merge_request.present?
- = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn'
+ = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'prepend-left-10 btn'
- elsif create_mr_button?
- = link_to "Create merge request", create_mr_path, class: 'prepend-left-10 btn'
+ = link_to _("Create merge request"), create_mr_path, class: 'prepend-left-10 btn'
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 2cf14859f30..14c64b3534a 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -1,18 +1,18 @@
- @no_container = true
+- breadcrumb_title "Compare Revisions"
- page_title "Compare"
-= render "projects/commits/head"
%div{ class: container_class }
+ %h3.page-title
+ = _("Compare Git revisions")
.sub-header-block
- Compare Git revisions.
+ - example_master = capture do
+ %code.ref-name master
+ - example_sha = capture do
+ %code.ref-name 4eedf23
+ = (_("Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.") % { master: example_master, sha: example_sha }).html_safe
%br
- Fill input field with commit SHA like
- %code.ref-name 4eedf23
- or branch/tag name like
- %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.
+ = (_("Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision.")).html_safe
.prepend-top-20
= render "form"
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index a1bca2cf83a..8da55664878 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
+- add_to_breadcrumbs _("Compare Revisions"), project_compare_index_path(@project)
- page_title "#{params[:from]}...#{params[:to]}"
-= render "projects/commits/head"
%div{ class: container_class }
.sub-header-block.no-bottom-space
@@ -13,12 +13,13 @@
.light-well
.center
%h4
- There isn't anything to compare.
+ = s_("CompareBranches|There isn't anything to compare.")
%p.slead
- if params[:to] == params[:from]
- %span.ref-name= params[:from]
- and
- %span.ref-name= params[:to]
- are the same.
+ - source_branch = capture do
+ %span.ref-name= params[:from]
+ - target_branch = capture do
+ %span.ref-name= params[:to]
+ = (s_("CompareBranches|%{source_branch} and %{target_branch} are the same.") % { source_branch: source_branch, target_branch: target_branch }).html_safe
- else
- You'll need to use different branch names to get a valid comparison.
+ = _("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 7000b289f75..5041f322612 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,25 +1,11 @@
- @no_container = true
- page_title "Cycle Analytics"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('cycle_analytics')
-
-= render "projects/head"
#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
- .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" }
- %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box' }
- = icon("times", "@click" => "dismissOverviewDialog()")
- .svg-container
- = custom_icon('icon_cycle_analytics_splash')
- .inner-content
- %h4
- {{ __('Introducing Cycle Analytics') }}
- %p
- {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }}
- %p
- = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
+ %banner{ "v-if" => "!isOverviewDialogDismissed",
+ "documentation-link": help_page_path('user/project/cycle_analytics'),
+ "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" }
= icon("spinner spin", "v-show" => "isLoading")
.wrapper{ "v-show" => "!isLoading && !hasError" }
.panel.panel-default
@@ -38,6 +24,9 @@
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
+ %a{ "href" => "#", "data-value" => "7" }
+ {{ n__('Last %d day', 'Last %d days', 7) }}
+ %li
%a{ "href" => "#", "data-value" => "30" }
{{ n__('Last %d day', 'Last %d days', 30) }}
%li
diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml
index edaa3a1119e..c363180d0db 100644
--- a/app/views/projects/deploy_keys/_form.html.haml
+++ b/app/views/projects/deploy_keys/_form.html.haml
@@ -10,13 +10,15 @@
%p.light.append-bottom-0
Paste a machine public key here. Read more about how to generate it
= link_to "here", help_page_path("ssh/README")
- .form-group
- .checkbox
- = f.label :can_push do
- = f.check_box :can_push
- %strong Write access allowed
- .form-group
- %p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
+
+ = f.fields_for :deploy_keys_projects do |deploy_keys_project_form|
+ .form-group
+ .checkbox
+ = deploy_keys_project_form.label :can_push do
+ = deploy_keys_project_form.check_box :can_push
+ %strong Write access allowed
+ .form-group
+ %p.light.append-bottom-0
+ Allow this key to push to repository as well? (Default only allows pull access.)
= f.submit "Add key", class: "btn-create btn"
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index cb98ce04430..75dd4c9ae15 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,15 +1,15 @@
- expanded = Rails.env.test?
-%section.settings
+%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Deploy Keys
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle.qa-expand-deploy-keys
= expanded ? 'Collapse' : '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.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .settings-content
%h5.prepend-top-0
Create a new deploy key for this project
= render @deploy_keys.form_partial_path
%hr
- #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
+ #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project) } }
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index 37219f8d7ae..cd910b82b57 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -7,4 +7,4 @@
= 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'
+ = link_to 'Cancel', project_settings_repository_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 4502c397d29..c7ac687e4a6 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -2,16 +2,16 @@
.branch-commit
- if deployment.ref
%span.icon-container
- = deployment.tag? ? icon('tag') : icon('code-fork')
+ = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= 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"
+ = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha"
%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"
+ = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message"
- else
Cant find HEAD commit for this branch
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index d538c4c86c8..376f672f424 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -2,22 +2,24 @@
- show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true)
- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project)
- diff_files = diffs.diff_files
+- merge_request = local_assigns.fetch(:merge_request, false)
-.content-block.oneline-block.files-changed
- .inline-parallel-buttons
- - 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')
- - elsif current_controller?(:merge_requests)
- = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs')
- - elsif current_controller?(:compare)
- = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs')
- .btn-group
- = inline_diff_btn
- = parallel_diff_btn
- = render 'projects/diffs/stats', diff_files: diff_files
+.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed{ class: ("diff-files-changed-merge-request" if merge_request) }
+ .files-changed-inner
+ .inline-parallel-buttons.hidden-xs.hidden-sm
+ - 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')
+ - elsif current_controller?('projects/merge_requests/diffs')
+ = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs')
+ - elsif current_controller?(:compare)
+ = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs')
+ .btn-group
+ = inline_diff_btn
+ = parallel_diff_btn
+ = render 'projects/diffs/stats', diff_files: diff_files
- if render_overflow_warning?(diff_files)
= render 'projects/diffs/warning', diff_files: diffs
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index b5aea217384..47bfcb21cf4 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -1,5 +1,8 @@
- environment = local_assigns.fetch(:environment, nil)
- file_hash = hexdigest(diff_file.file_path)
+- image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image'
+- image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha
+
.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
@@ -8,15 +11,18 @@
- unless diff_file.submodule?
- blob = diff_file.blob
.file-actions.hidden-xs
- - if blob.readable_text?
+ - 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
= icon('comment')
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
- = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
+ = edit_blob_button(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
blob: blob, link_opts: link_opts)
+ - if image_diff && image_replaced
+ = view_file_button(diff_file.old_content_sha, diff_file.old_path, project, replaced: true)
+
= 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
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index 73c316472e3..dbeddf6689a 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -35,3 +35,6 @@
- if diff_file.mode_changed?
%small
#{diff_file.a_mode} → #{diff_file.b_mode}
+
+ - if diff_file.stored_externally? && diff_file.external_storage == :lfs
+ %span.label.label-lfs.append-right-5 LFS
diff --git a/app/views/projects/diffs/_image_diff_frame.html.haml b/app/views/projects/diffs/_image_diff_frame.html.haml
new file mode 100644
index 00000000000..dae73e10460
--- /dev/null
+++ b/app/views/projects/diffs/_image_diff_frame.html.haml
@@ -0,0 +1,5 @@
+- class_name = local_assigns.fetch(:class_name, '')
+- note_type = local_assigns.fetch(:note_type, '')
+
+.frame{ class: class_name, data: { position: position, note_type: note_type } }
+ = image_tag(image_path, alt: alt, draggable: false, lazy: false)
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 43708d22a0c..cd0fb21f8a7 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -19,6 +19,7 @@
- if plain
= link_text
- else
+ = add_diff_note_button(line_code, diff_file.position(line), type)
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- discussion = line_discussions.try(:first)
- if discussion && discussion.resolvable? && !plain
@@ -29,7 +30,7 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
+ %td.line_content.noteable_line{ class: type }<
- if email
%pre= line.text
- else
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 8e5f4d2573d..1f0ca211074 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -1,4 +1,5 @@
/ Side-by-side diff view
+
.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data }
%table
- diff_file.parallel_diff_lines.each do |line|
@@ -13,19 +14,20 @@
= diff_match_line left.old_pos, nil, text: left.text, view: :parallel
- when 'old-nonewline', 'new-nonewline'
%td.old_line.diff-line-num
- %td.line_content.match= left.text
+ %td.line_content.match.left-side= left.text
- else
- left_line_code = diff_file.line_code(left)
- left_position = diff_file.position(left)
- %td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } }
+ %td.old_line.diff-line-num.js-avatar-container{ class: left.type, data: { linenumber: left.old_pos } }
+ = add_diff_note_button(left_line_code, left_position, 'old')
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
- discussion_left = discussions_left.try(:first)
- if discussion_left && discussion_left.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_left.id }
- %td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text)
+ %td.line_content.parallel.noteable_line.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.text)
- else
%td.old_line.diff-line-num.empty-cell
- %td.line_content.parallel
+ %td.line_content.parallel.left-side
- if right
- case right.type
@@ -33,19 +35,20 @@
= diff_match_line nil, right.new_pos, text: left.text, view: :parallel
- when 'old-nonewline', 'new-nonewline'
%td.new_line.diff-line-num
- %td.line_content.match= right.text
+ %td.line_content.match.right-side= right.text
- else
- right_line_code = diff_file.line_code(right)
- right_position = diff_file.position(right)
- %td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } }
+ %td.new_line.diff-line-num.js-avatar-container{ class: right.type, data: { linenumber: right.new_pos } }
+ = add_diff_note_button(right_line_code, right_position, 'new')
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
- discussion_right = discussions_right.try(:first)
- if discussion_right && discussion_right.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_right.id }
- %td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text)
+ %td.line_content.parallel.noteable_line.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.text)
- else
%td.old_line.diff-line-num.empty-cell
- %td.line_content.parallel
+ %td.line_content.parallel.right-side
- if discussions_left || discussions_right
= render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right
diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml
new file mode 100644
index 00000000000..6dffc7c4390
--- /dev/null
+++ b/app/views/projects/diffs/_replaced_image_diff.html.haml
@@ -0,0 +1,61 @@
+- blob = diff_file.blob
+- old_blob = diff_file.old_blob
+- blob_raw_url = diff_file_blob_raw_url(diff_file)
+- old_blob_raw_url = diff_file_old_blob_raw_url(diff_file)
+- click_to_comment = local_assigns.fetch(:click_to_comment, true)
+- diff_view_data = local_assigns.fetch(:diff_view_data, '')
+- class_name = ''
+
+- if click_to_comment
+ - class_name = 'js-add-image-diff-note-button click-to-comment'
+
+.image.js-replaced-image{ data: diff_view_data }
+ .two-up.view
+ .wrap
+ .frame.deleted
+ = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
+ %p.image-info.hide
+ %span.meta-filesize= number_to_human_size(old_blob.size)
+ |
+ %strong W:
+ %span.meta-width
+ |
+ %strong H:
+ %span.meta-height
+ .wrap
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
+ %p.image-info.hide
+ %span.meta-filesize= number_to_human_size(blob.size)
+ |
+ %strong W:
+ %span.meta-width
+ |
+ %strong H:
+ %span.meta-height
+
+ .swipe.view.hide
+ .swipe-frame
+ .frame.deleted
+ = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
+ .swipe-wrap
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
+ %span.swipe-bar
+ %span.top-handle
+ %span.bottom-handle
+
+ .onion-skin.view.hide
+ .onion-skin-frame
+ .frame.deleted
+ = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, 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/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml
new file mode 100644
index 00000000000..12be8beab39
--- /dev/null
+++ b/app/views/projects/diffs/_single_image_diff.html.haml
@@ -0,0 +1,16 @@
+- blob = diff_file.blob
+- old_blob = diff_file.old_blob
+- blob_raw_url = diff_file_blob_raw_url(diff_file)
+- old_blob_raw_url = diff_file_old_blob_raw_url(diff_file)
+- click_to_comment = local_assigns.fetch(:click_to_comment, true)
+- diff_view_data = local_assigns.fetch(:diff_view_data, '')
+- class_name = ''
+
+- if click_to_comment
+ - class_name = 'js-add-image-diff-note-button click-to-comment'
+
+.image.js-single-image{ data: diff_view_data }
+ .wrap
+ - single_class_name = diff_file.deleted_file? ? 'deleted' : 'added'
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
+ %p.image-info= number_to_human_size(blob.size)
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index e69c7f20d49..6fd6018dea3 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -1,36 +1,41 @@
-.js-toggle-container
- .commit-stat-summary
- Showing
- %button.diff-stats-summary-toggler.js-toggle-button{ type: "button" }
- %strong= pluralize(diff_files.size, "changed file")
+- sum_added_lines = diff_files.sum(&:added_lines)
+- sum_removed_lines = diff_files.sum(&:removed_lines)
+.commit-stat-summary.dropdown
+ Showing
+ %button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown" } }<
+ = pluralize(diff_files.size, "changed file")
+ = icon("caret-down", class: "prepend-left-5")
+ %span.diff-stats-additions-deletions-expanded#diff-stats
with
- %strong.cgreen #{diff_files.sum(&:added_lines)} additions
+ %strong.cgreen= pluralize(sum_added_lines, 'addition')
and
- %strong.cred #{diff_files.sum(&:removed_lines)} deletions
- .file-stats.js-toggle-content.hide
- %ul
- - diff_files.each do |diff_file|
- - file_hash = hexdigest(diff_file.file_path)
- %li
- - 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?
- %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?
- %span.new-file
- %a{ href: "##{file_hash}" }
- %i.fa.fa-plus
- = diff_file.new_path
- - else
- %span.edit-file
- %a{ href: "##{file_hash}" }
- %i.fa.fa-adjust
- = diff_file.new_path
+ %strong.cred= pluralize(sum_removed_lines, 'deletion')
+ .diff-stats-additions-deletions-collapsed.pull-right.hidden-xs.hidden-sm{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
+ %strong.cgreen<
+ +#{sum_added_lines}
+ %strong.cred<
+ \-#{sum_removed_lines}
+ .dropdown-menu.diff-file-changes
+ = dropdown_filter("Search files")
+ .dropdown-content
+ %ul
+ - diff_files.each do |diff_file|
+ %li
+ %a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
+ = sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon append-right-8")
+ %span.diff-changed-file-content.append-right-8
+ - if diff_file.blob&.name
+ %strong.diff-changed-file-name
+ = diff_file.blob.name
+ - else
+ %strong.diff-changed-blank-file-name
+ = s_('Diffs|No file name available')
+ %span.diff-changed-file-path.prepend-top-5= diff_file_path_text(diff_file)
+ %span.diff-changed-stats
+ %span.cgreen<
+ +#{diff_file.added_lines}
+ %span.cred<
+ \-#{diff_file.removed_lines}
+ %li.dropdown-menu-empty-item.hidden
+ %a
+ No files found.
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
index 295a1b62535..da34a83d8e0 100644
--- a/app/views/projects/diffs/_warning.html.haml
+++ b/app/views/projects/diffs/_warning.html.haml
@@ -2,13 +2,12 @@
%h4
Too many changes to show.
.pull-right
- - if current_controller?(:commit) or current_controller?(:merge_requests)
- - if current_controller?(:commit)
- = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-sm"
- = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-sm"
- - elsif @merge_request && @merge_request.persisted?
- = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm"
- = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm"
+ - if current_controller?(:commit)
+ = link_to "Plain diff", project_commit_path(@project, @commit, format: :diff), class: "btn btn-sm"
+ = link_to "Email patch", project_commit_path(@project, @commit, format: :patch), class: "btn btn-sm"
+ - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted?
+ = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm"
+ = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm"
%p
To preserve performance only
%strong #{diff_files.size} of #{diff_files.real_size}
diff --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml
index 19d08181223..f190073c2fc 100644
--- a/app/views/projects/diffs/viewers/_image.html.haml
+++ b/app/views/projects/diffs/viewers/_image.html.haml
@@ -1,69 +1,13 @@
- diff_file = viewer.diff_file
-- 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)
+- image_point = Gitlab::Diff::ImagePoint.new(nil, nil, nil, nil)
+- discussions = @grouped_diff_discussions[diff_file.new_path] if @grouped_diff_discussions
+
+- locals = { diff_file: diff_file, position: diff_file.position(image_point, position_type: :image).to_json, click_to_comment: true, diff_view_data: diff_view_data }
- 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)
+ = render partial: "projects/diffs/single_image_diff", locals: locals
- 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
-
+ = render partial: "projects/diffs/replaced_image_diff", locals: locals
- .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
+.note-container
+ = render partial: "discussions/notes", collection: discussions, as: :discussion
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 78057facde7..a96485ab155 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,12 +1,18 @@
+- breadcrumb_title "General Settings"
+- page_title "General"
- @content_class = "limit-container-width" unless fluid_layout
+- expanded = Rails.env.test?
-= render "projects/settings/head"
.project-edit-container
- .row.prepend-top-default
- .col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
- Project settings
- .col-lg-8
+ %section.settings.general-settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ General project settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Update your project name, description, avatar, and other general settings.
+ .settings-content
.project-edit-errors
= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f|
%fieldset
@@ -35,264 +41,156 @@
= f.label :tag_list, "Tags", class: 'label-light'
= f.text_field :tag_list, value: @project.tag_list.sort.join(', '), maxlength: 2000, class: "form-control"
%p.help-block Separate tags with commas.
- %hr
- %fieldset
- %h5.prepend-top-0
- Sharing &amp; Permissions
- .form_group.prepend-top-20.sharing-and-permissions
- .row.js-visibility-select
- .col-md-8
- .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-4.visibility-select-container
- = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
- = f.fields_for :project_feature do |feature_fields|
- %fieldset.features
- .row
- .col-md-8.project-feature
- = feature_fields.label :repository_access_level, "Repository", class: 'label-light'
- %span.help-block View and edit files in this project
- .col-md-4.js-repo-access-level
- = project_feature_access_select(:repository_access_level)
-
- .row
- .col-md-8.project-feature.nested
- = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
- %span.help-block Submit changes to be merged upstream
- .col-md-4
- = project_feature_access_select(:merge_requests_access_level)
-
- .row
- .col-md-8.project-feature.nested
- = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
- %span.help-block Build, test, and deploy your changes
- .col-md-4
- = project_feature_access_select(:builds_access_level)
-
- .row
- .col-md-8.project-feature
- = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
- %span.help-block Share code pastes with others out of Git repository
- .col-md-4
- = project_feature_access_select(:snippets_access_level)
-
- .row
- .col-md-8.project-feature
- = feature_fields.label :issues_access_level, "Issues", class: 'label-light'
- %span.help-block Lightweight issue tracking system for this project
- .col-md-4
- = project_feature_access_select(:issues_access_level)
-
- .row
- .col-md-8.project-feature
- = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
- %span.help-block Pages for project documentation
- .col-md-4
- = project_feature_access_select(:wiki_access_level)
- .form-group
- = render 'shared/allow_request_access', form: f
- - if Gitlab.config.lfs.enabled && current_user.admin?
- .row.js-lfs-enabled
- .col-md-8
- = 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-4
- .select-wrapper
- = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select select-control', data: { field: 'lfs_enabled' }
- = icon('chevron-down')
- - if Gitlab.config.registry.enabled
- .form-group.js-container-registry{ style: ("display: none;" if @project.project_feature.send(:repository_access_level) == 0) }
- .checkbox
- = f.label :container_registry_enabled do
- = f.check_box :container_registry_enabled
- %strong Container Registry
- %br
- %span.descr Enable Container Registry for this project
- = link_to icon('question-circle'), help_page_path('user/project/container_registry'), target: '_blank'
-
- = render 'merge_request_settings', form: f
-
- %hr
- %fieldset.features.append-bottom-default
- %h5.prepend-top-0
- Project avatar
+ %fieldset.features
+ %h5.prepend-top-0= _("Project avatar")
.form-group
- if @project.avatar?
- .avatar-container.s160
+ .avatar-container.s160.append-bottom-15
= project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160')
- %p.light
- - if @project.avatar_in_git
- Project avatar in repository: #{ @project.avatar_in_git }
- %a.choose-btn.btn.js-choose-project-avatar-button
- Browse file...
- %span.file_name.prepend-left-default.js-avatar-filename No file chosen
- = f.file_field :avatar, class: "js-project-avatar-input hidden"
- .help-block The maximum file size allowed is 200KB.
+ - if @project.avatar_in_git
+ %p.light
+ = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
+ .prepend-top-5.append-bottom-10
+ %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...")
+ %span.file_name.prepend-left-default.js-avatar-filename= _("No file chosen")
+ = f.file_field :avatar, class: "js-project-avatar-input hidden"
+ .help-block= _("The maximum file size allowed is 200KB.")
- if @project.avatar?
%hr
- = link_to 'Remove avatar', namespace_project_avatar_path(@project.namespace, @project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar"
+ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted"
+ = f.submit 'Save changes', class: "btn btn-success js-btn-save-general-project-settings"
+
+ %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Permissions
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Enable or disable certain project features and choose access levels.
+ .settings-content
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
+ -# haml-lint:disable InlineJavaScript
+ %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project)
+ .js-project-permissions-form
= f.submit 'Save changes', class: "btn btn-save"
- .row.prepend-top-default
- %hr
- .row.prepend-top-default
- .col-lg-4
- %h4.prepend-top-0
- Housekeeping
- %p.append-bottom-0
- %p
- Runs a number of housekeeping tasks within the current repository,
- such as compressing file revisions and removing unreachable objects.
- .col-lg-8
- = link_to 'Housekeeping', housekeeping_namespace_project_path(@project.namespace, @project),
- method: :post, class: "btn btn-default"
- %hr
- .row.prepend-top-default
- .col-lg-4
- %h4.prepend-top-0
- Export project
- %p.append-bottom-0
- %p
- Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
+ %section.settings.merge-requests-feature.no-animate{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
+ .settings-header
+ %h4
+ Merge request settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Customize your merge request restrictions.
+ .settings-content
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f|
+ = render 'merge_request_settings', form: f
+ = f.submit 'Save changes', class: "btn btn-save qa-save-merge-request-changes"
+
+ = render 'export', project: @project
+
+ %section.settings.advanced-settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Advanced settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project.
+ .settings-content
+ .sub-section
+ %h4 Housekeeping
%p
- Once the exported file is ready, you will receive a notification email with a download link.
-
- .col-lg-8
-
- - if @project.export_project_path
- = link_to 'Download export', download_export_namespace_project_path(@project.namespace, @project),
- rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
- = link_to 'Generate new export', generate_new_export_namespace_project_path(@project.namespace, @project),
- method: :post, class: "btn btn-default"
- - else
- = link_to 'Export project', export_namespace_project_path(@project.namespace, @project),
- method: :post, class: "btn btn-default"
-
- .bs-callout.bs-callout-info
- %p.append-bottom-0
- %p
- The following items will be exported:
- %ul
- %li Project and wiki repositories
- %li Project uploads
- %li Project configuration including web hooks and services
- %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
- %p
- The following items will NOT be exported:
- %ul
- %li Job traces and artifacts
- %li LFS objects
- %li Container registry images
- %li CI variables
- %li Any encrypted tokens
- - if can? current_user, :archive_project, @project
- %hr
- .row.prepend-top-default
- .col-lg-4
- %h4.warning-title.prepend-top-0
+ Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.
+ = link_to 'Run housekeeping', housekeeping_project_path(@project),
+ method: :post, class: "btn btn-default"
+ - if can? current_user, :archive_project, @project
+ .sub-section
+ %h4.warning-title
+ - if @project.archived?
+ Unarchive project
+ - else
+ Archive project
- if @project.archived?
- Unarchive project
+ %p
+ Unarchiving the project will mark its repository as active. The project can be committed to.
+ %strong Once active this project shows up in the search and on the dashboard.
+ = link_to 'Unarchive project', unarchive_project_path(@project),
+ data: { confirm: "Are you sure that you want to unarchive this project?\nWhen this project is unarchived it is active and can be committed to again." },
+ method: :post, class: "btn btn-success"
- else
- Archive project
- %p.append-bottom-0
- - if @project.archived?
- Unarchiving the project will mark its repository as active. The project can be committed to.
- - else
- Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches.
- .col-lg-8
- - if @project.archived?
- %p
- %strong Once active this project shows up in the search and on the dashboard.
- = link_to 'Unarchive project', unarchive_namespace_project_path(@project.namespace, @project),
- data: { confirm: "Are you sure that you want to unarchive this project?\nWhen this project is unarchived it is active and can be committed to again." },
- method: :post, class: "btn btn-success"
- - else
- %p
- %strong Archived projects cannot be committed to!
- = link_to 'Archive project', archive_namespace_project_path(@project.namespace, @project),
- data: { confirm: "Are you sure that you want to archive this project?\nAn archived project cannot be committed to." },
- method: :post, class: "btn btn-warning"
- %hr
- .row.prepend-top-default
- .col-lg-4
- %h4.prepend-top-0.warning-title
- Rename repository
- .col-lg-8
- = render 'projects/errors'
- = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
- .form-group.project_name_holder
- = f.label :name, class: 'label-light' do
- Project name
- .form-group
- = f.text_field :name, class: "form-control"
- .form-group
- = f.label :path, class: 'label-light' do
- %span Path
- .form-group
- .input-group
- .input-group-addon
- #{URI.join(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control'
- %ul
- %li Be careful. Renaming a project's repository can have unintended side effects.
- %li You will need to update your local repositories to point to the new location.
- - if @project.deployment_services.any?
- %li Your deployment services will be broken, you will need to manually fix the services after renaming.
- = f.submit 'Rename project', class: "btn btn-warning"
- - if can?(current_user, :change_namespace, @project)
- %hr
- .row.prepend-top-default
- .col-lg-4
- %h4.prepend-top-0.danger-title
- 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-8
- = 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|
+ %p
+ Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches.
+ %strong Archived projects cannot be committed to!
+ = link_to 'Archive project', archive_project_path(@project),
+ data: { confirm: "Are you sure that you want to archive this project?\nAn archived project cannot be committed to." },
+ method: :post, class: "btn btn-warning"
+ .sub-section.rename-respository
+ %h4.warning-title
+ Rename repository
+ = render 'projects/errors'
+ = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
+ .form-group.project_name_holder
+ = f.label :name, class: 'label-light' do
+ Project name
+ .form-group
+ = f.text_field :name, class: "form-control"
.form-group
- = label_tag :new_namespace_id, nil, class: 'label-light' do
- %span Select a new namespace
+ = f.label :path, class: 'label-light' do
+ %span Path
.form-group
- = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
+ .input-group
+ .input-group-addon
+ #{URI.join(root_url, @project.namespace.full_path)}/
+ = f.text_field :path, class: 'form-control'
%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.
+ %li Be careful. Renaming a project's repository can have unintended side effects.
%li You will need to update your local repositories to point to the new location.
- %li Project visibility level will be changed to match namespace rules when transfering to a group.
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
- - if @project.forked? && can?(current_user, :remove_fork_project, @project)
- %hr
- .row.prepend-top-default.append-bottom-default
- .col-lg-4
- %h4.prepend-top-0.danger-title
- Remove fork relationship
- %p.append-bottom-0
+ - if @project.deployment_platform.present?
+ %li Your deployment services will be broken, you will need to manually fix the services after renaming.
+ = f.submit 'Rename project', class: "btn btn-warning"
+ - if can?(current_user, :change_namespace, @project)
+ .sub-section
+ %h4.danger-title
+ Transfer project
+ = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@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 Select a new namespace
+ .form-group
+ = 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.
+ %li You will need to update your local repositories to point to the new location.
+ %li Project visibility level will be changed to match namespace rules when transferring to a group.
+ = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
+ - if @project.forked? && can?(current_user, :remove_fork_project, @project)
+ .sub-section
+ %h4.danger-title
+ Remove fork relationship
%p
This will remove the fork relationship to source project
= succeed "." do
- = link_to @project.forked_from_project.name_with_namespace, project_path(@project.forked_from_project)
- .col-lg-8
- = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_namespace_project_path(@project.namespace, @project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
- %p
- %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.
- = button_to 'Remove fork relationship', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) }
- - if can?(current_user, :remove_project, @project)
- %hr
- .row.prepend-top-default.append-bottom-default
- .col-lg-4
- %h4.prepend-top-0.danger-title
- Remove project
- %p.append-bottom-0
- Removing the project will delete its repository and all related resources including issues, merge requests etc.
- .col-lg-8
- = form_tag(namespace_project_path(@project.namespace, @project), method: :delete) do
+ - if @project.fork_source
+ = link_to(fork_source_name(@project), project_path(@project.fork_source))
+ - else
+ = fork_source_name(@project)
+ = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
+ %p
+ %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.
+ = button_to 'Remove fork relationship', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) }
+ - if can?(current_user, :remove_project, @project)
+ .sub-section
+ %h4.danger-title
+ Remove project
%p
- %strong Removed projects cannot be restored!
- = button_to 'Remove project', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
+ Removing the project will delete its repository and all related resources including issues, merge requests etc.
+ = form_tag(project_path(@project), method: :delete) do
+ %p
+ %strong Removed projects cannot be restored!
+ = button_to 'Remove project', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
.save-project-loader.hide
.center
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 50e0bad3ccf..8a36fada389 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,37 +1,45 @@
- @no_container = true
+- breadcrumb_title "Details"
-= content_for :flash_message do
- - if current_user && can?(current_user, :download_code, @project)
- = render 'shared/no_ssh'
- = render 'shared/no_password'
+= render partial: 'flash_messages', locals: { project: @project }
-= render "projects/head"
= render "home_panel"
-.row-content-block.second-block.center
- %h3.page-title
- The repository for this project is empty
- - if can?(current_user, :push_code, @project)
- %p
- If you already have files you can push them using command line instructions below.
- %p
- Otherwise you can start with adding a
- = succeed ',' do
- = link_to "README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link'
- a
- = succeed ',' do
- = link_to "LICENSE", add_special_file_path(@project, file_name: 'LICENSE'), class: 'underlined-link'
- or a
- = link_to '.gitignore', add_special_file_path(@project, file_name: '.gitignore'), class: 'underlined-link'
- to this project.
- %p
- You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected.
+.project-empty-note-panel
+ %div{ class: [container_class, ("limit-container-width-sm" unless fluid_layout)] }
+ .prepend-top-20
+ %h4
+ = _('The repository for this project is empty')
+
+ - if @project.can_current_user_push_code?
+ %p
+ - link_to_cli = link_to _('command line instructions'), '#repo-command-line-instructions'
+ = _('If you already have files you can push them using the %{link_to_cli} below.').html_safe % { link_to_cli: link_to_cli }
+ %p
+ %em
+ - link_to_protected_branches = link_to _('Learn more about protected branches'), help_page_path('user/project/protected_branches')
+ = _('Note that the master branch is automatically protected. %{link_to_protected_branches}').html_safe % { link_to_protected_branches: link_to_protected_branches }
+
+ %hr
+ %p
+ - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
+ - link_to_add_kubernetes_cluster = link_to(s_('AutoDevOps|add a Kubernetes cluster'), new_project_cluster_path(@project))
+ = s_('AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}.').html_safe % { link_to_auto_devops_settings: link_to_auto_devops_settings, link_to_add_kubernetes_cluster: link_to_add_kubernetes_cluster }
+
+ %hr
+ %p
+ = _('Otherwise it is recommended you start with one of the options below.')
+ .prepend-top-20
+
+%nav.project-stats{ class: container_class }
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
- if can?(current_user, :push_code, @project)
- %div{ class: container_class }
+ %div{ class: [container_class, ("limit-container-width-sm" unless fluid_layout)] }
.prepend-top-20
.empty_wrapper
- %h3.page-title-empty
+ %h3#repo-command-line-instructions.page-title-empty
Command line instructions
.git-empty
%fieldset
@@ -68,10 +76,11 @@
%pre.light-well
:preserve
cd existing_repo
+ git remote rename origin old-origin
git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')}
git push -u origin --all
git push -u origin --tags
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
+ = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove pull-right"
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index 6d040f5cfe6..1605f3a3351 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -19,4 +19,4 @@
.form-actions
= f.submit 'Save', class: 'btn btn-save'
- = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel'
+ = link_to 'Cancel', project_environments_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/environments/_stop.html.haml b/app/views/projects/environments/_stop.html.haml
index 14a2d627203..c35f9af2873 100644
--- a/app/views/projects/environments/_stop.html.haml
+++ b/app/views/projects/environments/_stop.html.haml
@@ -1,5 +1,5 @@
- if can?(current_user, :create_deployment, environment) && environment.stop_action?
.inline
- = link_to stop_namespace_project_environment_path(@project.namespace, @project, environment), method: :post,
+ = link_to stop_project_environment_path(@project, environment), method: :post,
class: 'btn stop-env-link', rel: 'nofollow', data: { confirm: 'Are you sure you want to stop this environment?' } do
= icon('stop', class: 'stop-env-icon')
diff --git a/app/views/projects/environments/_terminal_button.html.haml b/app/views/projects/environments/_terminal_button.html.haml
index 97de9c95de7..a6201bdbc42 100644
--- a/app/views/projects/environments/_terminal_button.html.haml
+++ b/app/views/projects/environments/_terminal_button.html.haml
@@ -1,3 +1,3 @@
- if environment.has_terminals? && can?(current_user, :admin_environment, @project)
- = link_to terminal_namespace_project_environment_path(@project.namespace, @project, environment), class: 'btn terminal-button' do
+ = link_to terminal_project_environment_path(@project, environment), class: 'btn terminal-button' do
= icon('terminal')
diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml
index 3871165763c..d6ff3f729b4 100644
--- a/app/views/projects/environments/edit.html.haml
+++ b/app/views/projects/environments/edit.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Edit", @environment.name, "Environments"
-= render "projects/pipelines/head"
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index f7e3733ba0b..1ac7dab6775 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -1,11 +1,8 @@
- @no_container = true
- page_title "Environments"
-= render "projects/pipelines/head"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag("environments_folder")
-
-#environments-folder-list-view{ data: { "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
+#environments-folder-list-view{ data: { endpoint: folder_project_environments_path(@project, @folder, format: :json),
+ "folder-name" => @folder,
+ "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
"css-class" => container_class } }
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 80d2b6f5d95..7ebe617766f 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -1,17 +1,11 @@
- @no_container = true
- page_title "Environments"
-= render "projects/pipelines/head"
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag("environments")
+- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
#environments-list-view{ data: { environments_data: environments_list_data,
"can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
"can-create-environment" => can?(current_user, :create_environment, @project).to_s,
- "project-environments-path" => project_environments_path(@project),
- "project-stopped-environments-path" => project_environments_path(@project, scope: :stopped),
- "new-environment-path" => new_namespace_project_environment_path(@project.namespace, @project),
+ "new-environment-path" => new_project_environment_path(@project),
"help-page-path" => help_page_path("ci/environments"),
"css-class" => container_class } }
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index e8f8fbbcf09..d6f0b230b58 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -1,80 +1,23 @@
- @no_container = true
- page_title "Metrics for environment", @environment.name
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('monitoring')
-= render "projects/pipelines/head"
-#js-metrics.prometheus-container{ class: container_class, data: { has_metrics: "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } }
+.prometheus-container{ class: container_class }
.top-area
.row
.col-sm-6
- %h3.page-title
+ %h3
Environment:
= link_to @environment.name, environment_path(@environment)
- .prometheus-state
- .js-getting-started.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- = render "shared/empty_states/monitoring/getting_started.svg"
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Get started with performance monitoring
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.
- = link_to help_page_path('administration/monitoring/prometheus/index.md') do
- Learn more about performance monitoring
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do
- Configure Prometheus
- .js-loading.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- = render "shared/empty_states/monitoring/loading.svg"
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Waiting for performance data
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- = link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do
- View documentation
- .js-unable-to-connect.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- = render "shared/empty_states/monitoring/unable_to_connect.svg"
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Unable to connect to Prometheus server
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Ensure connectivity is available from the GitLab server to the
- = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do
- Prometheus server
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- = link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do
- View documentation
-
- .prometheus-graphs
- .row
- .col-sm-12
- %h4
- CPU utilization
- %svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
- .row
- .col-sm-12
- %h4
- Memory usage
- %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
+ #prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'),
+ "clusters-path": project_clusters_path(@project),
+ "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'),
+ "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'),
+ "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'),
+ "empty-no-data-svg-path": image_path('illustrations/monitoring/no_data.svg'),
+ "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'),
+ "metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json),
+ "deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json),
+ "project-path": project_path(@project),
+ "tags-path": project_tags_path(@project),
+ "has-metrics": "#{@environment.has_metrics?}" } }
diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml
index 24638c77cbb..62b08e85e22 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
+- breadcrumb_title "Environments"
- page_title 'New Environment'
-= render "projects/pipelines/head"
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 31e2bb11ce8..add394a6356 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -1,6 +1,7 @@
- @no_container = true
+- add_to_breadcrumbs "Environments", project_environments_path(@project)
+- breadcrumb_title @environment.name
- page_title "Environments"
-= render "projects/pipelines/head"
%div{ class: container_class }
.row.top-area.adjust
@@ -12,20 +13,21 @@
= render 'projects/environments/external_url', environment: @environment
= 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'
+ = link_to 'Edit', edit_project_environment_path(@project, @environment), class: 'btn'
- 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
+ = link_to 'Stop', stop_project_environment_path(@project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post
.environments-container
- if @deployments.blank?
- .blank-state.blank-state-no-icon
- %h2.blank-state-title
- You don't have any deployments right now.
- %p.blank-state-text
- Define environments in the deploy stage(s) in
- %code .gitlab-ci.yml
- to track deployments here.
- = link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success"
+ .blank-state-row
+ .blank-state-center
+ %h2.blank-state-title
+ You don't have any deployments right now.
+ %p.blank-state-text
+ Define environments in the deploy stage(s) in
+ %code .gitlab-ci.yml
+ to track deployments here.
+ = link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success"
- else
.table-holder
.ci-table.environments{ role: 'grid' }
diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml
index 4c4aa0baff3..6ec4ff56552 100644
--- a/app/views/projects/environments/terminal.html.haml
+++ b/app/views/projects/environments/terminal.html.haml
@@ -1,10 +1,8 @@
- @no_container = true
- page_title "Terminal for environment", @environment.name
-= render "projects/pipelines/head"
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm/xterm"
- = page_specific_javascript_bundle_tag("terminal")
%div{ class: container_class }
.top-area
@@ -22,4 +20,4 @@
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.terminal-container{ class: container_class }
- #terminal{ data: { project_path: "#{terminal_namespace_project_environment_path(@project.namespace, @project, @environment)}.ws" } }
+ #terminal{ data: { project_path: "#{terminal_project_environment_path(@project, @environment)}.ws" } }
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 8a409541fe5..a3467eb6f05 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,13 +1,12 @@
- page_title "Find File", @ref
-= render "projects/commits/head"
-.file-finder-holder.tree-holder.clearfix
+.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, @options.merge(format: :json)))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) }
.nav-block
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'find_file', path: @path
%ul.breadcrumb.repo-breadcrumb
%li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
+ = link_to project_tree_path(@project, @ref) do
= @project.path
%li.file-finder
%input#file_find.form-control.file-finder-input{ type: "text", placeholder: _('Find by path'), autocomplete: 'off' }
@@ -17,11 +16,3 @@
%table.table.files-slider{ class: "table_#{@hex_path} tree-table table-striped" }
%tbody
= spinner nil, true
-
-:javascript
- var projectFindFile = new ProjectFindFile($(".file-finder-holder"), {
- url: "#{escape_javascript(namespace_project_files_path(@project.namespace, @project, @ref, @options.merge(format: :json)))}",
- treeUrl: "#{escape_javascript(namespace_project_tree_path(@project.namespace, @project, @ref))}",
- blobUrlTemplate: "#{escape_javascript(namespace_project_blob_path(@project.namespace, @project, @id || @commit.id))}"
- });
- new ShortcutsFindFile(projectFindFile);
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
new file mode 100644
index 00000000000..8a549d431ee
--- /dev/null
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -0,0 +1,26 @@
+- avatar = namespace_icon(namespace, 100)
+- can_create_project = current_user.can?(:create_projects, namespace)
+
+- if forked_project = namespace.find_fork_of(@project)
+ .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked
+ = link_to project_path(forked_project) do
+ - if /no_((\w*)_)*avatar/.match(avatar)
+ = project_identicon(namespace, class: "avatar s100 identicon")
+ - else
+ .avatar-container.s100
+ = image_tag(avatar, class: "avatar s100")
+ %h5.prepend-top-default
+ = namespace.human_name
+- else
+ .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: ("disabled" unless can_create_project) }
+ = link_to project_forks_path(@project, namespace_key: namespace.id),
+ method: "POST",
+ class: ("disabled has-tooltip" unless can_create_project),
+ title: (_('You have reached your project limit') unless can_create_project) do
+ - if /no_((\w*)_)*avatar/.match(avatar)
+ = project_identicon(namespace, class: "avatar s100 identicon")
+ - else
+ .avatar-container.s100
+ = image_tag(avatar, class: "avatar s100")
+ %h5.prepend-top-default
+ = namespace.human_name
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 524b77783ef..e8a89b8c6fc 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -2,7 +2,7 @@
- if @forked_project && !@forked_project.saved?
.alert.alert-danger.alert-block
%h4
- %i.fa.fa-code-fork
+ = sprite_icon('fork', size: 16)
Fork Error!
%p
You tried to fork
@@ -20,6 +20,5 @@
= error
%p
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork", class: "btn" do
- %i.fa.fa-code-fork
+ = link_to new_project_fork_path(@project), title: "Fork", class: "btn" do
Try to fork again
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index f4aa523b32d..21a4702a2a9 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -31,11 +31,11 @@
- 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 btn-new' do
- = custom_icon('icon_fork')
+ = sprite_icon('fork', size: 12)
%span Fork
- else
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork project", class: 'btn btn-new' do
- = custom_icon('icon_fork')
+ = link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-new' do
+ = sprite_icon('fork', size: 12)
%span Fork
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 5242bc72b71..475c6ba4d3d 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -9,46 +9,21 @@
%br
Forking a repository allows you to make changes without affecting the original project.
.col-lg-9
- .fork-namespaces
- - if @namespaces.present?
- %label.label-light
- %span
- Click to fork the project to a user or group
- - @namespaces.in_groups_of(6, false) do |group|
- .row
- - group.each do |namespace|
- - avatar = namespace_icon(namespace, 100)
- - if fork = namespace.find_fork_of(@project)
- .fork-thumbnail.forked
- = link_to project_path(fork) do
- - if /no_((\w*)_)*avatar/.match(avatar)
- .no-avatar
- = icon 'question'
- - else
- = image_tag avatar
- .caption
- = namespace.human_name
- - else
- .fork-thumbnail
- = link_to namespace_project_forks_path(@project.namespace, @project, namespace_key: namespace.id), method: "POST" do
- - if /no_((\w*)_)*avatar/.match(avatar)
- .no-avatar
- = icon 'question'
- - else
- = image_tag avatar
- .caption
- = namespace.human_name
- - else
- %label.label-light
- %span
- No available namespaces to fork the project.
- %br
- %small
- You must have permission to create a project in a namespace before forking.
+ - if @namespaces.present?
+ .fork-thumbnail-container.js-fork-content
+ %h5.prepend-top-0.append-bottom-0.prepend-left-default.append-right-default
+ Click to fork the project
+ - @namespaces.each do |namespace|
+ = render 'fork_button', namespace: namespace
+ - else
+ %strong
+ No available namespaces to fork the project.
+ %p.prepend-top-default
+ You must have permission to create a project in a namespace before forking.
- .save-project-loader.hide
- .center
- %h2
- %i.fa.fa-spinner.fa-spin
- Forking repository
- %p Please wait a moment, this page will automatically refresh when ready.
+ .save-project-loader.hide.js-fork-content
+ %h2.text-center
+ = icon('spinner spin')
+ Forking repository
+ %p.text-center
+ Please wait a moment, this page will automatically refresh when ready.
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 b23bbadbdb4..620fd1906ba 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
@@ -19,15 +19,15 @@
- if ref
- if generic_commit_status.ref
.icon-container
- = generic_commit_status.tags.any? ? icon('tag') : icon('code-fork')
- = link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref)
+ = generic_commit_status.tags.any? ? icon('tag') : sprite_icon('fork', size: 10)
+ = link_to generic_commit_status.ref, project_commits_path(generic_commit_status.project, generic_commit_status.ref)
- else
.light none
.icon-container.commit-icon
= 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-sha"
+ = link_to generic_commit_status.short_sha, project_commit_path(generic_commit_status.project, generic_commit_status.sha), class: "commit-sha"
- if retried
= icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.')
@@ -53,7 +53,7 @@
- if admin
%td
- if generic_commit_status.project
- = link_to generic_commit_status.project.name_with_namespace, admin_namespace_project_path(generic_commit_status.project.namespace, generic_commit_status.project)
+ = link_to generic_commit_status.project.full_name, admin_project_path(generic_commit_status.project)
%td
- if generic_commit_status.try(:runner)
= runner_link(generic_commit_status.runner)
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 464ac34d961..14c47a5d91c 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -1,13 +1,9 @@
- @no_container = true
- page_title "Charts"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('graphs')
-= render "projects/commits/head"
.repo-charts{ class: container_class }
%h4.sub-header
- Programming languages used in this repository
+ = _("Programming languages used in this repository")
.row
.col-md-4
@@ -30,9 +26,11 @@
.row.tree-ref-header
.col-md-6
%h4
- Commit statistics for
- %strong= @ref
- #{@commits_graph.start_date.strftime('%b %d')} - #{@commits_graph.end_date.strftime('%b %d')}
+ - start_time = capture do
+ #{@commits_graph.start_date.strftime('%b %d')}
+ - end_time = capture do
+ #{@commits_graph.end_date.strftime('%b %d')}
+ = (_("Commit statistics for %{ref} %{start_time} - %{end_time}") % { ref: "<strong>#{@ref}</strong>", start_time: start_time, end_time: end_time }).html_safe
.col-md-6
.tree-ref-container
@@ -45,83 +43,42 @@
.col-md-6
%ul.commit-stats
%li
- Total:
- %strong #{@commits_graph.commits.size} commits
+ - total = capture do
+ #{@commits_graph.commits.size}
+ = (_("Total: %{total}") % { total: "<strong>#{total} commits</strong>" }).html_safe
%li
- Average per day:
- %strong #{@commits_graph.commit_per_day} commits
+ - average = capture do
+ #{@commits_graph.commit_per_day}
+ = (_("Average per day: %{average}") % { average: "<strong>#{average} commits</strong>" }).html_safe
%li
- Authors:
- %strong= @commits_graph.authors
+ - authors = capture do
+ #{@commits_graph.authors}
+ = (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe
.col-md-6
%div
%p.slead
- Commits per day of month
+ = _("Commits per day of month")
%canvas#month-chart
.row
.col-md-6
.col-md-6
%div
%p.slead
- Commits per weekday
+ = _("Commits per weekday")
%canvas#weekday-chart
.row
.col-md-6
.col-md-6
%div
%p.slead
- Commits per day hour (UTC)
+ = _("Commits per day hour (UTC)")
%canvas#hour-chart
-:javascript
- var responsiveChart = function (selector, data) {
- var options = { "scaleOverlay": true, responsive: true, pointHitDetectionRadius: 2, maintainAspectRatio: false };
- // get selector by context
- var ctx = selector.get(0).getContext("2d");
- // pointing parent container to make chart.js inherit its width
- var container = $(selector).parent();
- var generateChart = function() {
- selector.attr('width', $(container).width());
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8
- }
- return new Chart(ctx).Bar(data, options);
- };
- // enabling auto-resizing
- $(window).resize(generateChart);
- return generateChart();
- };
-
- var chartData = function (keys, values) {
- var data = {
- labels : keys,
- datasets : [{
- fillColor : "rgba(220,220,220,0.5)",
- strokeColor : "rgba(220,220,220,1)",
- barStrokeWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
- data : values
- }]
- };
- return data;
- };
-
- var hourData = chartData(#{@commits_per_time.keys.to_json}, #{@commits_per_time.values.to_json});
- responsiveChart($('#hour-chart'), hourData);
-
- var dayData = chartData(#{@commits_per_week_days.keys.to_json}, #{@commits_per_week_days.values.to_json});
- responsiveChart($('#weekday-chart'), dayData);
-
- var monthData = chartData(#{@commits_per_month.keys.to_json}, #{@commits_per_month.values.to_json});
- responsiveChart($('#month-chart'), monthData);
-
- var data = #{@languages.to_json};
- var ctx = $("#languages-chart").get(0).getContext("2d");
- var options = {
- scaleOverlay: true,
- responsive: true,
- maintainAspectRatio: false
- }
- var myPieChart = new Chart(ctx).Pie(data, options);
+-# haml-lint:disable InlineJavaScript
+%script#projectChartData{ type: "application/json" }
+ - projectChartData = {};
+ - projectChartData['hour'] = @commits_per_time
+ - projectChartData['weekDays'] = @commits_per_week_days
+ - projectChartData['month'] = @commits_per_month
+ - projectChartData['languages'] = @languages
+ = projectChartData.to_json.html_safe
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index 680f8ae6c8f..c81ee6874e3 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -1,52 +1,27 @@
- @no_container = true
-- page_title "Contributors"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('graphs')
-= render 'projects/commits/head'
+- page_title _('Contributors')
-%div{ class: container_class }
+.js-graphs-show{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) }
.sub-header-block
- .tree-ref-holder
+ .tree-ref-holder.inline.vertical-align-middle
= render 'shared/ref_switcher', destination: 'graphs'
- %ul.breadcrumb.repo-breadcrumb
- = commits_breadcrumbs
+ = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn'
.loading-graph
.center
%h3.page-title
%i.fa.fa-spinner.fa-spin
- Building repository graph.
- %p.slead Please wait a moment, this page will automatically refresh when ready.
+ = s_('ContributorsPage|Building repository graph.')
+ %p.slead
+ = s_('ContributorsPage|Please wait a moment, this page will automatically refresh when ready.')
.stat-graph.hide
.header.clearfix
%h3#date_header.page-title
%p.light
- Commits to #{@ref}, excluding merge commits. Limited to 6,000 commits.
+ = s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref }
%input#brush_change{ :type => "hidden" }
.graphs.row
#contributors-master
#contributors.clearfix
%ol.contributors-list.clearfix
-
-
-
-:javascript
- $.ajax({
- type: "GET",
- url: "#{namespace_project_graph_path(@project.namespace, @project, current_ref, format: :json)}",
- dataType: "json",
- success: function (data) {
- var graph = new ContributorsStatGraph();
- graph.init(data);
-
- $("#brush_change").change(function(){
- graph.change_date_header();
- graph.redraw_authors();
- });
-
- $(".stat-graph").fadeIn();
- $(".loading-graph").hide();
- }
- });
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index 6962b223451..8096d9530c3 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -24,11 +24,11 @@
%td
= truncate(hook_log.url, length: 50)
%td.light
- #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} sec
%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)
+ = link_to 'View details', project_hook_hook_log_path(project, hook, hook_log)
= paginate hook_logs, theme: 'gitlab'
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index 2eabe92f8eb..1cf4105bd27 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -1,11 +1,9 @@
-= 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"
+ = link_to 'Resend Request', retry_project_hook_hook_log_path(@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 fd382c1d63f..dcc1f0e3fbe 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -1,5 +1,4 @@
- page_title 'Integrations'
-= render 'projects/settings/head'
.row.prepend-top-default
.col-lg-3
@@ -9,14 +8,13 @@
#{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
used for binding events when something is happening within the project.
.col-lg-9.append-bottom-default
- = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f|
+ = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) 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?' }
+ = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: @hook
+ = link_to 'Remove', project_hook_path(@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/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index 25a87411cac..778ff91362d 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -12,7 +12,7 @@
:preserve
#{h(sanitize_repo_path(@project, @project.import_error))}
-= form_for @project, url: namespace_project_import_path(@project.namespace, @project), method: :post, html: { class: 'form-horizontal' } do |f|
+= form_for @project, url: project_import_path(@project), method: :post, html: { class: 'form-horizontal' } do |f|
= render "shared/import_form", f: f
.form-actions
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index c52b3860636..3b0c828ccd1 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -1,14 +1,11 @@
-- page_title @project.forked? ? "Forking in progress" : "Import in progress"
+- page_title import_in_progress_title
+
.save-project-loader
.center
%h2
%i.fa.fa-spinner.fa-spin
- - if @project.forked?
- Forking in progress.
- - else
- Import in progress.
- - if @project.external_import?
+ = import_in_progress_title
+ - if !has_ci_cd_only_params? && @project.external_import?
%p.monospace git clone --bare #{@project.safe_import_url}
- %p Please wait while we import the repository for you. Refresh at will.
- :javascript
- new ProjectImport();
+ %p
+ = import_wait_and_refresh_message
diff --git a/app/views/projects/issues/_by_email_description.html.haml b/app/views/projects/issues/_by_email_description.html.haml
new file mode 100644
index 00000000000..f2d58534903
--- /dev/null
+++ b/app/views/projects/issues/_by_email_description.html.haml
@@ -0,0 +1,6 @@
+The subject will be used as the title of the new issue, and the message will be the description.
+
+= link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
+and styling with
+= link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
+are supported.
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 8b095f4ca10..cdfc3e232c5 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,7 +1,11 @@
+- @gfm_form = true
+
- 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: {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', :autocomplete => true
+%section.js-vue-notes-event
+ #js-vue-notes{ data: { notes_data: notes_data(@issue),
+ noteable_data: serialize_issuable(@issue),
+ current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml
deleted file mode 100644
index 7a188cb6445..00000000000
--- a/app/views/projects/issues/_head.html.haml
+++ /dev/null
@@ -1,33 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: (container_class) }
- - if project_nav_tab?(:issues) && !current_controller?(:merge_requests)
- = nav_link(controller: :issues) do
- = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do
- %span
- List
-
- = nav_link(controller: :boards) do
- = link_to namespace_project_boards_path(@project.namespace, @project), title: 'Board' do
- %span
- Board
-
- - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
- = nav_link(controller: :merge_requests) do
- = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
- %span
- Merge Requests
-
- - if project_nav_tab? :labels
- = nav_link(controller: :labels) do
- = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do
- %span
- Labels
-
- - if project_nav_tab? :milestones
- = nav_link(controller: :milestones) do
- = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do
- %span
- Milestones
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 9e4e6934ca9..0c58dd60e2c 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -4,43 +4,51 @@
.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
- %span.issue-title-text
- = confidential_icon(issue)
- = link_to issue.title, issue_path(issue)
+ .issue-main-info
+ .issue-title.title
+ %span.issue-title-text
+ - if issue.confidential?
+ %span.has-tooltip{ title: _('Confidential') }
+ = confidential_icon(issue)
+ = link_to issue.title, issue_path(issue)
+ - if issue.tasks?
+ %span.task-status.hidden-xs
+ &nbsp;
+ = issue.task_status
+
+ .issuable-info
+ %span.issuable-reference
+ #{issuable_reference(issue)}
+ %span.issuable-authored.hidden-xs
+ &middot;
+ opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')}
+ by #{link_to_member(@project, issue.author, avatar: false)}
+ - if issue.milestone
+ %span.issuable-milestone.hidden-xs
+ &nbsp;
+ = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do
+ = icon('clock-o')
+ = issue.milestone.title
+ - if issue.due_date
+ %span.issuable-due-date.hidden-xs.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') }
+ &nbsp;
+ = icon('calendar')
+ = issue.due_date.to_s(:medium)
+ - if issue.labels.any?
+ &nbsp;
+ - issue.labels.each do |label|
+ = link_to_label(label, subject: issue.project, css_class: 'label-link')
+
+ .issuable-meta
%ul.controls
- if issue.closed?
- %li
+ %li.issuable-status
CLOSED
-
- if issue.assignees.any?
%li
= render 'shared/issuable/assignees', project: @project, issue: issue
= render 'shared/issuable_meta_data', issuable: issue
- .issue-info
- #{issuable_reference(issue)} &middot;
- opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')}
- by #{link_to_member(@project, issue.author, avatar: false)}
- - if issue.milestone
- &nbsp;
- = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do
- = icon('clock-o')
- = issue.milestone.title
- - if issue.due_date
- %span{ class: "#{'cred' if issue.overdue?}" }
- &nbsp;
- = icon('calendar')
- = issue.due_date.to_s(:medium)
- - if issue.labels.any?
- &nbsp;
- - issue.labels.each do |label|
- = link_to_label(label, subject: issue.project, css_class: 'label-link')
- - if issue.tasks?
- &nbsp;
- %span.task-status
- = issue.task_status
-
- .pull-right.issue-updated-at
+ .pull-right.issuable-updated-at.hidden-xs
%span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')}
diff --git a/app/views/projects/issues/_issue_by_email.html.haml b/app/views/projects/issues/_issue_by_email.html.haml
deleted file mode 100644
index 35b7d1b920c..00000000000
--- a/app/views/projects/issues/_issue_by_email.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-.issues-footer.text-center
- %button.issue-email-modal-btn{ type: "button", data: { toggle: "modal", target: "#issue-email-modal" } }
- Email a new issue to this project
-
-#issue-email-modal.modal.fade{ tabindex: "-1", role: "dialog" }
- .modal-dialog{ role: "document" }
- .modal-content
- .modal-header
- %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } }
- %span{ aria: { hidden: "true" } }= icon("times")
- %h4.modal-title
- Create new issue by email
- .modal-body
- %p
- You can create a new issue inside this project by sending an email to the following email address:
- .email-modal-input-group.input-group
- = text_field_tag :issue_email, email, class: "monospace js-select-on-focus form-control", readonly: true
- .input-group-btn
- = clipboard_button(target: '#issue_email')
- %p
- The subject will be used as the title of the new issue, and the message will be the description.
-
- = link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
- and styling with
- = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
- are supported.
-
- %p
- This is a private email address, generated just for you.
-
- Anyone who gets ahold of it can create issues as if they were you.
- You should
- = link_to 'reset it', new_issue_address_namespace_project_path(@project.namespace, @project), class: 'incoming-email-token-reset'
- if that ever happens.
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index 34d5a3e1831..6f7713124ac 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,7 +1,9 @@
+- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
+
%ul.content-list.issues-list.issuable-list
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
- = render 'shared/empty_states/issues'
+ = render empty_state_path
- if @issues.present?
- = paginate @issues, theme: "gitlab"
+ = paginate @issues, theme: "gitlab", total_pages: @total_pages
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
index bda52fe461c..5c36d2202a6 100644
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ b/app/views/projects/issues/_merge_requests.html.haml
@@ -2,13 +2,13 @@
%h2.merge-requests-title
= pluralize(@merge_requests.count, 'Related Merge Request')
%ul.unstyled-list.related-merge-requests
- - has_any_ci = @merge_requests.any?(&:head_pipeline)
+ - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id)
- @merge_requests.each do |merge_request|
%li
%span.merge-request-ci-status
- if merge_request.head_pipeline
= render_pipeline_status(merge_request.head_pipeline)
- - elsif has_any_ci
+ - elsif has_any_head_pipeline
= icon('blank fw')
%span.merge-request-id
= merge_request.to_reference
@@ -18,7 +18,7 @@
- unless @issue.project.id == merge_request.target_project.id
in
- project = merge_request.target_project
- = link_to project.name_with_namespace, namespace_project_path(project.namespace, project)
+ = link_to project.full_name, project_path(project)
- if merge_request.merged?
%span.merge-request-status.prepend-left-10.merged
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
new file mode 100644
index 00000000000..0d39edb7bfd
--- /dev/null
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -0,0 +1,10 @@
+= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
+ = icon('rss')
+- if @can_bulk_update
+ = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
+= link_to "New issue", new_project_issue_path(@project,
+ issue: { assignee_id: finder.assignee.try(:id),
+ milestone_id: finder.milestones.first.try(:id) }),
+ class: "btn btn-new",
+ title: "New issue",
+ id: "new_issue_link"
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index dba092c8844..36e24037214 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -1,29 +1,53 @@
+- can_create_merge_request = can?(current_user, :create_merge_request, @project)
+- data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
+- value = can_create_merge_request ? 'Create merge request' : 'Create branch'
+
- if can?(current_user, :push_code, @project)
- .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue), create_mr_path: create_merge_request_namespace_project_issue_path(@project.namespace, @project, @issue), create_branch_path: namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } }
+ - can_create_path = can_create_branch_project_issue_path(@project, @issue)
+ - create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch)
+ - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
+ - refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
+
+ .create-mr-dropdown-wrap{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
.btn-group.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin')
%span.text
Checking branch availability…
.btn-group.available.hide
- %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } }
- %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } }
+ %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } }
+ = value
+
+ %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
= icon('caret-down')
- %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
- %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } }
- .menu-item
- .icon-container
- = icon('check')
- .description
- %strong Create a merge request
- %span
- Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'.
- %li.divider.droplab-item-ignore
- %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } }
- .menu-item
- .icon-container
- = icon('check')
- .description
- %strong Create a branch
- %span
- Creates a branch named after this issue, from '#{@project.default_branch}'.
+
+ .droplab-dropdown
+ %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
+ - if can_create_merge_request
+ %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: _('Create merge request') } }
+ .menu-item
+ = icon('check', class: 'icon')
+ = _('Create merge request and branch')
+
+ %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
+ .menu-item
+ = icon('check', class: 'icon')
+ = _('Create branch')
+ %li.divider.droplab-item-ignore
+
+ %li.droplab-item-ignore.prepend-left-8.append-right-8.prepend-top-16
+ .form-group
+ %label{ for: 'new-branch-name' }
+ = _('Branch name')
+ %input#new-branch-name.js-branch-name.form-control{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
+ %span.js-branch-message.help-block
+
+ .form-group
+ %label{ for: 'source-name' }
+ = _('Source (branch or tag)')
+ %input#source-name.js-ref.ref.form-control{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
+ %span.js-ref-message.help-block
+
+ .form-group
+ %button.btn.btn-success.js-create-target{ type: 'button', data: { action: 'create-mr' } }
+ = _('Create merge request')
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 8c9f6f3b4df..1df38db9fd4 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -11,4 +11,4 @@
= render_pipeline_status(pipeline)
%span.related-branch-info
%strong
- = link_to branch, namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "ref-name"
+ = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name"
diff --git a/app/views/projects/issues/index.atom.builder b/app/views/projects/issues/index.atom.builder
index 61346884346..4029926f373 100644
--- a/app/views/projects/issues/index.atom.builder
+++ b/app/views/projects/issues/index.atom.builder
@@ -1,7 +1,7 @@
xml.title "#{@project.name} issues"
xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
-xml.link href: namespace_project_issues_url(@project.namespace, @project), rel: "alternate", type: "text/html"
-xml.id namespace_project_issues_url(@project.namespace, @project)
+xml.link href: project_issues_url(@project), rel: "alternate", type: "text/html"
+xml.id project_issues_url(@project)
xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 7183794ce72..c427a9eedc2 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -2,13 +2,7 @@
- @can_bulk_update = can?(current_user, :admin_issue, @project)
- page_title "Issues"
-- new_issue_email = @project.new_issue_address(current_user)
-= content_for :sub_nav do
- = render "projects/issues/head"
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
+- new_issue_email = @project.new_issuable_address(current_user, 'issue')
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues")
@@ -18,18 +12,7 @@
.top-area
= render 'shared/issuable/nav', type: :issues
.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),
- milestone_id: issues_finder.milestones.first.try(:id) }),
- class: "btn btn-new",
- title: "New issue",
- id: "new_issue_link" do
- New issue
+ = render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update
@@ -38,6 +21,6 @@
.issues-holder
= render 'issues'
- if new_issue_email
- = render 'issue_by_email', email: new_issue_email
+ = render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue'
- else
- = render 'shared/empty_states/issues', button_path: new_namespace_project_issue_path(@project.namespace, @project)
+ = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project)
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index e8aae0f47e2..60fe442014f 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Issues"
- page_title "New Issue"
%h3.page-title
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index d909b0bfbbd..ec7e87219f5 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -1,61 +1,62 @@
- @content_class = "limit-container-width" unless fluid_layout
+- add_to_breadcrumbs "Issues", project_issues_path(@project)
+- breadcrumb_title @issue.to_reference
- 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)
-.clearfix.detail-page-header
- .issuable-header
- .issuable-status-box.status-box.status-box-closed{ class: issue_button_visibility(@issue, false) }
- = icon('check', class: "hidden-sm hidden-md hidden-lg")
+.detail-page-header
+ .detail-page-header-body
+ .issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) }
+ = sprite_icon('mobile-issue-close', size: 16, css_class: 'hidden-sm hidden-md hidden-lg')
%span.hidden-xs
Closed
.issuable-status-box.status-box.status-box-open{ class: issue_button_visibility(@issue, true) }
- = icon('circle-o', class: "hidden-sm hidden-md hidden-lg")
+ = sprite_icon('issue-open-m', size: 16, css_class: 'hidden-sm hidden-md hidden-lg')
%span.hidden-xs Open
- %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
-
.issuable-meta
- = confidential_icon(@issue)
+ - if @issue.confidential
+ .issuable-warning-icon.inline= sprite_icon('eye-slash', size: 16, css_class: 'icon')
+ - if @issue.discussion_locked?
+ .issuable-warning-icon.inline= sprite_icon('lock', size: 16, css_class: 'icon')
= issuable_meta(@issue, @project, "Issue")
- .issuable-actions
+ %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
+ = icon('angle-double-left')
+
+ .detail-page-header-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul
+ - unless current_user == @issue.author
+ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
- if can_update_issue
- %li
- = 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'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- %li
- = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{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'
+ %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@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'
+ %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
+
+ = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue
- - 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
+ = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
+ = link_to new_project_issue_path(@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
.issue-details.issuable-details
.detail-page-description.content-block
- %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue)
+ -# haml-lint:disable InlineJavaScript
+ %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json
#js-issuable-app
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
@@ -65,15 +66,15 @@
= 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) } }
+ #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } }
// This element is filled in using JavaScript.
- #related-branches{ data: { url: related_branches_namespace_project_issue_url(@project.namespace, @project, @issue) } }
+ #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
// This element is filled in using JavaScript.
.content-block.emoji-block
.row
- .col-sm-8
+ .col-sm-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential?
@@ -82,6 +83,3 @@
= render 'projects/issues/discussion'
= render 'shared/issuable/sidebar', issuable: @issue
-
-= page_specific_javascript_bundle_tag('common_vue')
-= page_specific_javascript_bundle_tag('issue_show')
diff --git a/app/views/projects/jobs/_empty_state.html.haml b/app/views/projects/jobs/_empty_state.html.haml
new file mode 100644
index 00000000000..c66313bdbf3
--- /dev/null
+++ b/app/views/projects/jobs/_empty_state.html.haml
@@ -0,0 +1,17 @@
+- illustration = local_assigns.fetch(:illustration)
+- illustration_size = local_assigns.fetch(:illustration_size)
+- title = local_assigns.fetch(:title)
+- content = local_assigns.fetch(:content)
+- action = local_assigns.fetch(:action, nil)
+
+.row.empty-state
+ .col-xs-12
+ .svg-content{ class: illustration_size }
+ = image_tag illustration
+ .col-xs-12
+ .text-content
+ %h4.text-center= title
+ %p= content
+ - if action
+ .text-center
+ = action
diff --git a/app/views/projects/jobs/_header.html.haml b/app/views/projects/jobs/_header.html.haml
index ad72ab5b199..83a2af1dc74 100644
--- a/app/views/projects/jobs/_header.html.haml
+++ b/app/views/projects/jobs/_header.html.haml
@@ -1,18 +1,18 @@
- show_controls = local_assigns.fetch(:show_controls, true)
- pipeline = @build.pipeline
-.content-block.build-header.top-area
+.content-block.build-header.top-area.page-content-header
.header-content
= render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
%strong
Job
- = link_to "##{@build.id}", namespace_project_job_path(@project.namespace, @project, @build), class: 'js-build-id'
+ = link_to "##{@build.id}", project_job_path(@project, @build), class: 'js-build-id'
in pipeline
%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'
+ = link_to pipeline.short_sha, project_commit_path(@project, pipeline.sha), class: 'commit-sha'
from
%strong
= link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name'
@@ -24,8 +24,8 @@
- if show_controls
.nav-controls
- if can?(current_user, :create_issue, @project) && @build.failed?
- = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted'
+ = link_to "New issue", new_project_issue_path(@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_job_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post
+ = link_to "Retry job", retry_project_job_path(@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/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index 93e8a4e385c..e779473c239 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -1,101 +1,105 @@
- builds = @build.pipeline.builds.to_a
%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
- .blocks-container
- .block
- %strong
- = @build.name
- %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
- = icon('angle-double-right')
+ .sidebar-container
+ .blocks-container
+ .block
+ %strong.inline.prepend-top-8
+ = @build.name
+ - if can?(current_user, :update_build, @build) && @build.retryable?
+ = link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post
+ %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
+ = icon('angle-double-right')
- #js-details-block-vue
+ #js-details-block-vue
- - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
- .block{ class: ("block-first" if !@build.coverage) }
- .title
- Job artifacts
- - if @build.artifacts_expired?
- %p.build-detail-row
- The artifacts were removed
- #{time_ago_with_tooltip(@build.artifacts_expire_at)}
- - elsif @build.has_expiring_artifacts?
- %p.build-detail-row
- The artifacts will be removed in
- %span.js-artifacts-remove= @build.artifacts_expire_at
+ - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
+ .block
+ .title
+ Job artifacts
+ - if @build.artifacts_expired?
+ %p.build-detail-row
+ The artifacts were removed
+ #{time_ago_with_tooltip(@build.artifacts_expire_at)}
+ - elsif @build.has_expiring_artifacts?
+ %p.build-detail-row
+ The artifacts will be removed in
+ %span= time_ago_in_words @build.artifacts_expire_at
- - 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_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
- Keep
+ - if @build.artifacts?
+ .btn-group.btn-group-justified{ role: :group }
+ - if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
+ = link_to keep_project_job_artifacts_path(@project, @build), class: 'btn btn-sm btn-default', method: :post do
+ Keep
- = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
- Download
+ = link_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
+ Download
- - if @build.artifacts_metadata?
- = link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
- Browse
+ - if @build.artifacts_metadata?
+ = link_to browse_project_job_artifacts_path(@project, @build), class: 'btn btn-sm btn-default' do
+ Browse
- - if @build.trigger_request
- .build-widget
- %h4.title
- Trigger
+ - if @build.trigger_request
+ .build-widget.block
+ %h4.title
+ Trigger
- %p
- %span.build-light-text Token:
- #{@build.trigger_request.trigger.short_token}
-
- - if @build.trigger_request.variables
- %p
- %button.btn.group.btn-group-justified.reveal-variables Reveal Variables
+ - if @build.trigger_request&.trigger&.short_token
+ %p
+ %span.build-light-text Token:
+ #{@build.trigger_request.trigger.short_token}
+ - if @build.trigger_variables.any?
+ %p
+ %button.btn.group.btn-group-justified.js-reveal-variables Reveal Variables
- - @build.trigger_request.variables.each do |key, value|
- .hide.js-build
- .js-build-variable.trigger-build-variable= key
- .js-build-value.trigger-build-value= value
+ %dl.js-build-variables.trigger-build-variables.hide
+ - @build.trigger_variables.each do |trigger_variable|
+ %dt.js-build-variable.trigger-build-variable= trigger_variable[:key]
+ %dd.js-build-value.trigger-build-value= trigger_variable[:value]
- .block
- %p
- Commit
- = link_to @build.pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha), class: 'commit-sha link-commit'
- = clipboard_button(text: @build.pipeline.short_sha, title: "Copy commit SHA to clipboard")
- - if @build.merge_request
- in
- = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'link-commit'
+ %div{ class: (@build.pipeline.stages_count > 1 ? "block" : "block-last") }
+ %p
+ Commit
+ = link_to @build.pipeline.short_sha, project_commit_path(@project, @build.pipeline.sha), class: 'commit-sha link-commit'
+ = clipboard_button(text: @build.pipeline.short_sha, title: "Copy commit SHA to clipboard")
+ - if @build.merge_request
+ in
+ = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'link-commit'
- %p.build-light-text.append-bottom-0
- #{@build.pipeline.git_commit_title}
+ %p.build-light-text.append-bottom-0
+ #{@build.pipeline.git_commit_title}
- - if @build.pipeline.stages_count > 1
- .dropdown.build-dropdown
- .title
- %span{ class: "ci-status-icon-#{@build.pipeline.status}" }
- = ci_icon_for_status(@build.pipeline.status)
- Pipeline
- = link_to "##{@build.pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @build.pipeline), class: 'link-commit'
- from
- = link_to "#{@build.pipeline.ref}", namespace_project_branch_path(@project.namespace, @project, @build.pipeline.ref), class: 'link-commit'
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.stage-selection More
- = icon('chevron-down')
- %ul.dropdown-menu
- - @build.pipeline.legacy_stages.each do |stage|
- %li
- %a.stage-item= stage.name
+ - if @build.pipeline.stages_count > 1
+ .block-last.dropdown.build-dropdown
+ %div
+ %span{ class: "ci-status-icon-#{@build.pipeline.status}" }
+ = ci_icon_for_status(@build.pipeline.status)
+ Pipeline
+ = link_to "##{@build.pipeline.id}", project_pipeline_path(@project, @build.pipeline), class: 'link-commit'
+ from
+ = link_to "#{@build.pipeline.ref}", project_ref_path(@project, @build.pipeline.ref), class: 'link-commit ref-name'
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %span.stage-selection More
+ = icon('chevron-down')
+ %ul.dropdown-menu
+ - @build.pipeline.legacy_stages.each do |stage|
+ %li
+ %a.stage-item= stage.name
- .builds-container
- - 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_job_path(@project.namespace, @project, build) do
- = icon('arrow-right')
- %span{ class: "ci-status-icon-#{build.status}" }
- = ci_icon_for_status(build.status)
- %span
- - if build.name
- = build.name
- - else
- = build.id
- - if build.retried?
- %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
+ .builds-container
+ - 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 project_job_path(@project, build) do
+ = sprite_icon('arrow-right', size:16, css_class: 'icon-arrow-right')
+ %span{ class: "ci-status-icon-#{build.status}" }
+ = ci_icon_for_status(build.status)
+ %span
+ - if build.name
+ = build.name
+ - else
+ = build.id
+ - if build.retried?
+ %span.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
+ = sprite_icon('retry', size:16, css_class: 'icon-retry')
diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index 82806f022ee..d124d3ebfc1 100644
--- a/app/views/projects/jobs/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
@@ -22,4 +22,4 @@
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
- = paginate builds, theme: 'gitlab'
+ = paginate_collection(builds)
diff --git a/app/views/projects/jobs/_user.html.haml b/app/views/projects/jobs/_user.html.haml
index 83f299da651..461d503f95d 100644
--- a/app/views/projects/jobs/_user.html.haml
+++ b/app/views/projects/jobs/_user.html.haml
@@ -1,7 +1,7 @@
by
%a{ href: user_path(@build.user) }
%span.hidden-xs
- = image_tag avatar_icon(@build.user, 24), class: "avatar s24"
+ = image_tag avatar_icon_for_user(@build.user, 24), class: "avatar s24"
%strong{ data: { toggle: 'tooltip', placement: 'top', title: @build.user.to_reference } }
= @build.user.name
%strong.visible-xs-inline= @build.user.to_reference
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index a33e3978ee1..9963cc93633 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Jobs"
-= render "projects/pipelines/head"
%div{ class: container_class }
.top-area
@@ -9,8 +8,8 @@
.nav-controls
- if can?(current_user, :update_build, @project)
- - if @all_builds.running_or_pending.any?
- = link_to 'Cancel running', cancel_all_namespace_project_jobs_path(@project.namespace, @project),
+ - if @all_builds.running_or_pending.limit(1).any?
+ = link_to 'Cancel running', cancel_all_project_jobs_path(@project),
data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
- unless @repository.gitlab_ci_yml
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index c73bae0a2c9..849c273db8c 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -1,6 +1,7 @@
- @no_container = true
+- add_to_breadcrumbs "Jobs", project_jobs_path(@project)
+- breadcrumb_title "##{@build.id}"
- page_title "#{@build.name} (##{@build.id})", "Jobs"
-= render "projects/pipelines/head"
%div{ class: container_class }
.build-page.js-build-page
@@ -21,7 +22,7 @@
%br
Go to
- = link_to namespace_project_runners_path(@build.project.namespace, @build.project) do
+ = link_to project_runners_path(@build.project) do
Runners page
- if @build.starts_environment?
@@ -53,45 +54,61 @@
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)}
+ - if @build.started?
+ .build-trace-container.prepend-top-default
+ .top-bar.js-top-bar
+ .js-truncated-info.truncated-info.hidden-xs.pull-left.hidden<
+ Showing last
+ %span.js-truncated-info-size.truncated-info-size><
+ of log -
+ %a.js-raw-link.raw-link{ href: raw_project_job_path(@project, @build) }>< Complete Raw
- .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_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: 'Show complete raw',
- data: { placement: 'top', container: 'body' },
- class: 'js-raw-link-controller has-tooltip controllers-buttons' do
- = icon('file-text-o')
+ .controllers.pull-right
+ - if @build.has_trace?
+ = link_to raw_project_job_path(@project, @build),
+ title: 'Show complete raw',
+ data: { placement: 'top', container: 'body' },
+ class: 'js-raw-link-controller has-tooltip controllers-buttons' do
+ = icon('file-text-o')
- - 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 job log',
- class: 'has-tooltip js-erase-link controllers-buttons' do
- = icon('trash')
- .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
- %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
- = custom_icon('scroll_up')
- .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
- %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
- = custom_icon('scroll_down')
- .bash.sticky.js-scroll-container
- %code.js-build-output
- .build-loader-animation.js-build-refresh
+ - if @build.erasable? && can?(current_user, :erase_build, @build)
+ = link_to erase_project_job_path(@project, @build),
+ method: :post,
+ data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
+ title: 'Erase job log',
+ class: 'has-tooltip js-erase-link controllers-buttons' do
+ = icon('trash')
+ .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
+ %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
+ = custom_icon('scroll_up')
+ .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
+ %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
+ = custom_icon('scroll_down')
+ %pre.build-trace#build-trace
+ %code.bash.js-build-output
+ .build-loader-animation.js-build-refresh
+ - elsif @build.playable?
+ = render 'empty_state',
+ illustration: 'illustrations/manual_action.svg',
+ illustration_size: 'svg-394',
+ title: _('This job requires a manual action'),
+ content: _('This job depends on a user to trigger its process. Often they are used to deploy code to production environments'),
+ action: ( link_to _('Trigger this manual action'), play_project_job_path(@project, @build), method: :post, class: 'btn btn-primary', title: _('Trigger this manual action') )
+ - elsif @build.created?
+ = render 'empty_state',
+ illustration: 'illustrations/job_not_triggered.svg',
+ illustration_size: 'svg-306',
+ title: _('This job has not been triggered yet'),
+ content: _('This job depends on upstream jobs that need to succeed in order for this job to be triggered')
+ - else
+ = render 'empty_state',
+ illustration: 'illustrations/pending_job_empty.svg',
+ illustration_size: 'svg-430',
+ title: _('This job has not started yet'),
+ content: _('This job is in pending state and is waiting to be picked by a runner')
= render "sidebar"
.js-build-options{ data: javascript_build_options }
-#js-job-details-vue{ data: { endpoint: namespace_project_job_path(@project.namespace, @project, @build, format: :json) } }
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_vue')
- = webpack_bundle_tag('job_details')
+#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json) } }
diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml
index 7f0059cdcda..b8ee4305142 100644
--- a/app/views/projects/labels/edit.html.haml
+++ b/app/views/projects/labels/edit.html.haml
@@ -1,9 +1,8 @@
- @no_container = true
- page_title "Edit", @label.name, "Labels"
-= render "shared/mr_head"
%div{ class: container_class }
%h3.page-title
Edit Label
%hr
- = render 'shared/labels/form', url: namespace_project_label_path(@project.namespace.becomes(Namespace), @project, @label), back_path: namespace_project_labels_path(@project.namespace, @project)
+ = render 'shared/labels/form', url: project_label_path(@project, @label), back_path: project_labels_path(@project)
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index fc72c4fb635..9c78bade254 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -1,26 +1,29 @@
- @no_container = true
- page_title "Labels"
- hide_class = ''
-= render "shared/mr_head"
+- can_admin_label = can?(current_user, :admin_label, @project)
- if @labels.exists? || @prioritized_labels.exists?
+ #promote-label-modal
%div{ class: container_class }
.top-area.adjust
.nav-text
- Labels can be applied to issues and merge requests. Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.
+ Labels can be applied to issues and merge requests.
+ - if can_admin_label
+ Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.
- .nav-controls
- - if can?(current_user, :admin_label, @project)
- = link_to new_namespace_project_label_path(@project.namespace, @project), class: "btn btn-new" do
+ - if can_admin_label
+ .nav-controls
+ = link_to new_project_label_path(@project), class: "btn btn-new" do
New label
.labels
- - if can?(current_user, :admin_label, @project)
+ - if can_admin_label
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
.prioritized-labels{ class: ('hide' if hide) }
%h5 Prioritized Labels
- %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_namespace_project_labels_path(@project.namespace, @project) }
+ %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) }
#js-priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty?}" }
= render 'shared/empty_states/priority_labels'
- if @prioritized_labels.present?
@@ -28,7 +31,7 @@
- if @labels.present?
.other-labels
- - if can?(current_user, :admin_label, @project)
+ - if can_admin_label
%h5{ class: ('hide' if hide) } Other Labels
%ul.content-list.manage-labels-list.js-other-labels
= render partial: 'shared/label', subject: @project, collection: @labels, as: :label
diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml
index 8f6c085a361..02f59f30a39 100644
--- a/app/views/projects/labels/new.html.haml
+++ b/app/views/projects/labels/new.html.haml
@@ -1,9 +1,9 @@
- @no_container = true
+- breadcrumb_title "Labels"
- page_title "New Label"
-= render "shared/mr_head"
%div{ class: container_class }
%h3.page-title
New Label
%hr
- = render 'shared/labels/form', url: namespace_project_labels_path(@project.namespace.becomes(Namespace), @project), back_path: namespace_project_labels_path(@project.namespace, @project)
+ = render 'shared/labels/form', url: project_labels_path(@project), back_path: project_labels_path(@project)
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index aac74a25b75..243dcfdc187 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -13,4 +13,4 @@
and try again.
%hr
.clearfix
- = link_to 'Go back', edit_namespace_project_service_path(@project.namespace, @project, @service), class: 'btn btn-lg pull-right'
+ = link_to 'Go back', edit_project_service_path(@project, @service), class: 'btn btn-lg pull-right'
diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml
index 04bd4e8b683..20acd476f73 100644
--- a/app/views/projects/mattermosts/_team_selection.html.haml
+++ b/app/views/projects/mattermosts/_team_selection.html.haml
@@ -2,7 +2,7 @@
This service will be installed on the Mattermost instance at
%strong= link_to Gitlab.config.mattermost.host, Gitlab.config.mattermost.host
%hr
-= form_for(:mattermost, method: :post, url: namespace_project_mattermost_path(@project.namespace, @project), html: { class: 'js-requires-input'} ) do |f|
+= form_for(:mattermost, method: :post, url: project_mattermost_path(@project), html: { class: 'js-requires-input'} ) do |f|
%h4 Team
%p
= @teams.one? ? 'The team' : 'Select the team'
@@ -33,7 +33,7 @@
Suggestions:
%code= 'gitlab'
%code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ %code= @project.full_path
%p
Reserved:
= link_to 'https://docs.mattermost.com/help/messaging/executing-commands.html#built-in-commands', target: '__blank' do
@@ -42,5 +42,5 @@
%hr
.clearfix
.pull-right
- = link_to 'Cancel', edit_namespace_project_service_path(@project.namespace, @project, @service), class: 'btn btn-lg'
+ = link_to 'Cancel', edit_project_service_path(@project, @service), class: 'btn btn-lg'
= f.submit 'Install', class: 'btn btn-save btn-lg'
diff --git a/app/views/projects/merge_requests/_by_email_description.html.haml b/app/views/projects/merge_requests/_by_email_description.html.haml
new file mode 100644
index 00000000000..8ba251749b8
--- /dev/null
+++ b/app/views/projects/merge_requests/_by_email_description.html.haml
@@ -0,0 +1 @@
+The subject will be used as the source branch name for the new merge request and the target branch will be the default branch for the project.
diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml
new file mode 100644
index 00000000000..b414518b597
--- /dev/null
+++ b/app/views/projects/merge_requests/_commits.html.haml
@@ -0,0 +1,8 @@
+- if @commits.empty?
+ .commits-empty
+ %h4
+ There are no commits yet.
+ = custom_icon ('illustration_no_commits')
+- else
+ %ol#commits-list.list-unstyled
+ = render "projects/commits/commits", merge_request: @merge_request
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index b787edb3427..3303aa72604 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -4,8 +4,8 @@
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- if @merge_request.reopenable?
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
- %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
- %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 }}
+ %comment-and-resolve-btn{ "inline-template" => true }
+ %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", :autocomplete => true
diff --git a/app/views/projects/merge_requests/_head.html.haml b/app/views/projects/merge_requests/_head.html.haml
deleted file mode 100644
index b7f73fe5339..00000000000
--- a/app/views/projects/merge_requests/_head.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: (container_class) }
- = nav_link(controller: :merge_requests) do
- = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
- %span
- List
-
- - if project_nav_tab? :labels
- = nav_link(controller: :labels) do
- = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do
- %span
- Labels
-
- - if project_nav_tab? :milestones
- = nav_link(controller: :milestones) do
- = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do
- %span
- Milestones
diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml
new file mode 100644
index 00000000000..54a661040ea
--- /dev/null
+++ b/app/views/projects/merge_requests/_how_to_merge.html.haml
@@ -0,0 +1,52 @@
+#modal_merge_info.modal
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %a.close{ href: "#", "data-dismiss" => "modal" } ×
+ %h3 Check out, review, and merge locally
+ .modal-body
+ %p
+ %strong Step 1.
+ Fetch and check out the branch for this merge request
+ = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
+ %pre.dark#merge-info-1
+ - if @merge_request.for_fork?
+ :preserve
+ git fetch #{h default_url_to_repo(@merge_request.source_project)} #{h @merge_request.source_branch}
+ git checkout -b #{h @merge_request.source_project_path}-#{h @merge_request.source_branch} FETCH_HEAD
+ - else
+ :preserve
+ git fetch origin
+ git checkout -b #{h @merge_request.source_branch} origin/#{h @merge_request.source_branch}
+ %p
+ %strong Step 2.
+ Review the changes locally
+
+ %p
+ %strong Step 3.
+ Merge the branch and fix any conflicts that come up
+ = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
+ %pre.dark#merge-info-3
+ - if @merge_request.for_fork?
+ :preserve
+ git checkout #{h @merge_request.target_branch}
+ git merge --no-ff #{h @merge_request.source_project_path}-#{h @merge_request.source_branch}
+ - else
+ :preserve
+ git checkout #{h @merge_request.target_branch}
+ git merge --no-ff #{h @merge_request.source_branch}
+ %p
+ %strong Step 4.
+ Push the result of the merge to GitLab
+ = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
+ %pre.dark#merge-info-4
+ :preserve
+ git push origin #{h @merge_request.target_branch}
+ - unless @merge_request.can_be_merged_by?(current_user)
+ %p
+ Note that pushing to GitLab requires write access to this repository.
+ %p
+ %strong Tip:
+ = succeed '.' do
+ You can also checkout merge requests locally by
+ = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index c13110deb16..a94267deeb2 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -4,58 +4,60 @@
= check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue"
.issue-info-container
- .merge-request-title.title
- %span.merge-request-title-text
- = link_to merge_request.title, merge_request_path(merge_request)
+ .issue-main-info
+ .merge-request-title.title
+ %span.merge-request-title-text
+ = link_to merge_request.title, merge_request_path(merge_request)
+ - if merge_request.tasks?
+ %span.task-status.hidden-xs
+ &nbsp;
+ = merge_request.task_status
+
+ .issuable-info
+ %span.issuable-reference
+ #{issuable_reference(merge_request)}
+ %span.issuable-authored.hidden-xs
+ &middot;
+ opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
+ by #{link_to_member(@project, merge_request.author, avatar: false)}
+ - if merge_request.milestone
+ %span.issuable-milestone.hidden-xs
+ &nbsp;
+ = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(merge_request) } do
+ = icon('clock-o')
+ = merge_request.milestone.title
+ - if merge_request.target_project.default_branch != merge_request.target_branch
+ %span.project-ref-path.has-tooltip{ title: _('Target branch') }
+ &nbsp;
+ = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
+ = sprite_icon('fork', size: 12, css_class: 'fork-sprite')
+ = merge_request.target_branch
+ - if merge_request.labels.any?
+ &nbsp;
+ - merge_request.labels.each do |label|
+ = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link')
+
+ .issuable-meta
%ul.controls
- if merge_request.merged?
- %li
+ %li.issuable-status.hidden-xs
MERGED
- elsif merge_request.closed?
- %li
+ %li.issuable-status.hidden-xs
= icon('ban')
CLOSED
-
- if merge_request.head_pipeline
- %li
+ %li.issuable-pipeline-status.hidden-xs
= render_pipeline_status(merge_request.head_pipeline)
-
- if merge_request.open? && merge_request.broken?
- %li
- = link_to merge_request_path(merge_request), class: "has-tooltip", title: "Cannot be merged automatically", data: { container: 'body' } do
+ %li.issuable-pipeline-broken.hidden-xs
+ = link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do
= icon('exclamation-triangle')
-
- if merge_request.assignee
%li
- = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: "Assigned to :name")
+ = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name'))
= render 'shared/issuable_meta_data', issuable: merge_request
- .merge-request-info
- #{issuable_reference(merge_request)} &middot;
- opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
- by #{link_to_member(@project, merge_request.author, avatar: false)}
- - if merge_request.target_project.default_branch != merge_request.target_branch
- &nbsp;
- = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
- = icon('code-fork')
- = merge_request.target_branch
-
- - if merge_request.milestone
- &nbsp;
- = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do
- = icon('clock-o')
- = merge_request.milestone.title
-
- - if merge_request.labels.any?
- &nbsp;
- - merge_request.labels.each do |label|
- = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link')
-
- - if merge_request.tasks?
- &nbsp;
- %span.task-status
- = merge_request.task_status
-
- .pull-right.hidden-xs
+ .pull-right.issuable-updated-at.hidden-xs
%span updated #{time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago')}
diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml
index 4e97f74dd6a..bd6f1c05949 100644
--- a/app/views/projects/merge_requests/_merge_requests.html.haml
+++ b/app/views/projects/merge_requests/_merge_requests.html.haml
@@ -5,4 +5,4 @@
= render 'shared/empty_states/merge_requests'
- if @merge_requests.present?
- = paginate @merge_requests, theme: "gitlab"
+ = paginate @merge_requests, theme: "gitlab", total_pages: @total_pages
diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 8a390cf8700..8a390cf8700 100644
--- a/app/views/projects/merge_requests/show/_mr_box.html.haml
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
new file mode 100644
index 00000000000..22c8b6b513d
--- /dev/null
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -0,0 +1,42 @@
+- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
+
+- if @merge_request.closed_without_fork?
+ .alert.alert-danger
+ %p The source project of this merge request has been removed.
+
+.detail-page-header
+ .detail-page-header-body
+ .issuable-status-box.status-box{ class: status_box_class(@merge_request) }
+ = sprite_icon(@merge_request.state_icon_name, size: 16, css_class: 'hidden-sm hidden-md hidden-lg')
+ %span.hidden-xs
+ = @merge_request.state_human_name
+
+ .issuable-meta
+ - if @merge_request.discussion_locked?
+ .issuable-warning-icon.inline= sprite_icon('lock', size: 16, css_class: 'icon')
+ = issuable_meta(@merge_request, @project, "Merge request")
+
+ %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
+ = icon('angle-double-left')
+
+ .detail-page-header-actions.js-issuable-actions
+ .clearfix.issue-btn-group.dropdown
+ %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
+ Options
+ = icon('caret-down')
+ .dropdown-menu.dropdown-menu-align-right.hidden-lg
+ %ul
+ - if can_update_merge_request
+ %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ - unless current_user == @merge_request.author
+ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
+ - if can_update_merge_request
+ %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
+ = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
+ %li{ class: merge_request_button_visibility(@merge_request, false) }
+ = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
+
+ - if can_update_merge_request
+ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped js-issuable-edit"
+
+ = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
new file mode 100644
index 00000000000..e73dab8ad4a
--- /dev/null
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -0,0 +1,5 @@
+- if @can_bulk_update
+ = button_tag "Edit merge requests", class: "btn append-right-10 js-bulk-update-toggle"
+- if merge_project
+ = link_to new_merge_request_path, class: "btn btn-new", title: "New merge request" do
+ New merge request
diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml
deleted file mode 100644
index 0f37abb579c..00000000000
--- a/app/views/projects/merge_requests/_new_compare.html.haml
+++ /dev/null
@@ -1,75 +0,0 @@
-%h3.page-title
- New Merge Request
-
-= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: new_namespace_project_merge_request_path(@project.namespace, @project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f|
- .hide.alert.alert-danger.mr-compare-errors
- .merge-request-branches.row
- .col-md-6
- .panel.panel-default.panel-new-merge-request
- .panel-heading
- Source branch
- .panel-body.clearfix
- .merge-request-select.dropdown
- = f.hidden_field :source_project_id
- = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", field_name: "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-source-project
- = dropdown_title("Select source project")
- = dropdown_filter("Search projects")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/project',
- projects: [@merge_request.source_project],
- 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 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
- = render 'projects/merge_requests/dropdowns/branch',
- branches: @merge_request.source_branches,
- selected: f.object.source_branch
- .panel-footer
- .text-center= icon('spinner spin', class: 'js-source-loading')
- %ul.list-unstyled.mr_source_commit
-
- .col-md-6
- .panel.panel-default.panel-new-merge-request
- .panel-heading
- Target branch
- .panel-body.clearfix
- - projects = target_projects(@project)
- .merge-request-select.dropdown
- = f.hidden_field :target_project_id
- = dropdown_toggle f.object.target_project.path_with_namespace, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-target-project
- = dropdown_title("Select target project")
- = dropdown_filter("Search projects")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/project',
- projects: projects,
- 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 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
- = render 'projects/merge_requests/dropdowns/branch',
- branches: @merge_request.target_branches,
- selected: f.object.target_branch
- .panel-footer
- .text-center= icon('spinner spin', class: "js-target-loading")
- %ul.list-unstyled.mr_target_commit
-
- - if @merge_request.errors.any?
- = form_errors(@merge_request)
- = f.submit 'Compare branches and continue', class: "btn btn-new mr-compare-btn"
-
-:javascript
- new Compare({
- targetProjectUrl: "#{update_branches_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}",
- sourceBranchUrl: "#{branch_from_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}",
- targetBranchUrl: "#{branch_to_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}"
- });
diff --git a/app/views/projects/merge_requests/_new_diffs.html.haml b/app/views/projects/merge_requests/_new_diffs.html.haml
deleted file mode 100644
index 627fc4e9671..00000000000
--- a/app/views/projects/merge_requests/_new_diffs.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render "projects/diffs/diffs", diffs: @diffs, environment: @environment, show_whitespace_toggle: false
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
deleted file mode 100644
index e3ecbee5490..00000000000
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ /dev/null
@@ -1,58 +0,0 @@
-%h3.page-title
- New Merge Request
-%p.slead
- - source_title, target_title = format_mr_branch_names(@merge_request)
- From
- %strong.ref-name= source_title
- %span into
- %strong.ref-name= target_title
-
- %span.pull-right
- = link_to 'Change branches', mr_change_branches_path(@merge_request)
-%hr
-= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal common-note-form js-requires-input js-quick-submit' } do |f|
- = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits
- = f.hidden_field :source_project_id
- = f.hidden_field :source_branch
- = f.hidden_field :target_project_id
- = f.hidden_field :target_branch
-
-.mr-compare.merge-request
- - if @commits.empty?
- .commits-empty
- %h4
- There are no commits yet.
- = custom_icon ('illustration_no_commits')
- - else
- %ul.merge-request-tabs.nav-links.no-top.no-bottom
- %li.commits-tab.active
- = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
- Commits
- %span.badge= @commits.size
- - if @pipelines.any?
- %li.builds-tab
- = link_to url_for(params), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do
- Pipelines
- %span.badge= @pipelines.size
- %li.diffs-tab
- = link_to url_for(params.merge(action: 'new_diffs')), data: {target: 'div#diffs', action: 'new/diffs', toggle: 'tab'} do
- Changes
- %span.badge= @merge_request.diff_size
-
- .tab-content
- #commits.commits.tab-pane.active
- = render "projects/merge_requests/show/commits"
- #diffs.diffs.tab-pane
- -# This tab is always loaded via AJAX
- - if @pipelines.any?
- #pipelines.pipelines.tab-pane
- = render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)), disable_initialization: true
-
- .mr-loading-status
- = spinner
-
-:javascript
- var merge_request = new MergeRequest({
- action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}",
- setUrl: false,
- });
diff --git a/app/views/projects/merge_requests/_pipelines.html.haml b/app/views/projects/merge_requests/_pipelines.html.haml
new file mode 100644
index 00000000000..473b7b919c8
--- /dev/null
+++ b/app/views/projects/merge_requests/_pipelines.html.haml
@@ -0,0 +1,4 @@
+- endpoint_path = local_assigns[:endpoint] || pipelines_project_merge_request_path(@project, @merge_request, format: :json)
+- disable_initialization = local_assigns.fetch(:disable_initialization, false)
+
+= render 'projects/commit/pipelines_list', endpoint: endpoint_path, disable_initialization: disable_initialization
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
deleted file mode 100644
index 75120409bb3..00000000000
--- a/app/views/projects/merge_requests/_show.html.haml
+++ /dev/null
@@ -1,97 +0,0 @@
-- @content_class = "limit-container-width" unless fluid_layout
-- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
-- page_description @merge_request.description
-- page_card_attributes @merge_request.card_attributes
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('diff_notes')
-
-.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) }
- = render "projects/merge_requests/show/mr_title"
-
- .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
- = render "projects/merge_requests/show/mr_box"
-
- - if @merge_request.source_branch_exists?
- = render "projects/merge_requests/show/how_to_merge"
-
- :javascript
- window.gl.mrWidgetData = #{serialize_issuable(@merge_request)}
-
- #js-vue-mr-widget.mr-widget
-
- - content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'vue_merge_request_widget'
-
- .content-block.content-block-small.emoji-list-container
- = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
-
- .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
- .merge-request-tabs-container
- .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- .nav-links.scrolling-tabs
- %ul.merge-request-tabs
- %li.notes-tab
- = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do
- Discussion
- %span.badge= @merge_request.related_notes.user.count
- - if @merge_request.source_project
- %li.commits-tab
- = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
- Commits
- %span.badge= @commits_count
- - if @pipelines.any?
- %li.pipelines-tab
- = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
- Pipelines
- %span.badge= @pipelines.size
- %li.diffs-tab
- = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
- Changes
- %span.badge= @merge_request.diff_size
- #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
- %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
- %div
- .line-resolve-all{ "v-show" => "discussionCount > 0",
- ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
- %span.line-resolve-btn.is-disabled{ type: "button",
- ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
- = render "shared/icons/icon_status_success.svg"
- %span.line-resolve-text
- {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
- = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
- = render "discussions/jump_to_next"
-
- .tab-content#diff-notes-app
- #notes.notes.tab-pane.voting_notes
- .row
- %section.col-md-12
- .issuable-discussion
- = render "projects/merge_requests/discussion"
-
- #commits.commits.tab-pane
- -# This tab is always loaded via AJAX
- #pipelines.pipelines.tab-pane
- - if @pipelines.any?
- = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- #diffs.diffs.tab-pane
- -# This tab is always loaded via AJAX
-
- .mr-loading-status
- = spinner
-
-= render 'shared/issuable/sidebar', issuable: @merge_request
-- if @merge_request.can_be_reverted?(current_user)
- = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
-- if @merge_request.can_be_cherry_picked?
- = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title
-
-:javascript
- $(function () {
- window.mergeRequest = new MergeRequest({
- action: "#{controller.action_name}"
- });
- });
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
index 51d59280be8..a6e2565a485 100644
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -1,17 +1,15 @@
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js')
-= render "projects/merge_requests/show/mr_title"
+= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details
- = render "projects/merge_requests/show/mr_box"
+ = render "projects/merge_requests/mr_box"
= render 'shared/issuable/sidebar', issuable: @merge_request
-#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json),
- resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } }
+#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
.loading{ "v-if" => "isLoading" }
%i.fa.fa-spinner.fa-spin
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
index e675e1830d0..13026b7566a 100644
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -13,4 +13,4 @@
%button.btn.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
%span {{commitButtonText}}
.col-xs-6.text-right
- = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
+ = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "btn btn-cancel"
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
new file mode 100644
index 00000000000..a6e2565a485
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -0,0 +1,36 @@
+- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+= render "projects/merge_requests/mr_title"
+
+.merge-request-details.issuable-details
+ = render "projects/merge_requests/mr_box"
+
+= render 'shared/issuable/sidebar', issuable: @merge_request
+
+#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
+ .loading{ "v-if" => "isLoading" }
+ %i.fa.fa-spinner.fa-spin
+
+ .nothing-here-block{ "v-if" => "hasError" }
+ {{conflictsData.errorMessage}}
+
+ = render partial: "projects/merge_requests/conflicts/commit_stats"
+
+ .files-wrapper{ "v-if" => "!isLoading && !hasError" }
+ .files
+ .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" }
+ .js-file-title.file-title
+ %i.fa.fa-fw{ ":class" => "file.iconClass" }
+ %strong {{file.filePath}}
+ = render partial: 'projects/merge_requests/conflicts/file_actions'
+ .diff-content.diff-wrap-lines
+ .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines"
+ .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ %parallel-conflict-lines{ ":file" => "file" }
+ %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" }
+ = render partial: "projects/merge_requests/conflicts/components/diff_file_editor"
+
+ = render partial: "projects/merge_requests/conflicts/submit_form"
diff --git a/app/views/projects/merge_requests/creations/_diffs.html.haml b/app/views/projects/merge_requests/creations/_diffs.html.haml
new file mode 100644
index 00000000000..5b70e894b39
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/_diffs.html.haml
@@ -0,0 +1,5 @@
+- if @merge_request.can_be_created
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, show_whitespace_toggle: false
+- else
+ .nothing-here-block
+ This merge request cannot be created.
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
new file mode 100644
index 00000000000..9d5cebdda53
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -0,0 +1,68 @@
+%h3.page-title
+ New Merge Request
+
+= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f|
+ .hide.alert.alert-danger.mr-compare-errors
+ .merge-request-branches.js-merge-request-new-compare.row{ 'data-target-project-url': project_new_merge_request_update_branches_path(@source_project), 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) }
+ .col-md-6
+ .panel.panel-default.panel-new-merge-request
+ .panel-heading
+ Source branch
+ .panel-body.clearfix
+ .merge-request-select.dropdown
+ = f.hidden_field :source_project_id
+ = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", field_name: "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-source-project
+ = dropdown_title("Select source project")
+ = dropdown_filter("Search projects")
+ = dropdown_content do
+ = render 'projects/merge_requests/dropdowns/project',
+ projects: [@merge_request.source_project],
+ 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 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
+ = render 'projects/merge_requests/dropdowns/branch',
+ branches: @merge_request.source_branches,
+ selected: f.object.source_branch
+ .panel-footer
+ .text-center= icon('spinner spin', class: 'js-source-loading')
+ %ul.list-unstyled.mr_source_commit
+
+ .col-md-6
+ .panel.panel-default.panel-new-merge-request
+ .panel-heading
+ Target branch
+ .panel-body.clearfix
+ - projects = target_projects(@project)
+ .merge-request-select.dropdown
+ = f.hidden_field :target_project_id
+ = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-target-project
+ = dropdown_title("Select target project")
+ = dropdown_filter("Search projects")
+ = dropdown_content do
+ = render 'projects/merge_requests/dropdowns/project',
+ projects: projects,
+ 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 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
+ = render 'projects/merge_requests/dropdowns/branch',
+ branches: @merge_request.target_branches,
+ selected: f.object.target_branch
+ .panel-footer
+ .text-center= icon('spinner spin', class: "js-target-loading")
+ %ul.list-unstyled.mr_target_commit
+
+ - if @merge_request.errors.any?
+ = form_errors(@merge_request)
+ = f.submit 'Compare branches and continue', class: "btn btn-new mr-compare-btn"
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
new file mode 100644
index 00000000000..376ac377562
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -0,0 +1,52 @@
+%h3.page-title
+ New Merge Request
+%p.slead
+ - source_title, target_title = format_mr_branch_names(@merge_request)
+ From
+ %strong.ref-name= source_title
+ %span into
+ %strong.ref-name= target_title
+
+ %span.pull-right
+ = link_to 'Change branches', mr_change_branches_path(@merge_request)
+%hr
+= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal common-note-form js-requires-input js-quick-submit' } do |f|
+ = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits
+ = f.hidden_field :source_project_id
+ = f.hidden_field :source_branch
+ = f.hidden_field :target_project_id
+ = f.hidden_field :target_branch, id: ''
+
+.mr-compare.merge-request.js-merge-request-new-submit{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" }
+ - if @commits.empty?
+ .commits-empty
+ %h4
+ There are no commits yet.
+ = custom_icon ('illustration_no_commits')
+ - else
+ %ul.merge-request-tabs.nav-links.no-top.no-bottom
+ %li.commits-tab.active
+ = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
+ Commits
+ %span.badge= @commits.size
+ - if @pipelines.any?
+ %li.builds-tab
+ = link_to url_for(params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do
+ Pipelines
+ %span.badge= @pipelines.size
+ %li.diffs-tab
+ = link_to url_for(params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
+ Changes
+ %span.badge= @merge_request.diff_size
+
+ .tab-content
+ #commits.commits.tab-pane.active
+ = render "projects/merge_requests/commits"
+ #diffs.diffs.tab-pane
+ -# This tab is always loaded via AJAX
+ - if @pipelines.any?
+ #pipelines.pipelines.tab-pane
+ = render 'projects/merge_requests/pipelines', endpoint: url_for(params.merge(action: 'pipelines', format: :json)), disable_initialization: true
+
+ .mr-loading-status
+ = spinner
diff --git a/app/views/projects/merge_requests/branch_from.html.haml b/app/views/projects/merge_requests/creations/branch_from.html.haml
index 3837c4b388d..3837c4b388d 100644
--- a/app/views/projects/merge_requests/branch_from.html.haml
+++ b/app/views/projects/merge_requests/creations/branch_from.html.haml
diff --git a/app/views/projects/merge_requests/branch_to.html.haml b/app/views/projects/merge_requests/creations/branch_to.html.haml
index d69b71790a0..d69b71790a0 100644
--- a/app/views/projects/merge_requests/branch_to.html.haml
+++ b/app/views/projects/merge_requests/creations/branch_to.html.haml
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
new file mode 100644
index 00000000000..3220512d60d
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -0,0 +1,7 @@
+- breadcrumb_title "Merge Requests"
+- page_title "New Merge Request"
+
+- if @merge_request.can_be_created && !params[:change_branches]
+ = render 'new_submit'
+- else
+ = render 'new_compare'
diff --git a/app/views/projects/merge_requests/update_branches.html.haml b/app/views/projects/merge_requests/creations/update_branches.html.haml
index 64482973a89..64482973a89 100644
--- a/app/views/projects/merge_requests/update_branches.html.haml
+++ b/app/views/projects/merge_requests/creations/update_branches.html.haml
diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
new file mode 100644
index 00000000000..2e5594f8cbe
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
@@ -0,0 +1,5 @@
+- if @commit
+ .info-well.hidden-xs.prepend-top-default
+ .well-segment
+ %ul.blob-commit-info
+ = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true
diff --git a/app/views/projects/merge_requests/diffs/_different_base.html.haml b/app/views/projects/merge_requests/diffs/_different_base.html.haml
new file mode 100644
index 00000000000..0e57066f9c9
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_different_base.html.haml
@@ -0,0 +1,11 @@
+- if @merge_request_diff && different_base?(@start_version, @merge_request_diff)
+ .mr-version-controls
+ .content-block
+ = icon('info-circle')
+ Selected versions have different base commits.
+ Changes will include
+ = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
+ new commits
+ from
+ = succeed '.' do
+ %code.ref-name= @merge_request.target_branch
diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml
new file mode 100644
index 00000000000..986ba5ae02d
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml
@@ -0,0 +1,21 @@
+= render 'projects/merge_requests/diffs/version_controls'
+= render 'projects/merge_requests/diffs/different_base'
+= render 'projects/merge_requests/diffs/not_all_comments_displayed'
+= render 'projects/merge_requests/diffs/commit_widget'
+
+- if @merge_request_diff&.empty?
+ .row.empty-state.nothing-here-block
+ .col-xs-12
+ .svg-content= image_tag 'illustrations/merge_request_changes_empty.svg'
+ .col-xs-12
+ .text-content.text-center
+ %p
+ No changes between
+ %span.ref-name= @merge_request.source_branch
+ and
+ %span.ref-name= @merge_request.target_branch
+ .text-center= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-save'
+- else
+ - diff_viewable = @merge_request_diff ? @merge_request_diff.collected? || @merge_request_diff.overflow? : true
+ - if diff_viewable
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true
diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml
new file mode 100644
index 00000000000..529fbb8547a
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml
@@ -0,0 +1,17 @@
+- if @commit || @start_version || (@merge_request_diff && !@merge_request_diff.latest?)
+ .mr-version-controls
+ .content-block.comments-disabled-notif.clearfix
+ = icon('info-circle')
+ = succeed '.' do
+ - if @commit
+ Only comments from the following commit are shown below
+ - else
+ Not all comments are displayed because you're
+ - if @start_version
+ comparing two versions of the diff
+ - else
+ viewing an old version of the diff
+ .pull-right
+ = link_to diffs_project_merge_request_path(@merge_request.project, @merge_request), class: 'btn btn-sm' do
+ Show latest version
+ = "of the diff" if @commit
diff --git a/app/views/projects/merge_requests/diffs/_version_controls.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml
new file mode 100644
index 00000000000..1c26f0405d2
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_version_controls.html.haml
@@ -0,0 +1,73 @@
+- if @merge_request_diff && @merge_request_diffs.size > 1
+ .mr-version-controls
+ .mr-version-menus-container.content-block
+ Changes between
+ %span.dropdown.inline.mr-version-dropdown
+ %a.dropdown-toggle.btn.btn-default{ data: {toggle: :dropdown} }
+ %span
+ - if @merge_request_diff.latest?
+ latest version
+ - else
+ version #{version_index(@merge_request_diff)}
+ = icon('caret-down')
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ .dropdown-title
+ %span Version:
+ %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
+ = icon('times', class: 'dropdown-menu-close-icon')
+ .dropdown-content
+ %ul
+ - @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
+ %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} }
+ - 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
+ %span Compared with:
+ %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
+ = icon('times', class: 'dropdown-menu-close-icon')
+ .dropdown-content
+ %ul
+ - @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
+ %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
+ %div
+ %strong
+ %span.ref-name= @merge_request.target_branch
+ (base)
+ %div
+ %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha)
diff --git a/app/views/projects/merge_requests/dropdowns/_project.html.haml b/app/views/projects/merge_requests/dropdowns/_project.html.haml
index 25d5dc92f8a..aaf1ab00eeb 100644
--- a/app/views/projects/merge_requests/dropdowns/_project.html.haml
+++ b/app/views/projects/merge_requests/dropdowns/_project.html.haml
@@ -2,4 +2,4 @@
- projects.each do |project|
%li
%a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id } }
- = project.path_with_namespace
+ = project.full_path
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 6d75a9f34a3..b2c0d9e1cfa 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -1,29 +1,20 @@
- @no_container = true
- @can_bulk_update = can?(current_user, :admin_merge_request, @project)
+- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+- new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project
- page_title "Merge Requests"
-- unless @project.default_issues_tracker?
- = content_for :sub_nav do
- = render "projects/merge_requests/head"
+- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
-
-
-= render 'projects/last_push'
+%div{ class: container_class }
+ = 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
- New merge request
+ = render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
= render 'shared/issuable/search_bar', type: :merge_requests
@@ -32,5 +23,7 @@
.merge-requests-holder
= render 'merge_requests'
+ - if new_merge_request_email
+ = render 'projects/issuable_by_email', email: new_merge_request_email, issuable_type: 'merge_request'
- else
- = render 'shared/empty_states/merge_requests', button_path: new_namespace_project_merge_request_path(@project.namespace, @project)
+ = render 'shared/empty_states/merge_requests', button_path: new_merge_request_path
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index a00d3128ffe..6df19d6438b 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,8 +1,8 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
.merge-request
- = render "projects/merge_requests/show/mr_title"
- = render "projects/merge_requests/show/mr_box"
+ = render "projects/merge_requests/mr_title"
+ = render "projects/merge_requests/mr_box"
.alert.alert-danger
%p
diff --git a/app/views/projects/merge_requests/new.html.haml b/app/views/projects/merge_requests/new.html.haml
deleted file mode 100644
index 2e798ce780a..00000000000
--- a/app/views/projects/merge_requests/new.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- page_title "New Merge Request"
-
-- if @merge_request.can_be_created && !params[:change_branches]
- = render 'new_submit'
-- else
- = render 'new_compare'
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 2a5b8b1441e..9866cc716ee 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1 +1,100 @@
-= render "show"
+- @gfm_form = true
+- @content_class = "limit-container-width" unless fluid_layout
+- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
+- breadcrumb_title @merge_request.to_reference
+- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
+- page_description @merge_request.description
+- page_card_attributes @merge_request.card_attributes
+
+.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
+ = render "projects/merge_requests/mr_title"
+
+ .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
+ = render "projects/merge_requests/mr_box"
+
+ - if @merge_request.source_branch_exists?
+ = render "projects/merge_requests/how_to_merge"
+
+ -# haml-lint:disable InlineJavaScript
+ :javascript
+ window.gl = window.gl || {};
+ window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget')}
+
+ #js-vue-mr-widget.mr-widget
+
+ .content-block.content-block-small.emoji-list-container.js-noteable-awards
+ = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
+
+ .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
+ .merge-request-tabs-container
+ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
+ .fade-left= icon('angle-left')
+ .fade-right= icon('angle-right')
+ .nav-links.scrolling-tabs
+ %ul.merge-request-tabs
+ %li.notes-tab
+ = tab_link_for @merge_request, :show, force_link: @commit.present? do
+ Discussion
+ %span.badge= @merge_request.related_notes.user.count
+ - if @merge_request.source_project
+ %li.commits-tab
+ = tab_link_for @merge_request, :commits do
+ Commits
+ %span.badge= @commits_count
+ - if @pipelines.any?
+ %li.pipelines-tab
+ = tab_link_for @merge_request, :pipelines do
+ Pipelines
+ %span.badge.js-pipelines-mr-count= @pipelines.size
+ %li.diffs-tab
+ = tab_link_for @merge_request, :diffs do
+ Changes
+ %span.badge= @merge_request.diff_size
+
+ - if has_vue_discussions_cookie?
+ #js-vue-discussion-counter
+ - else
+ #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
+ %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
+ %div
+ .line-resolve-all{ "v-show" => "discussionCount > 0",
+ ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
+ %span.line-resolve-btn.is-disabled{ type: "button",
+ ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
+ %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
+ = render 'shared/icons/icon_status_success_solid.svg'
+ %template{ 'v-else' => '' }
+ = render 'shared/icons/icon_resolve_discussion.svg'
+ %span.line-resolve-text
+ {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
+ = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
+ = render "discussions/jump_to_next"
+
+ .tab-content#diff-notes-app
+ #notes.notes.tab-pane.voting_notes
+ .row
+ %section.col-md-12
+ %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
+ .issuable-discussion.js-vue-notes-event
+ = render "projects/merge_requests/discussion"
+ - if has_vue_discussions_cookie?
+ #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
+ noteable_data: serialize_issuable(@merge_request),
+ current_user_data: UserSerializer.new.represent(current_user).to_json} }
+
+ #commits.commits.tab-pane
+ -# This tab is always loaded via AJAX
+ #pipelines.pipelines.tab-pane
+ - if @pipelines.any?
+ = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
+ #diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } }
+ -# This tab is always loaded via AJAX
+
+ .mr-loading-status
+ = spinner
+
+= render 'shared/issuable/sidebar', issuable: @merge_request
+- if @merge_request.can_be_reverted?(current_user)
+ = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
+- if @merge_request.can_be_cherry_picked?
+ = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title
diff --git a/app/views/projects/merge_requests/show/_commits.html.haml b/app/views/projects/merge_requests/show/_commits.html.haml
deleted file mode 100644
index 11793919ff7..00000000000
--- a/app/views/projects/merge_requests/show/_commits.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- if @commits.empty?
- .commits-empty
- %h4
- There are no commits yet.
- = custom_icon ('illustration_no_commits')
-- else
- %ol#commits-list.list-unstyled
- = render "projects/commits/commits", project: @merge_request.source_project, ref: @merge_request.source_branch
diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml
deleted file mode 100644
index 7f0913ea516..00000000000
--- a/app/views/projects/merge_requests/show/_diffs.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- if @merge_request_diff.collected? || @merge_request_diff.overflow?
- = render 'projects/merge_requests/show/versions'
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment
-- elsif @merge_request_diff.empty?
- .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
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
deleted file mode 100644
index 766cb272bec..00000000000
--- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml
+++ /dev/null
@@ -1,63 +0,0 @@
-#modal_merge_info.modal
- .modal-dialog
- .modal-content
- .modal-header
- %a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3 Check out, review, and merge locally
- .modal-body
- %p
- %strong Step 1.
- Fetch and check out the branch for this merge request
- = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
- %pre.dark#merge-info-1
- - if @merge_request.for_fork?
- :preserve
- git fetch #{h default_url_to_repo(@merge_request.source_project)} #{h @merge_request.source_branch}
- git checkout -b #{h @merge_request.source_project_path}-#{h @merge_request.source_branch} FETCH_HEAD
- - else
- :preserve
- git fetch origin
- git checkout -b #{h @merge_request.source_branch} origin/#{h @merge_request.source_branch}
- %p
- %strong Step 2.
- Review the changes locally
-
- %p
- %strong Step 3.
- Merge the branch and fix any conflicts that come up
- = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
- %pre.dark#merge-info-3
- - if @merge_request.for_fork?
- :preserve
- git checkout #{h @merge_request.target_branch}
- git merge --no-ff #{h @merge_request.source_project_path}-#{h @merge_request.source_branch}
- - else
- :preserve
- git checkout #{h @merge_request.target_branch}
- git merge --no-ff #{h @merge_request.source_branch}
- %p
- %strong Step 4.
- Push the result of the merge to GitLab
- = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
- %pre.dark#merge-info-4
- :preserve
- git push origin #{h @merge_request.target_branch}
- - unless @merge_request.can_be_merged_by?(current_user)
- %p
- Note that pushing to GitLab requires write access to this repository.
- %p
- %strong Tip:
- = succeed '.' do
- You can also checkout merge requests locally by
- = 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(){
- var modal = $('#modal_merge_info').modal({modal: true, show:false});
- $('.how_to_merge_link').bind("click", function(){
- modal.show();
- });
- $('.modal-header .close').bind("click", function(){
- modal.hide();
- })
- })
diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml
deleted file mode 100644
index d9428b8562e..00000000000
--- a/app/views/projects/merge_requests/show/_mr_title.html.haml
+++ /dev/null
@@ -1,35 +0,0 @@
-- if @merge_request.closed_without_fork?
- .alert.alert-danger
- %p The source project of this merge request has been removed.
-
-.clearfix.detail-page-header
- .issuable-header
- .issuable-status-box.status-box{ class: status_box_class(@merge_request) }
- = icon(@merge_request.state_icon_name, class: "hidden-sm hidden-md hidden-lg")
- %span.hidden-xs
- = @merge_request.state_human_name
-
- %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
-
- .issuable-meta
- = issuable_meta(@merge_request, @project, "Merge request")
-
- - if can?(current_user, :update_merge_request, @merge_request)
- .issuable-actions
- .clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
- Options
- = icon('caret-down')
- .dropdown-menu.dropdown-menu-align-right.hidden-lg
- %ul
- %li{ class: merge_request_button_visibility(@merge_request, true) }
- = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
- %li{ class: merge_request_button_visibility(@merge_request, false) }
- = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
- %li
- = link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'issuable-edit'
- = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{merge_request_button_visibility(@merge_request, true)}", title: 'Close merge request'
- = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen reopen-mr-link #{merge_request_button_visibility(@merge_request, false)}", title: 'Reopen merge request'
- = link_to edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit" do
- Edit
diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml
deleted file mode 100644
index 2f1dbe87619..00000000000
--- a/app/views/projects/merge_requests/show/_pipelines.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- endpoint_path = local_assigns[:endpoint] || pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, format: :json)
-- disable_initialization = local_assigns.fetch(:disable_initialization, false)
-
-= render 'projects/commit/pipelines_list', endpoint: endpoint_path, disable_initialization: disable_initialization
diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml
deleted file mode 100644
index 0999b95c9c9..00000000000
--- a/app/views/projects/merge_requests/show/_versions.html.haml
+++ /dev/null
@@ -1,97 +0,0 @@
-- if @merge_request_diffs.size > 1
- .mr-version-controls
- .mr-version-menus-container.content-block
- Changes between
- %span.dropdown.inline.mr-version-dropdown
- %a.dropdown-toggle.btn.btn-default{ data: {toggle: :dropdown} }
- %span
- - if @merge_request_diff.latest?
- latest version
- - else
- version #{version_index(@merge_request_diff)}
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Version:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @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
- %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} }
- - 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
- %span Compared with:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @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
- %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
- %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
- = icon('info-circle')
- Selected versions have different base commits.
- Changes will include
- = link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
- new commits
- from
- = succeed '.' do
- %code= @merge_request.target_branch
-
- - if @start_version || !@merge_request_diff.latest?
- .comments-disabled-notif.content-block
- = icon('info-circle')
- Not all comments are displayed because you're
- - if @start_version
- comparing two versions
- - else
- viewing an old version
- 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/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 9a95b2a82ff..2e74b1b83cb 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -19,7 +19,7 @@
.form-actions
- if @milestone.new_record?
= f.submit 'Create milestone', class: "btn-create btn"
- = link_to "Cancel", namespace_project_milestones_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to "Cancel", project_milestones_path(@project), class: "btn btn-cancel"
- else
= f.submit 'Save changes', class: "btn-save btn"
- = link_to "Cancel", namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-cancel"
+ = link_to "Cancel", project_milestone_path(@project, @milestone), class: "btn btn-cancel"
diff --git a/app/views/projects/milestones/_milestone.html.haml b/app/views/projects/milestones/_milestone.html.haml
index 77b566db6b6..bc82b45f902 100644
--- a/app/views/projects/milestones/_milestone.html.haml
+++ b/app/views/projects/milestones/_milestone.html.haml
@@ -1,5 +1,5 @@
= render 'shared/milestones/milestone',
- milestone_path: namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone),
- issues_path: namespace_project_issues_path(milestone.project.namespace, milestone.project, milestone_title: milestone.title),
- merge_requests_path: namespace_project_merge_requests_path(milestone.project.namespace, milestone.project, milestone_title: milestone.title),
+ milestone_path: project_milestone_path(milestone.project, milestone),
+ issues_path: project_issues_path(milestone.project, milestone_title: milestone.title),
+ merge_requests_path: project_merge_requests_path(milestone.project, milestone_title: milestone.title),
milestone: milestone
diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml
index 1e66c6079e3..af3f25c6a30 100644
--- a/app/views/projects/milestones/edit.html.haml
+++ b/app/views/projects/milestones/edit.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Edit", @milestone.title, "Milestones"
-= render "shared/mr_head"
%div{ class: container_class }
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index e1096bd1d67..5b0197ed58c 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title 'Milestones'
-= render "shared/mr_head"
%div{ class: container_class }
.top-area
@@ -9,10 +8,13 @@
.nav-controls
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New milestone' do
+ = link_to new_project_milestone_path(@project), class: "btn btn-new", title: 'New milestone' do
New milestone
.milestones
+ #delete-milestone-modal
+ #promote-milestone-modal
+
%ul.content-list
= render @milestones
diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml
index 586eb909afa..c301f517013 100644
--- a/app/views/projects/milestones/new.html.haml
+++ b/app/views/projects/milestones/new.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
+- breadcrumb_title "Milestones"
- page_title "New Milestone"
-= render "shared/mr_head"
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 4b692aba11c..b423888c875 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -1,7 +1,8 @@
- @no_container = true
+- add_to_breadcrumbs "Milestones", project_milestones_path(@project)
+- breadcrumb_title @milestone.title
- page_title @milestone.title, "Milestones"
- page_description @milestone.description
-= render "shared/mr_head"
%div{ class: container_class }
.detail-page-header.milestone-page-header
@@ -22,16 +23,37 @@
= milestone_date_range(@milestone)
.milestone-buttons
- if can?(current_user, :admin_milestone, @project)
+ = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do
+ Edit
+
+ - if @project.group
+ %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal',
+ target: '#promote-milestone-modal',
+ milestone_title: @milestone.title,
+ url: promote_project_milestone_path(@milestone.project, @milestone),
+ container: 'body' },
+ disabled: true,
+ type: 'button' }
+ = _('Promote')
+ #promote-milestone-modal
+
- if @milestone.active?
- = link_to 'Close milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
+ = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
- else
- = link_to 'Reopen milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
+ = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
- = link_to edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-grouped btn-nr" do
- Edit
+ %button.js-delete-milestone-button.btn.btn-grouped.btn-danger{ data: { toggle: 'modal',
+ target: '#delete-milestone-modal',
+ milestone_id: @milestone.id,
+ milestone_title: markdown_field(@milestone, :title),
+ milestone_url: project_milestone_path(@project, @milestone),
+ milestone_issue_count: @milestone.issues.count,
+ milestone_merge_request_count: @milestone.merge_requests.count },
+ disabled: true }
+ = _('Delete')
+ = icon('spin spinner', class: 'js-loading-icon hidden' )
- = link_to namespace_project_milestone_path(@project.namespace, @project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do
- Delete
+ #delete-milestone-modal
%a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" }
= icon('angle-double-left')
@@ -39,6 +61,7 @@
.detail-page-description.milestone-detail
%h2.title
= markdown_field(@milestone, :title)
+
%div
- if @milestone.description.present?
.description
diff --git a/app/views/projects/network/_head.html.haml b/app/views/projects/network/_head.html.haml
index 8f6805268d5..f08526f485e 100644
--- a/app/views/projects/network/_head.html.haml
+++ b/app/views/projects/network/_head.html.haml
@@ -6,4 +6,4 @@
= render partial: 'shared/ref_switcher', locals: {destination: 'graph'}
.oneline
- You can move around the graph by using the arrow keys.
+ = _("You can move around the graph by using the arrow keys.")
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index ed6077f6c6b..4b7be9a223f 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -1,20 +1,18 @@
+- breadcrumb_title "Graph"
- page_title "Graph", @ref
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('network')
-= render "projects/commits/head"
= render "head"
%div{ class: container_class }
.project-network
.controls
- = form_tag namespace_project_network_path(@project.namespace, @project, @id), method: :get, class: 'form-inline network-form' do |f|
- = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: "Git revision", class: 'search-input form-control input-mx-250 search-sha'
+ = form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f|
+ = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha'
= button_tag class: 'btn btn-success' do
= icon('search')
.inline.prepend-left-20
.checkbox.light
= label_tag :filter_ref do
= check_box_tag :filter_ref, 1, @options[:filter_ref]
- %span Begin with the selected commit
+ %span= _("Begin with the selected commit")
- if @commit
.network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb
index 122e84b41b2..a0e82e891ff 100644
--- a/app/views/projects/network/show.json.erb
+++ b/app/views/projects/network/show.json.erb
@@ -9,11 +9,11 @@
author: {
name: c.author_name,
email: c.author_email,
- icon: image_path(avatar_icon(c.author_email, 20))
+ icon: image_path(avatar_icon_for_email(c.author_email, 20))
},
time: c.time,
space: c.spaces.first,
- refs: get_refs(@graph.repo, c),
+ refs: refs(@graph.repo, c),
id: c.sha,
date: c.date,
message: c.message,
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 7b8be58554a..8cdb0a6aff4 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -1,6 +1,10 @@
+- @breadcrumb_link = dashboard_projects_path
+- breadcrumb_title "Projects"
+- @hide_top_links = true
- page_title 'New Project'
- header_title "Projects", dashboard_projects_path
- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility
+- active_tab = local_assigns.fetch(:active_tab, 'blank')
.project-edit-container
.project-edit-errors
@@ -8,98 +12,107 @@
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
- New project
+ = _('New project')
%p
- Create or Import your project from popular Git services
- .col-lg-9
- = form_for @project, html: { class: 'new_project' } do |f|
- .row
- .form-group.col-xs-12.col-sm-6
- = f.label :namespace_id, class: 'label-light' do
- %span
- Project path
- .form-group
- .input-group
- - if current_user.can_select_namespace?
- .input-group-addon
- = root_url
- = f.select :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), {}, { class: 'select2 js-select-namespace', tabindex: 1}
-
- - else
- .input-group-addon.static-namespace
- #{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 :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
- - if current_user.can_create_group?
- .help-block
- Want to house several dependent projects under the same namespace?
- = link_to "Create a group", new_group_path
-
- - if import_sources_enabled?
- .project-import.js-toggle-container
- .form-group.clearfix
- = f.label :visibility_level, class: 'label-light' do
- Import project from
- .col-sm-12.import-buttons
- %div
- - if github_import_enabled?
- = link_to new_import_github_path, class: 'btn import_github' do
- = icon('github', text: 'GitHub')
- %div
- - if bitbucket_import_enabled?
- = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
- = icon('bitbucket', text: 'Bitbucket')
- - unless bitbucket_import_configured?
- = render 'bitbucket_import_modal'
- %div
- - if gitlab_import_enabled?
- = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
- = icon('gitlab', text: 'GitLab.com')
- - unless gitlab_import_configured?
- = render 'gitlab_import_modal'
- %div
- - if google_code_import_enabled?
- = link_to new_import_google_code_path, class: 'btn import_google_code' do
- = icon('google', text: 'Google Code')
- %div
- - if fogbugz_import_enabled?
- = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
- = icon('bug', text: 'Fogbugz')
- %div
- - if gitea_import_enabled?
- = link_to new_import_gitea_url, class: 'btn import_gitea' do
- = custom_icon('go_logo')
- Gitea
+ - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'
+ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link }
+ %p
+ = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
+ -# EE-specific start
+ -# EE-specific end
+ .md
+ = brand_new_project_guidelines
+ %p
+ %strong= _("Tip:")
+ = _("You can also create a project from the command line.")
+ %a.push-new-project-tip{ data: { title: _("Push to create a project") }, href: help_page_path('gitlab-basics/create-project', anchor: 'push-to-create-a-new-project'), target: "_blank", rel: "noopener noreferrer" }
+ = _("Show command")
+ %template.push-new-project-tip-template= render partial: "new_project_push_tip"
+
+ .col-lg-9.js-toggle-container
+ %ul.nav-links.gitlab-tabs{ role: 'tablist' }
+ %li{ class: active_when(active_tab == 'blank'), role: 'presentation' }
+ %a{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab' }, role: 'tab' }
+ %span.hidden-xs Blank project
+ %span.visible-xs Blank
+ %li{ class: active_when(active_tab == 'template'), role: 'presentation' }
+ %a{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab' }, role: 'tab' }
+ %span.hidden-xs Create from template
+ %span.visible-xs Template
+ %li{ class: active_when(active_tab == 'import'), role: 'presentation' }
+ %a{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' }
+ %span.hidden-xs Import project
+ %span.visible-xs Import
+ -# EE-specific start
+ -# EE-specific end
+
+ .tab-content.gitlab-tab-content
+ .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' }
+ = form_for @project, html: { class: 'new_project' } do |f|
+ = render 'new_project_fields', f: f, project_name_id: "blank-project-name"
+
+ .tab-pane.no-padding{ id: 'create-from-template-pane', class: active_when(active_tab == 'template'), role: 'tabpanel' }
+ = form_for @project, html: { class: 'new_project' } do |f|
+ .project-template
+ .form-group
%div
- - if git_import_enabled?
- %button.btn.js-toggle-button.import_git{ type: "button" }
- = icon('git', text: 'Repo by URL')
- .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- - if gitlab_project_import_enabled?
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
- = icon('gitlab', text: 'GitLab export')
-
- .js-toggle-content.hide
- = render "shared/import_form", f: f
-
- .form-group
- = f.label :description, class: 'label-light' do
- Project description
- %span.light (optional)
- = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250
-
- .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"), 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
- = link_to 'Cancel', dashboard_projects_path, class: 'btn btn-cancel'
+ = render 'project_templates', f: f
+
+ .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' }
+ = form_for @project, html: { class: 'new_project' } do |f|
+ - if import_sources_enabled?
+ .project-import.row
+ .col-lg-12
+ .form-group.import-btn-container.clearfix
+ = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
+ Import project from
+ .import-buttons
+ - if gitlab_project_import_enabled?
+ .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+ = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
+ = icon('gitlab', text: 'GitLab export')
+ %div
+ - if github_import_enabled?
+ = link_to new_import_github_path, class: 'btn js-import-github' do
+ = icon('github', text: 'GitHub')
+ %div
+ - if bitbucket_import_enabled?
+ = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
+ = icon('bitbucket', text: 'Bitbucket')
+ - unless bitbucket_import_configured?
+ = render 'bitbucket_import_modal'
+ %div
+ - if gitlab_import_enabled?
+ = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
+ = icon('gitlab', text: 'GitLab.com')
+ - unless gitlab_import_configured?
+ = render 'gitlab_import_modal'
+ %div
+ - if google_code_import_enabled?
+ = link_to new_import_google_code_path, class: 'btn import_google_code' do
+ = icon('google', text: 'Google Code')
+ %div
+ - if fogbugz_import_enabled?
+ = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
+ = icon('bug', text: 'Fogbugz')
+ %div
+ - if gitea_import_enabled?
+ = link_to new_import_gitea_path, class: 'btn import_gitea' do
+ = custom_icon('go_logo')
+ Gitea
+ %div
+ - if git_import_enabled?
+ %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } }
+ = icon('git', text: 'Repo by URL')
+ .col-lg-12
+ .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
+ %hr
+ = render "shared/import_form", f: f
+ = render 'new_project_fields', f: f, project_name_id: "import-url-name"
+
+
+ -# EE-specific start
+ -# EE-specific end
.save-project-loader.hide
.center
@@ -107,46 +120,3 @@
%i.fa.fa-spinner.fa-spin
Creating project &amp; repository.
%p Please wait a moment, this page will automatically refresh when ready.
-
-:javascript
- var importBtnTooltip = "Please enter a valid project name.";
- var $importBtnWrapper = $('.import_gitlab_project');
-
- $('.how_to_import_link').bind('click', function (e) {
- e.preventDefault();
- var import_modal = $(this).next(".modal").show();
- });
-
- $('.modal-header .close').bind('click', function() {
- $(".modal").hide();
- });
-
- $('.btn_import_gitlab_project').bind('click', function() {
- var _href = $("a.btn_import_gitlab_project").attr("href");
- $(".btn_import_gitlab_project").attr("href", _href + '?namespace_id=' + $("#project_namespace_id").val() + '&path=' + $("#project_path").val());
- });
-
- $('.btn_import_gitlab_project').attr('disabled', $('#project_path').val().trim().length === 0);
- $importBtnWrapper.attr('title', importBtnTooltip);
-
- $('#new_project').submit(function(){
- var $path = $('#project_path');
- $path.val($path.val().trim());
- });
-
- $('#project_path').keyup(function(){
- if($(this).val().trim().length !== 0) {
- $('.btn_import_gitlab_project').attr('disabled', false);
- $importBtnWrapper.attr('title','');
- $importBtnWrapper.removeClass('has-tooltip');
- } else {
- $('.btn_import_gitlab_project').attr('disabled',true);
- $importBtnWrapper.addClass('has-tooltip');
- }
- });
-
- $('#project_import_url').disable();
- $('.import_git').click(function( event ) {
- $projectImportUrl = $('#project_import_url');
- $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled'));
- });
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 1cf286ddc40..ba5845877e5 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -9,12 +9,12 @@
%hr
.no-repo-actions
- = link_to namespace_project_repository_path(@project.namespace, @project), method: :post, class: 'btn btn-primary' do
+ = link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do
#{ _('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
+ = link_to new_project_import_path(@project), class: 'btn' do
#{ _('Import repository') }
- if can? current_user, :remove_project, @project
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 9c42be4e0ff..5ea653ccad5 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -1,6 +1,9 @@
- access = note_max_access_for_user(note)
-- if access
- %span.note-role= access
+- if note.has_special_role?(Note::SpecialRole::FIRST_TIME_CONTRIBUTOR)
+ %span.note-role.note-role-special.has-tooltip{ title: _("This is the author's first Merge Request to this project.") }
+ = issuable_first_contribution_icon
+- if access.nonzero?
+ %span.note-role.user-access-role= Gitlab::Access.human_access(access)
- if note.resolvable?
- can_resolve = can?(current_user, :resolve_note, note)
@@ -17,24 +20,36 @@
"inline-template" => true,
"ref" => "note_#{note.id}" }
- %button.note-action-button.line-resolve-btn{ type: "button",
- class: ("is-disabled" unless can_resolve),
- ":class" => "{ 'is-active': isResolved }",
- ":aria-label" => "buttonText",
- "@click" => "resolve",
- ":title" => "buttonText",
- ":ref" => "'button'" }
+ .note-actions-item
+ %button.note-action-button.line-resolve-btn{ type: "button",
+ class: ("is-disabled" unless can_resolve),
+ ":class" => "{ 'is-active': isResolved }",
+ ":aria-label" => "buttonText",
+ "@click" => "resolve",
+ ":title" => "buttonText",
+ ":ref" => "'button'" }
- = icon('spin spinner', 'v-show' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
- %div{ 'v-show' => '!loading' }= render 'shared/icons/icon_status_success.svg'
+ = icon('spin spinner', 'v-if' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
+ %div{ 'v-else' => '' }
+ %template{ 'v-if' => 'isResolved' }
+ = render 'shared/icons/icon_status_success_solid.svg'
+ %template{ 'v-else' => '' }
+ = render 'shared/icons/icon_resolve_discussion.svg'
- if current_user
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
- = 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')
+ .note-actions-item
+ = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } 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')
- = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
+ - if note_editable
+ .note-actions-item
+ = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ %span.link-highlight
+ = custom_icon('icon_pencil')
+
+ = 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
index 75a4687e1e3..88085c7185b 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -1,14 +1,13 @@
- is_current_user = current_user == note.author
- if note_editable || !is_current_user
- .dropdown.more-actions
+ .dropdown.more-actions.note-actions-item
= 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')
+ %span.icon
+ = custom_icon('ellipsis_v')
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
- - if note_editable
- %li
- = button_tag 'Edit comment', class: 'js-note-edit btn btn-transparent'
- %li.divider
+ %li
+ = clipboard_button(text: noteable_note_url(note), title: 'Copy reference to clipboard', button_text: 'Copy link', class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true)
- unless is_current_user
%li
= link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index 42d9ef5ccba..7d6c30b7f8d 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -7,6 +7,6 @@
%p
Removing the pages will prevent from exposing them to outside world.
.form-actions
- = link_to 'Remove pages', namespace_project_pages_path(@project.namespace, @project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove"
+ = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove"
- else
.nothing-here-block Only the project owner can remove pages
diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml
new file mode 100644
index 00000000000..6a3ffce949f
--- /dev/null
+++ b/app/views/projects/pages/_https_only.html.haml
@@ -0,0 +1,10 @@
+= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f|
+ = f.check_box :pages_https_only, class: 'pull-left', disabled: pages_https_only_disabled?
+
+ .prepend-left-20
+ = f.label :pages_https_only, class: pages_https_only_label_class do
+ %strong Force domains with SSL certificates to use HTTPS
+
+ - unless pages_https_only_disabled?
+ .prepend-top-10
+ = f.submit 'Save', class: 'btn btn-success'
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 4f2dd1a1398..75df92b05a7 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -3,15 +3,26 @@
.panel-heading
Domains (#{@domains.count})
%ul.well-list
+ - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
- @domains.each do |domain|
%li
.pull-right
- = link_to 'Details', namespace_project_pages_domain_path(@project.namespace, @project, domain), class: "btn btn-sm btn-grouped"
- = link_to 'Remove', namespace_project_pages_domain_path(@project.namespace, @project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
+ = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped"
+ = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
.clearfix
- %span= link_to domain.domain, domain.url
+ - if verification_enabled
+ - tooltip, status = domain.unverified? ? ['Unverified', 'failed'] : ['Verified', 'success']
+ = link_to domain.url, title: tooltip, class: 'has-tooltip' do
+ = sprite_icon("status_#{status}", size: 16, css_class: "has-tooltip ci-status-icon ci-status-icon-#{status}")
+ = domain.domain
+ - else
+ = link_to domain.domain, domain.url
%p
- if domain.subject
%span.label.label-gray Certificate: #{domain.subject}
- if domain.expired?
%span.label.label-danger Expired
+ - if verification_enabled && domain.unverified?
+ %li.warning-row
+ #{domain.domain} is not verified. To learn how to verify ownership, visit your
+ = link_to 'domain details', project_pages_domain_path(@project, domain)
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index b22a54d75c8..f17d9d24db6 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,11 +1,10 @@
- page_title 'Pages'
-= render "projects/settings/head"
%h3.page_title
Pages
- if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
- = link_to new_namespace_project_pages_domain_path(@project.namespace, @project), class: 'btn btn-new pull-right', title: 'New Domain' do
+ = link_to new_project_pages_domain_path(@project), class: 'btn btn-new pull-right', title: 'New Domain' do
%i.fa.fa-plus
New Domain
@@ -14,6 +13,9 @@
Combined with the power of GitLab CI and the help of GitLab Runner
you can deploy static pages for your individual projects, your user or your group.
+- if Gitlab.config.pages.external_https
+ = render 'https_only'
+
%hr.clearfix
= render 'access'
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index ca1b41b140a..d81b07832bb 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,34 +1,30 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
- - if @domain.errors.any?
- #error_explanation
- .alert.alert-danger
- - @domain.errors.full_messages.each do |msg|
- %p= msg
+- if @domain.errors.any?
+ #error_explanation
+ .alert.alert-danger
+ - @domain.errors.full_messages.each do |msg|
+ %p= msg
+.form-group
+ = f.label :domain, class: 'control-label' do
+ Domain
+ .col-sm-10
+ = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted?
+
+- if Gitlab.config.pages.external_https
.form-group
- = f.label :domain, class: 'control-label' do
- Domain
+ = f.label :certificate, class: 'control-label' do
+ Certificate (PEM)
.col-sm-10
- = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control'
-
- - if Gitlab.config.pages.external_https
- .form-group
- = f.label :certificate, class: 'control-label' do
- Certificate (PEM)
- .col-sm-10
- = f.text_area :certificate, rows: 5, class: 'form-control'
- %span.help-inline Upload a certificate for your domain with all intermediates
-
- .form-group
- = f.label :key, class: 'control-label' do
- Key (PEM)
- .col-sm-10
- = f.text_area :key, rows: 5, class: 'form-control'
- %span.help-inline Upload a private key for your certificate
- - else
- .nothing-here-block
- Support for custom certificates is disabled.
- Ask your system's administrator to enable it.
+ = f.text_area :certificate, rows: 5, class: 'form-control'
+ %span.help-inline Upload a certificate for your domain with all intermediates
- .form-actions
- = f.submit 'Create New Domain', class: "btn btn-save"
+ .form-group
+ = f.label :key, class: 'control-label' do
+ Key (PEM)
+ .col-sm-10
+ = f.text_area :key, rows: 5, class: 'form-control'
+ %span.help-inline Upload a private key for your certificate
+- else
+ .nothing-here-block
+ Support for custom certificates is disabled.
+ Ask your system's administrator to enable it.
diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml
new file mode 100644
index 00000000000..5645a4604bf
--- /dev/null
+++ b/app/views/projects/pages_domains/edit.html.haml
@@ -0,0 +1,11 @@
+- add_to_breadcrumbs "Pages", project_pages_path(@project)
+- breadcrumb_title @domain.domain
+- page_title @domain.domain
+%h3.page_title
+ = @domain.domain
+%hr.clearfix
+%div
+ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
+ = render 'form', { f: f }
+ .form-actions
+ = f.submit 'Save Changes', class: "btn btn-save"
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index e1477c71d06..5a397c9d3c7 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -1,6 +1,10 @@
+- add_to_breadcrumbs "Pages", project_pages_path(@project)
- page_title 'New Pages Domain'
%h3.page_title
New Pages Domain
%hr.clearfix
%div
- = render 'form'
+ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
+ = render 'form', { f: f }
+ .form-actions
+ = f.submit 'Create New Domain', class: "btn btn-save"
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 876cac0dacb..ba0713daee9 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -1,7 +1,17 @@
+- add_to_breadcrumbs "Pages", project_pages_path(@project)
+- breadcrumb_title @domain.domain
- page_title "#{@domain.domain}", 'Pages Domains'
+- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
+- if verification_enabled && @domain.unverified?
+ %p.alert.alert-warning
+ %strong
+ This domain is not verified. You will need to verify ownership before
+ access is enabled.
+
%h3.page-title
Pages Domain
+ = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right'
.table-holder
%table.table
@@ -15,9 +25,26 @@
DNS
%td
%p
- To access the domain create a new DNS record:
+ To access this domain create a new DNS record:
%pre
#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}.
+ - if verification_enabled
+ %tr
+ %td
+ Verification status
+ %td
+ %p
+ - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ To #{link_to 'verify ownership', help_link} of your domain, create
+ this DNS record:
+ %pre
+ #{@domain.verification_domain} TXT #{@domain.keyed_verification_code}
+ %p
+ - if @domain.verified?
+ #{@domain.domain} has been successfully verified.
+ - else
+ = button_to 'Verify ownership', verify_project_pages_domain_path(@project, @domain), class: 'btn btn-save btn-sm'
+
%tr
%td
Certificate
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index fc7fa5c1876..160e325996a 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -1,7 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'schedule_form'
-
= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f|
= form_errors(@schedule)
.form-group
@@ -22,6 +18,20 @@
= f.label :ref, _('Target Branch'), class: 'label-light'
= dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
+ .form-group.js-ci-variable-list-section
+ .col-md-9
+ %label.label-light
+ #{ s_('PipelineSchedules|Variables') }
+ %ul.ci-variable-list
+ - @schedule.variables.each do |variable|
+ = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true
+ = render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true
+ - if @schedule.variables.size > 0
+ %button.btn.btn-info.btn-inverted.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } }
+ - if @schedule.variables.size == 0
+ = n_('Hide value', 'Hide values', @schedule.variables.size)
+ - else
+ = n_('Reveal value', 'Reveal values', @schedule.variables.size)
.form-group
.col-md-9
= f.label :active, s_('PipelineSchedules|Activated'), class: 'label-light'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 966d6cd8495..55d0e8bb7f9 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -3,30 +3,33 @@
%td
= pipeline_schedule.description
%td.branch-name-cell
- = icon('code-fork')
- - if pipeline_schedule.ref
+ = sprite_icon('fork', size: 12)
+ - if pipeline_schedule.ref.present?
= 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}" }
- = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline_schedule.last_pipeline.id) do
+ = link_to project_pipeline_path(@project, pipeline_schedule.last_pipeline.id) do
= ci_icon_for_status(pipeline_schedule.last_pipeline.status)
%span ##{pipeline_schedule.last_pipeline.id}
- else
= s_("PipelineSchedules|None")
%td.next-run-cell
- - if pipeline_schedule.active?
+ - if pipeline_schedule.active? && pipeline_schedule.next_run_at
= time_ago_with_tooltip(pipeline_schedule.real_next_run)
- else
= s_("PipelineSchedules|Inactive")
%td
- if pipeline_schedule.owner
- = image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20"
+ = image_tag avatar_icon_for_user(pipeline_schedule.owner, 20), class: "avatar s20"
= link_to user_path(pipeline_schedule.owner) do
= pipeline_schedule.owner&.name
%td
.pull-right.btn-group
- - if can?(current_user, :update_pipeline_schedule, @project) && !pipeline_schedule.owned_by?(current_user)
+ - if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
+ = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do
+ = icon('play')
+ - if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule)
= link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do
= s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml
index 7fcb624a9dd..8996c1b3e38 100644
--- a/app/views/projects/pipeline_schedules/_tabs.html.haml
+++ b/app/views/projects/pipeline_schedules/_tabs.html.haml
@@ -1,4 +1,4 @@
-%ul.nav-links
+%ul.nav-links.mobile-separator
%li{ class: active_when(scope.nil?) }>
= link_to schedule_path_proc.call(nil) do
= s_("PipelineSchedules|All")
diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml
index 9b2a7b5821d..d95fa6da903 100644
--- a/app/views/projects/pipeline_schedules/edit.html.haml
+++ b/app/views/projects/pipeline_schedules/edit.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs _("Schedules"), pipeline_schedules_path(@project)
+- breadcrumb_title "##{@schedule.id}"
- page_title _("Edit"), @schedule.description, _("Pipeline Schedule")
%h3.page-title
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index c296152e54f..bcb6dddba1a 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -1,10 +1,7 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'schedules_index'
+- breadcrumb_title _("Schedules")
- @no_container = true
- page_title _("Pipeline Schedules")
-= render "projects/pipelines/head"
%div{ class: container_class }
#pipeline-schedules-callout{ data: { docs_url: help_page_path('user/project/pipelines/schedules') } }
@@ -12,9 +9,10 @@
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
- .nav-controls
- = link_to new_namespace_project_pipeline_schedule_path(@project.namespace, @project), class: 'btn btn-create' do
- %span= _('New schedule')
+ - if can?(current_user, :create_pipeline_schedule, @project)
+ .nav-controls
+ = link_to new_project_pipeline_schedule_path(@project), class: 'btn btn-create' do
+ %span= _('New schedule')
- if @schedules.present?
%ul.content-list
diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml
index 87390d4dd02..cfdaf6d43bb 100644
--- a/app/views/projects/pipeline_schedules/new.html.haml
+++ b/app/views/projects/pipeline_schedules/new.html.haml
@@ -1,5 +1,9 @@
+- breadcrumb_title "Schedules"
+- @breadcrumb_link = namespace_project_pipeline_schedules_path(@project.namespace, @project)
- page_title _("New Pipeline Schedule")
+- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
+
%h3.page-title
= _("Schedule a new pipeline")
%hr
diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml
deleted file mode 100644
index d2f0cb0806f..00000000000
--- a/app/views/projects/pipelines/_head.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: (container_class) }
- - if project_nav_tab? :pipelines
- = nav_link(path: ['pipelines#index', 'pipelines#show']) do
- = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
- %span
- Pipelines
-
- - if project_nav_tab? :builds
- = nav_link(controller: [:jobs, :artifacts]) do
- = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
- %span
- Jobs
-
- - if project_nav_tab? :pipelines
- = nav_link(controller: :pipeline_schedules) do
- = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do
- %span
- Schedules
-
- - if project_nav_tab? :environments
- = nav_link(controller: :environments) do
- = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do
- %span
- Environments
-
- - if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
- = nav_link(path: 'pipelines#charts') do
- = link_to charts_namespace_project_pipelines_path(@project.namespace, @project), title: 'Charts', class: 'shortcuts-pipelines-charts' do
- %span
- Charts
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 673c3370b62..85946aec1f2 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,6 +1,6 @@
#js-pipeline-header-vue.pipeline-header-container
-- if @commit
+- if @commit.present?
.commit-box
%h3.commit-title
= markdown(@commit.title, pipeline: :single_line)
@@ -8,28 +8,28 @@
%pre.commit-description
= preserve(markdown(@commit.description, pipeline: :single_line))
-.info-well
- - if @commit.status
- .well-segment.pipeline-info
- .icon-container
- = icon('clock-o')
- = pluralize @pipeline.statuses.count(:id), "job"
- - if @pipeline.ref
- from
- = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
- - if @pipeline.duration
- in
- = time_interval_in_words(@pipeline.duration)
- - if @pipeline.queued_duration
- = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
+ .info-well
+ - if @commit.status
+ .well-segment.pipeline-info
+ .icon-container
+ = icon('clock-o')
+ = pluralize @pipeline.total_size, "job"
+ - if @pipeline.ref
+ from
+ = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
+ - if @pipeline.duration
+ in
+ = time_interval_in_words(@pipeline.duration)
+ - if @pipeline.queued_duration
+ = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
- .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: "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: "commit-sha commit-hash-full"
- = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
+ .well-segment.branch-info
+ .icon-container.commit-icon
+ = custom_icon("icon_commit")
+ = link_to @commit.short_id, project_commit_path(@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, project_commit_path(@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 85550e8fd32..852143ecb2a 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,18 +1,18 @@
- failed_builds = @pipeline.statuses.latest.failed
.tabs-holder
- %ul.pipelines-tabs.nav-links.no-top.no-bottom
+ %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator
%li.js-pipeline-tab-link
- = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: { target: 'div#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
- Pipeline
+ = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
+ = _("Pipeline")
%li.js-builds-tab-link
- = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
- Jobs
- %span.badge.js-builds-counter= pipeline.statuses.count
+ = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
+ = _("Jobs")
+ %span.badge.js-builds-counter= pipeline.total_size
- if failed_builds.present?
%li.js-failures-tab-link
- = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
- Failed Jobs
+ = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
+ = _("Failed Jobs")
%span.badge.js-failures-counter= failed_builds.count
.tab-content
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 8ffddfe6154..a86cb14960a 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,14 +1,11 @@
- @no_container = true
-- page_title "Charts", "Pipelines"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('graphs')
-= render 'head'
+- breadcrumb_title "CI / CD Charts"
+- page_title _("Charts"), _("Pipelines")
%div{ class: container_class }
.sub-header-block
.oneline
- A collection of graphs for Continuous Integration
+ = _("A collection of graphs regarding Continuous Integration")
#charts.ci-charts
.row
diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml
index 93083397d5b..66786c7ff59 100644
--- a/app/views/projects/pipelines/charts/_overall.haml
+++ b/app/views/projects/pipelines/charts/_overall.haml
@@ -1,15 +1,15 @@
-%h4 Overall stats
+%h4= s_("PipelineCharts|Overall statistics")
%ul
%li
- Total:
- %strong= pluralize @counts[:total], 'pipeline'
+ = s_("PipelineCharts|Total:")
+ %strong= n_("1 pipeline", "%d pipelines", @counts[:total]) % @counts[:total]
%li
- Successful:
- %strong= pluralize @counts[:success], 'pipeline'
+ = s_("PipelineCharts|Successful:")
+ %strong= n_("1 pipeline", "%d pipelines", @counts[:success]) % @counts[:success]
%li
- Failed:
- %strong= pluralize @counts[:failed], 'pipeline'
+ = s_("PipelineCharts|Failed:")
+ %strong= n_("1 pipeline", "%d pipelines", @counts[:failed]) % @counts[:failed]
%li
- Success ratio:
+ = s_("PipelineCharts|Success ratio:")
%strong
#{success_ratio(@counts)}%
diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml
index aee7c5492aa..c23fe6ff170 100644
--- a/app/views/projects/pipelines/charts/_pipeline_times.haml
+++ b/app/views/projects/pipelines/charts/_pipeline_times.haml
@@ -1,27 +1,8 @@
%div
%p.light
- Commit duration in minutes for last 30 commits
+ = _("Commit duration in minutes for last 30 commits")
%canvas#build_timesChart{ height: 200 }
-:javascript
- var data = {
- labels : #{@charts[:pipeline_times].labels.to_json},
- datasets : [
- {
- fillColor : "rgba(220,220,220,0.5)",
- strokeColor : "rgba(220,220,220,1)",
- barStrokeWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
- data : #{@charts[:pipeline_times].pipeline_times.to_json}
- }
- ]
- }
- var ctx = $("#build_timesChart").get(0).getContext("2d");
- var options = { scaleOverlay: true, responsive: true, maintainAspectRatio: false };
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8
- }
- new Chart(ctx).Bar(data, options);
+-# haml-lint:disable InlineJavaScript
+%script#pipelinesTimesChartsData{ type: "application/json" }= { :labels => @charts[:pipeline_times].labels, :values => @charts[:pipeline_times].pipeline_times }.to_json.html_safe
diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml
index b6f453b9736..14b3d47a9c2 100644
--- a/app/views/projects/pipelines/charts/_pipelines.haml
+++ b/app/views/projects/pipelines/charts/_pipelines.haml
@@ -1,56 +1,34 @@
-%h4 Pipelines charts
+%h4= _("Pipelines charts")
%p
&nbsp;
- %span.cgreen
+ %span.legend-success
= icon("circle")
- success
+ = s_("Pipeline|success")
&nbsp;
- %span.cgray
+ %span.legend-all
= icon("circle")
- all
+ = s_("Pipeline|all")
.prepend-top-default
%p.light
- Jobs for last week
+ = _("Pipelines for last week")
(#{date_from_to(Date.today - 7.days, Date.today)})
%canvas#weekChart{ height: 200 }
.prepend-top-default
%p.light
- Jobs for last month
+ = _("Pipelines for last month")
(#{date_from_to(Date.today - 30.days, Date.today)})
%canvas#monthChart{ height: 200 }
.prepend-top-default
%p.light
- Jobs for last year
+ = _("Pipelines for last year")
%canvas#yearChart.padded{ height: 250 }
-- [:week, :month, :year].each do |scope|
- :javascript
- var data = {
- labels : #{@charts[scope].labels.to_json},
- datasets : [
- {
- fillColor : "#7f8fa4",
- strokeColor : "#7f8fa4",
- pointColor : "#7f8fa4",
- pointStrokeColor : "#EEE",
- data : #{@charts[scope].total.to_json}
- },
- {
- fillColor : "#44aa22",
- strokeColor : "#44aa22",
- pointColor : "#44aa22",
- pointStrokeColor : "#fff",
- data : #{@charts[scope].success.to_json}
- }
- ]
- }
- var ctx = $("##{scope}Chart").get(0).getContext("2d");
- var options = { scaleOverlay: true, responsive: true, maintainAspectRatio: false };
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8
- }
- new Chart(ctx).Line(data, options);
+-# haml-lint:disable InlineJavaScript
+%script#pipelinesChartsData{ type: "application/json" }
+ - chartData = []
+ - [:week, :month, :year].each do |scope|
+ - chartData.push({ 'scope' => scope, 'labels' => @charts[scope].labels, 'totalValues' => @charts[scope].total, 'successValues' => @charts[scope].success })
+ = chartData.to_json.html_safe
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 38237d2d97d..3e6b3346787 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,20 +1,15 @@
- @no_container = true
- page_title "Pipelines"
-= render "projects/pipelines/head"
-#pipelines-list-vue{ data: { endpoint: namespace_project_pipelines_path(@project.namespace, @project, format: :json),
- "css-class" => container_class,
- "help-page-path" => help_page_path('ci/quick_start/README'),
- "new-pipeline-path" => new_namespace_project_pipeline_path(@project.namespace, @project),
- "can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
- "all-path" => project_pipelines_path(@project),
- "pending-path" => project_pipelines_path(@project, scope: :pending),
- "running-path" => project_pipelines_path(@project, scope: :running),
- "finished-path" => project_pipelines_path(@project, scope: :finished),
- "branches-path" => project_pipelines_path(@project, scope: :branches),
- "tags-path" => project_pipelines_path(@project, scope: :tags),
- "has-ci" => @repository.gitlab_ci_yml,
- "ci-lint-path" => ci_lint_path } }
-
-= page_specific_javascript_bundle_tag('common_vue')
-= page_specific_javascript_bundle_tag('pipelines')
+%div{ 'class' => container_class }
+ #pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
+ "help-page-path" => help_page_path('ci/quick_start/README'),
+ "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
+ "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
+ "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
+ "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'),
+ "can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
+ "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project),
+ "ci-lint-path" => can?(current_user, :create_pipeline, @project) && ci_lint_path,
+ "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) ,
+ "has-gitlab-ci" => (@project.has_ci? && @project.builds_enabled?).to_s } }
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 71a8e490c3e..877101b05ca 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,10 +1,11 @@
+- breadcrumb_title "Pipelines"
- page_title "New Pipeline"
%h3.page-title
New Pipeline
%hr
-= form_for @pipeline, as: :pipeline, url: namespace_project_pipelines_path(@project.namespace, @project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f|
+= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f|
= form_errors(@pipeline)
.form-group
= f.label :ref, 'Create for', class: 'control-label'
@@ -17,9 +18,7 @@
.help-block Existing branch name, tag
.form-actions
= f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', namespace_project_pipelines_path(@project.namespace, @project), class: 'btn btn-cancel'
+ = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel'
-:javascript
- var availableRefs = #{@project.repository.ref_names.to_json};
-
- new NewBranchForm($('.js-new-pipeline-form'), availableRefs)
+-# haml-lint:disable InlineJavaScript
+%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index b39453a50fb..a7d7c923957 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -1,6 +1,7 @@
- @no_container = true
+- add_to_breadcrumbs "Pipelines", project_pipelines_path(@project)
+- breadcrumb_title "##{@pipeline.id}"
- page_title "Pipeline"
-= render "projects/pipelines/head"
.js-pipeline-container{ class: container_class, data: { controller_action: "#{controller.action_name}" } }
- if @commit
@@ -8,8 +9,4 @@
= 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')
+.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } }
diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml
index 3de518c8b9a..e8028059487 100644
--- a/app/views/projects/pipelines_settings/_badge.html.haml
+++ b/app/views/projects/pipelines_settings/_badge.html.haml
@@ -1,34 +1,32 @@
%div{ class: badge.title.gsub(' ', '-') }
- .col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ .col-lg-12
+ %h4
= badge.title.capitalize
- .col-lg-8
- .prepend-top-10
- .panel.panel-default
- .panel-heading
- %b
- = badge.title.capitalize
- &middot;
- = badge.to_html
- .pull-right
- = render 'shared/ref_switcher', destination: 'badges', align_right: true
- .panel-body
- .row
- .col-md-2.text-center
- Markdown
- .col-md-10.code.js-syntax-highlight
- = highlight('.md', badge.to_markdown)
- .row
- %hr
- .row
- .col-md-2.text-center
- HTML
- .col-md-10.code.js-syntax-highlight
- = highlight('.html', badge.to_html)
- .row
- %hr
- .row
- .col-md-2.text-center
- AsciiDoc
- .col-md-10.code.js-syntax-highlight
- = highlight('.adoc', badge.to_asciidoc)
+ .panel.panel-default
+ .panel-heading
+ %b
+ = badge.title.capitalize
+ &middot;
+ = badge.to_html
+ .pull-right
+ = render 'shared/ref_switcher', destination: 'badges', align_right: true
+ .panel-body
+ .row
+ .col-md-2.text-center
+ Markdown
+ .col-md-10.code.js-syntax-highlight
+ = highlight('.md', badge.to_markdown)
+ .row
+ %hr
+ .row
+ .col-md-2.text-center
+ HTML
+ .col-md-10.code.js-syntax-highlight
+ = highlight('.html', badge.to_html)
+ .row
+ %hr
+ .row
+ .col-md-2.text-center
+ AsciiDoc
+ .col-md-10.code.js-syntax-highlight
+ = highlight('.adoc', badge.to_asciidoc)
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index 580129ca809..646c01c0989 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -1,19 +1,53 @@
.row.prepend-top-default
- .col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
- Pipelines
- .col-lg-8
- = form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f|
+ .col-lg-12
+ = form_for @project, url: project_pipelines_settings_path(@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 Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
- %hr
- .form-group.append-bottom-default
+ .form-group
+ %h5 Auto DevOps (Beta)
+ %p
+ Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.
+ = link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md')
+ - message = auto_devops_warning_message(@project)
+ - if message
+ %p.settings-message.text-center
+ = message.html_safe
+ = f.fields_for :auto_devops_attributes, @auto_devops do |form|
+ .radio
+ = form.label :enabled_true do
+ = form.radio_button :enabled, 'true'
+ %strong Enable Auto DevOps
+ %br
+ %span.descr
+ The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project.
+
+ .radio
+ = form.label :enabled_false do
+ = form.radio_button :enabled, 'false'
+ %strong Disable Auto DevOps
+ %br
+ %span.descr
+ An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
+
+ .radio
+ = form.label :enabled_ do
+ = form.radio_button :enabled, ''
+ %strong Instance default (#{Gitlab::CurrentSettings.auto_devops_enabled? ? 'enabled' : 'disabled'})
+ %br
+ %span.descr
+ Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>.
+ %p
+ You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.
+ = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
+
+ %hr
+ .form-group.append-bottom-default.js-secret-runner-token
= f.label :runners_token, "Runner token", class: 'label-light'
- = f.text_field :runners_token, class: "form-control", placeholder: 'xEeFCaDAB89'
+ .form-control.js-secret-value-placeholder
+ = '*' * 20
+ = f.text_field :runners_token, class: "form-control hide js-secret-value", placeholder: 'xEeFCaDAB89'
%p.help-block The secure token used by the Runner to checkout the project
+ %button.btn.btn-info.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: 'false' } }
+ = _('Reveal value')
%hr
.form-group
@@ -47,13 +81,34 @@
%hr
.form-group
+ = f.label :ci_config_path, 'Custom CI config path', class: 'label-light'
+ = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
+ %p.help-block
+ The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank'
+
+ %hr
+ .form-group
.checkbox
= f.label :public_builds do
= f.check_box :public_builds
%strong Public pipelines
.help-block
- Allow everyone to access pipelines for public and internal projects
+ Allow public access to pipelines and job details, including output logs and artifacts
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
+ .bs-callout.bs-callout-info
+ %p If enabled:
+ %ul
+ %li
+ For public projects, anyone can view pipelines and access job details (output logs and artifacts)
+ %li
+ For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)
+ %li
+ For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)
+ %p
+ If disabled, the access level will depend on the user's
+ permissions in the project.
+
%hr
.form-group
.checkbox
diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml
deleted file mode 100644
index c7996077bc7..00000000000
--- a/app/views/projects/project_members/_group_members.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-.panel.panel-default
- .panel-heading
- Group members with access to
- %strong= @group.name
- %span.badge= members.size
- - if can?(current_user, :admin_group_member, @group)
- .controls
- = link_to 'Manage group members',
- group_group_members_path(@group),
- class: 'btn'
- %ul.content-list
- = render partial: 'shared/members/member',
- collection: members.limit(20),
- as: :member,
- locals: { show_controls: false }
- - if members.size > 20
- %li
- and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)}
diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml
deleted file mode 100644
index fa99610c0be..00000000000
--- a/app/views/projects/project_members/_index.html.haml
+++ /dev/null
@@ -1,40 +0,0 @@
-.row.prepend-top-default
- .col-lg-4.settings-sidebar
- %h4.prepend-top-0
- Project members
- - if can?(current_user, :admin_project_member, @project)
- %p
- You can add a new member to
- %strong= @project.name
- or share it with another group.
- - else
- %p
- Members can be added by project
- %i Masters
- or
- %i Owners
- .col-lg-8
- .light
- - if can?(current_user, :admin_project_member, @project)
- %ul.nav-links.project-member-tabs{ role: 'tablist' }
- %li.active{ role: 'presentation' }
- %a{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
- - if @project.allowed_to_share_with_group?
- %li{ role: 'presentation' }
- %a{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
-
- .tab-content.project-member-tab-content
- .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_member', tab_title: 'Add member'
- .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
-
- = render 'shared/members/requests', membership_source: @project, requesters: @requesters
- .clearfix
- %h5.member.existing-title
- Existing members and groups
- - if @group_links.any?
- = render 'projects/project_members/groups', group_links: @group_links
-
- = render 'projects/project_members/team', members: @project_members
- = paginate @project_members, theme: "gitlab"
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 8bf2246662a..bf5b11ea30c 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -1,6 +1,6 @@
.row
.col-sm-12
- = form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'users-project-form' } do |f|
+ = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f|
.form-group
= label_tag :user_ids, "Select members to invite", class: "label-light"
= users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
@@ -18,4 +18,4 @@
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
%i.clear-icon.js-clear-input
= f.submit "Add to project", class: "btn btn-create"
- = link_to "Import", import_namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-default", title: "Import members from another project"
+ = link_to "Import", import_project_project_members_path(@project), class: "btn btn-default", title: "Import members from another project"
diff --git a/app/views/projects/project_members/_new_shared_group.html.haml b/app/views/projects/project_members/_new_shared_group.html.haml
index 643569db646..c10ef648a8f 100644
--- a/app/views/projects/project_members/_new_shared_group.html.haml
+++ b/app/views/projects/project_members/_new_shared_group.html.haml
@@ -1,6 +1,6 @@
.row
.col-sm-12
- = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do
+ = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do
.form-group
= label_tag :link_group_id, "Select a group to share with", class: "label-light"
= groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true)
diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml
deleted file mode 100644
index 7902ddb1ae9..00000000000
--- a/app/views/projects/project_members/_shared_group_members.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-- @project_group_links.each do |group_links|
- - shared_group = group_links.group
- - shared_group_members = shared_group.members
- - shared_group_users_count = shared_group_members.size
- .panel.panel-default
- .panel-heading
- Shared with
- %strong= shared_group.name
- group, members with
- %strong= group_links.human_access
- role (#{shared_group_users_count})
- - if can?(current_user, :admin_group, shared_group)
- .panel-head-actions
- = link_to group_group_members_path(shared_group), class: 'btn btn-sm' do
- %i.fa.fa-pencil-square-o
- Edit group members
- %ul.content-list
- = render partial: 'shared/members/member',
- collection: shared_group_members.order(access_level: :desc).limit(20),
- as: :member,
- locals: { show_controls: false, show_roles: false }
- - if shared_group_users_count > 20
- %li
- and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)}
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index 7b1a26043e1..16bcf671c25 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -1,15 +1,17 @@
+- project = local_assigns.fetch(:project)
+- members = local_assigns.fetch(:members)
+
.panel.panel-default
.panel-heading.flex-project-members-panel
%span.flex-project-title
Members of
- %strong
- #{@project.name}
- %span.badge= @project_members.total_count
- = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
+ %strong= project.name
+ %span.badge= members.total_count
+ = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
%button.member-search-btn{ type: "submit", "aria-label" => "Submit search" }
= icon("search")
= render 'shared/members/sort_dropdown'
- %ul.content-list
+ %ul.content-list.members-list
= render partial: 'shared/members/member', collection: members, as: :member
diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml
index 42ce4f8001b..755128af565 100644
--- a/app/views/projects/project_members/import.html.haml
+++ b/app/views/projects/project_members/import.html.haml
@@ -5,11 +5,11 @@
%p.light
Only project members will be imported. Group members will be skipped.
%hr
-= form_tag apply_import_namespace_project_project_members_path(@project.namespace, @project), method: 'post', class: 'form-horizontal' do
+= form_tag apply_import_project_project_members_path(@project), method: 'post', class: 'form-horizontal' do
.form-group
= label_tag :source_project_id, "Project", class: 'control-label'
- .col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(current_user.authorized_projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
+ .col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
.form-actions
= button_tag 'Import project members', class: "btn btn-create"
- = link_to "Cancel", namespace_project_settings_members_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to "Cancel", project_project_members_path(@project), class: "btn btn-cancel"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
new file mode 100644
index 00000000000..d81103c3a92
--- /dev/null
+++ b/app/views/projects/project_members/index.html.haml
@@ -0,0 +1,41 @@
+- page_title "Members"
+
+.row.prepend-top-default
+ .col-lg-12
+ %h4
+ Project members
+ - if can?(current_user, :admin_project_member, @project)
+ %p
+ You can add a new member to
+ %strong= @project.name
+ or share it with another group.
+ - else
+ %p
+ Members can be added by project
+ %i Masters
+ or
+ %i Owners
+ .light
+ - if can?(current_user, :admin_project_member, @project)
+ %ul.nav-links.gitlab-tabs{ role: 'tablist' }
+ %li.active{ role: 'presentation' }
+ %a{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
+ - if @project.allowed_to_share_with_group?
+ %li{ role: 'presentation' }
+ %a{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
+
+ .tab-content.gitlab-tab-content
+ .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_member', tab_title: 'Add member'
+ .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
+
+ = render 'shared/members/requests', membership_source: @project, requesters: @requesters
+ .clearfix
+ %h5.member.existing-title
+ Existing members and groups
+ - if @group_links.any?
+ = render 'projects/project_members/groups', group_links: @group_links
+
+ = render 'projects/project_members/team', project: @project, members: @project_members
+ = paginate @project_members, theme: "gitlab"
diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml
deleted file mode 100644
index d15f4310ff5..00000000000
--- a/app/views/projects/project_members/update.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/member', member: @project_member))}');
- $("##{dom_id(@project_member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(@project_member)}"));
diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml
index cf0db943865..5377d745371 100644
--- a/app/views/projects/protected_branches/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/_branches_list.html.haml
@@ -1,28 +1,4 @@
-.panel.panel-default.protected-branches-list
- - if @protected_branches.empty?
- .panel-heading
- %h3.panel-title
- Protected branch (#{@protected_branches.size})
- %p.settings-message.text-center
- There are currently no protected branches, protect a branch with the form above.
- - else
- - can_admin_project = can?(current_user, :admin_project, @project)
+- can_admin_project = can?(current_user, :admin_project, @project)
- %table.table.table-bordered
- %colgroup
- %col{ width: "25%" }
- %col{ width: "30%" }
- %col{ width: "25%" }
- %col{ width: "20%" }
- %thead
- %tr
- %th Protected branch (#{@protected_branches.size})
- %th Last commit
- %th Allowed to merge
- %th Allowed to push
- - if can_admin_project
- %th
- %tbody
- = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project}
-
- = paginate @protected_branches, theme: 'gitlab'
+= render layout: 'projects/protected_branches/shared/branches_list', locals: { can_admin_project: can_admin_project } do
+ = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project}
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index 99bc2516366..98d56a3e5c5 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -1,41 +1,14 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f|
- .panel.panel-default
- .panel-heading
- %h3.panel-title
- Protect a branch
- .panel-body
- .form-horizontal
- = form_errors(@protected_branch)
- .form-group
- = f.label :name, class: 'col-md-2 text-right' do
- Branch:
- .col-md-10
- = render partial: "projects/protected_branches/dropdown", locals: { f: f }
- .help-block
- = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
- such as
- %code *-stable
- or
- %code production/*
- are supported
- .form-group
- %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
- Allowed to merge:
- .col-md-10
- .merge_access_levels-container
- = dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-merge wide',
- dropdown_class: 'dropdown-menu-selectable capitalize-header',
- data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
- .form-group
- %label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
- Allowed to push:
- .col-md-10
- .push_access_levels-container
- = dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-push wide',
- dropdown_class: 'dropdown-menu-selectable capitalize-header',
- data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
+- content_for :merge_access_levels do
+ .merge_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-merge wide',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
+- content_for :push_access_levels do
+ .push_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-push wide',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
- .panel-footer
- = f.submit 'Protect', class: 'btn-create btn', disabled: true
+= render 'projects/protected_branches/shared/create_protected_branch'
diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml
deleted file mode 100644
index 6e9c473494e..00000000000
--- a/app/views/projects/protected_branches/_dropdown.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-= f.hidden_field(:name)
-
-= dropdown_tag('Select branch or create wildcard',
- 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],
- project_id: @project.try(:id) } }) do
-
- %ul.dropdown-footer-list
- %li
- %button{ class: "create-new-protected-branch-button js-create-new-protected-branch", title: "New Protected Branch" }
- Create wildcard
- %code
diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml
index 5d2422bdf54..2b0a502fe4d 100644
--- a/app/views/projects/protected_branches/_index.html.haml
+++ b/app/views/projects/protected_branches/_index.html.haml
@@ -1,26 +1,7 @@
-- expanded = Rails.env.test?
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('protected_branches')
+- content_for :create_protected_branch do
+ = render 'projects/protected_branches/create_protected_branch'
-%section.settings
- .settings-header
- %h4
- Protected Branches
- %button.btn.js-settings-toggle
- = expanded ? 'Collapse' : '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 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"}.
+- content_for :branches_list do
+ = render "projects/protected_branches/branches_list"
- - if can? current_user, :admin_project, @project
- = render 'projects/protected_branches/create_protected_branch'
-
- = render "projects/protected_branches/branches_list"
+= render 'projects/protected_branches/shared/index'
diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml
deleted file mode 100644
index 27896272733..00000000000
--- a/app/views/projects/protected_branches/_matching_branch.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%tr
- %td
- = 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-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 0f80de94392..b12ae995ece 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -1,22 +1,2 @@
-%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
- %td
- %span.ref-name= protected_branch.name
-
- - if @project.root_ref?(protected_branch.name)
- %span.label.label-info.prepend-left-5 default
- %td
- - if protected_branch.wildcard?
- - matching_branches = protected_branch.matching(repository.branches)
- = 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-sha')
- = time_ago_with_tooltip(commit.committed_date)
- - else
- (branch was removed from repository)
-
+= render layout: 'projects/protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do
= render partial: 'projects/protected_branches/update_protected_branch', locals: { protected_branch: protected_branch }
-
- - if can_admin_project
- %td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
new file mode 100644
index 00000000000..2a0704bc7af
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -0,0 +1,30 @@
+.panel.panel-default.protected-branches-list.js-protected-branches-list
+ - if @protected_branches.empty?
+ .panel-heading
+ %h3.panel-title
+ Protected branch (#{@protected_branches.size})
+ %p.settings-message.text-center
+ There are currently no protected branches, protect a branch with the form above.
+ - else
+ %table.table.table-bordered
+ %colgroup
+ %col{ width: "20%" }
+ %col{ width: "20%" }
+ %col{ width: "20%" }
+ %col{ width: "20%" }
+ - if can_admin_project
+ %col
+ %thead
+ %tr
+ %th Protected branch (#{@protected_branches.size})
+ %th Last commit
+ %th Allowed to merge
+ %th Allowed to push
+ - if can_admin_project
+ %th
+ %tbody
+ %tr
+ %td.flash-container{ colspan: 5 }
+ = yield
+
+ = paginate @protected_branches, theme: 'gitlab'
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
new file mode 100644
index 00000000000..9f0c4f3b3a8
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -0,0 +1,33 @@
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
+ .panel.panel-default
+ .panel-heading
+ %h3.panel-title
+ Protect a branch
+ .panel-body
+ .form-horizontal
+ = form_errors(@protected_branch)
+ .form-group
+ = f.label :name, class: 'col-md-2 text-right' do
+ Branch:
+ .col-md-10
+ = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
+ .help-block
+ = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
+ such as
+ %code *-stable
+ or
+ %code production/*
+ are supported
+ .form-group
+ %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
+ Allowed to merge:
+ .col-md-10
+ = yield :merge_access_levels
+ .form-group
+ %label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
+ Allowed to push:
+ .col-md-10
+ = yield :push_access_levels
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
new file mode 100644
index 00000000000..74435236808
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -0,0 +1,15 @@
+= f.hidden_field(:name)
+
+= dropdown_tag('Select branch or create wildcard',
+ 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],
+ project_id: @project.try(:id) } }) do
+
+ %ul.dropdown-footer-list
+ %li
+ %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: "New Protected Branch" }
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
new file mode 100644
index 00000000000..e662b877fbb
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -0,0 +1,24 @@
+- expanded = Rails.env.test?
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Protected Branches
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Keep stable branches secure and force developers to use merge requests.
+ .settings-content
+ %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 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches")} and #{link_to "project permissions", help_page_path("user/permissions")}.
+
+ - if can? current_user, :admin_project, @project
+ = content_for :create_protected_branch
+
+ = content_for :branches_list
diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
new file mode 100644
index 00000000000..98793d632e6
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
@@ -0,0 +1,10 @@
+%tr
+ %td
+ = 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, project_commit_path(@project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
new file mode 100644
index 00000000000..10b81e42572
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -0,0 +1,24 @@
+- can_admin_project = can?(current_user, :admin_project, @project)
+
+%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
+ %td
+ %span.ref-name= protected_branch.name
+
+ - if @project.root_ref?(protected_branch.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - if protected_branch.wildcard?
+ - matching_branches = protected_branch.matching(repository.branches)
+ = 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-sha')
+ = time_ago_with_tooltip(commit.committed_date)
+ - else
+ (branch was removed from repository)
+
+ = yield
+
+ - if can_admin_project
+ %td
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index a806a0756ec..1012ceefe93 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -19,7 +19,7 @@
%th Last commit
%tbody
- @matching_refs.each do |matching_branch|
- = render partial: "matching_branch", object: matching_branch
+ = render partial: "projects/protected_branches/shared/matching_branch", object: matching_branch
- else
%p.settings-message.text-center
Couldn't find any matching branches.
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 dd5b346d922..f53b81cada6 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -1,32 +1,8 @@
-= 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
- Protect a tag
- .panel-body
- .form-horizontal
- = form_errors(@protected_tag)
- .form-group
- = f.label :name, class: 'col-md-2 text-right' do
- Tag:
- .col-md-10.protected-tags-dropdown
- = render partial: "projects/protected_tags/dropdown", locals: { f: f }
- .help-block
- = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
- such as
- %code v*
- or
- %code *-release
- are supported
- .form-group
- %label.col-md-2.text-right{ for: 'create_access_levels_attributes' }
- Allowed to create:
- .col-md-10
- .create_access_levels-container
- = dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-create wide',
- dropdown_class: 'dropdown-menu-selectable',
- data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
+- content_for :create_access_levels do
+ .create_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-create wide',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
- .panel-footer
- = f.submit 'Protect', class: 'btn-create btn', disabled: true
+= render 'projects/protected_tags/shared/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
deleted file mode 100644
index 9b6923210f7..00000000000
--- a/app/views/projects/protected_tags/_dropdown.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-= f.hidden_field(:name)
-
-= dropdown_tag('Select tag or create wildcard',
- 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],
- project_id: @project.try(:id) } }) do
-
- %ul.dropdown-footer-list
- %li
- %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 8250f692a69..6b284fda35c 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -1,26 +1,7 @@
-- expanded = Rails.env.test?
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('protected_tags')
+- content_for :create_protected_tag do
+ = render 'projects/protected_tags/create_protected_tag'
-%section.settings
- .settings-header
- %h4
- Protected Tags
- %button.btn.js-settings-toggle
- = expanded ? 'Collapse' : '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
+- content_for :tag_list do
+ = render "projects/protected_tags/tags_list"
- %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'
-
- = render "projects/protected_tags/tags_list"
+= render 'projects/protected_tags/shared/index'
diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml
deleted file mode 100644
index f17353df122..00000000000
--- a/app/views/projects/protected_tags/_matching_tag.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%tr
- %td
- = 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-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 f11ce0483a9..da1f97c8d6a 100644
--- a/app/views/projects/protected_tags/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -1,22 +1,2 @@
-%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } }
- %td
- %span.ref-name= protected_tag.name
-
- - if @project.root_ref?(protected_tag.name)
- %span.label.label-info.prepend-left-5 default
- %td
- - if protected_tag.wildcard?
- - matching_tags = protected_tag.matching(repository.tags)
- = 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-sha')
- = time_ago_with_tooltip(commit.committed_date)
- - else
- (tag was removed from repository)
-
+= render layout: 'projects/protected_tags/shared/protected_tag', locals: { protected_tag: protected_tag } do
= render partial: 'projects/protected_tags/update_protected_tag', locals: { protected_tag: protected_tag }
-
- - 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'
diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml
index d432a5c9113..a6b18cc9f8f 100644
--- a/app/views/projects/protected_tags/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -1,30 +1,4 @@
-.panel.panel-default.protected-tags-list
- - if @protected_tags.empty?
- .panel-heading
- %h3.panel-title
- Protected tag (#{@protected_tags.size})
- %p.settings-message.text-center
- There are currently no protected tags, protect a tag with the form above.
- - else
- - can_admin_project = can?(current_user, :admin_project, @project)
+- can_admin_project = can?(current_user, :admin_project, @project)
- %table.table.table-bordered
- %colgroup
- %col{ width: "25%" }
- %col{ width: "25%" }
- %col{ width: "50%" }
- - if can_admin_project
- %col
- %thead
- %tr
- %th Protected tag (#{@protected_tags.size})
- %th Last commit
- %th Allowed to create
- - if can_admin_project
- %th
- %tbody
- %tr
- %td.flash-container{ colspan: 4 }
- = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project}
-
- = paginate @protected_tags, theme: 'gitlab'
+= render layout: 'projects/protected_tags/shared/tags_list' do
+ = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project}
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
new file mode 100644
index 00000000000..5a53c704fcb
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -0,0 +1,29 @@
+= 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
+ Protect a tag
+ .panel-body
+ .form-horizontal
+ = form_errors(@protected_tag)
+ .form-group
+ = f.label :name, class: 'col-md-2 text-right' do
+ Tag:
+ .col-md-10.protected-tags-dropdown
+ = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f }
+ .help-block
+ = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
+ such as
+ %code v*
+ or
+ %code *-release
+ are supported
+ .form-group
+ %label.col-md-2.text-right{ for: 'create_access_levels_attributes' }
+ Allowed to create:
+ .col-md-10
+ .create_access_levels-container
+ = yield :create_access_levels
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
new file mode 100644
index 00000000000..f0d7dcccd36
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml
@@ -0,0 +1,15 @@
+= f.hidden_field(:name)
+
+= dropdown_tag('Select tag or create wildcard',
+ 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],
+ project_id: @project.try(:id) } }) do
+
+ %ul.dropdown-footer-list
+ %li
+ %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: "New Protected Tag" }
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
new file mode 100644
index 00000000000..24baf1cfc89
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -0,0 +1,24 @@
+- expanded = Rails.env.test?
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Protected Tags
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Limit access to creating and updating tags.
+ .settings-content
+ %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
+
+ %p Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags")}.
+
+ - if can? current_user, :admin_project, @project
+ = yield :create_protected_tag
+
+ = yield :tag_list
diff --git a/app/views/projects/protected_tags/shared/_matching_tag.html.haml b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
new file mode 100644
index 00000000000..05f102d1ca3
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
@@ -0,0 +1,10 @@
+%tr
+ %td
+ = 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, project_commit_path(@project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
new file mode 100644
index 00000000000..c778f7b9781
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -0,0 +1,22 @@
+%tr.js-protected-tag-edit-form{ data: { url: project_protected_tag_path(@project, protected_tag) } }
+ %td
+ %span.ref-name= protected_tag.name
+
+ - if @project.root_ref?(protected_tag.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - if protected_tag.wildcard?
+ - matching_tags = protected_tag.matching(repository.tags)
+ = link_to pluralize(matching_tags.count, "matching tag"), project_protected_tag_path(@project, protected_tag)
+ - else
+ - if commit = protected_tag.commit
+ = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
+ - else
+ (tag was removed from repository)
+
+ = yield
+
+ - if can? current_user, :admin_project, @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'
diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml
new file mode 100644
index 00000000000..3f42ae58438
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml
@@ -0,0 +1,30 @@
+.panel.panel-default.protected-tags-list.js-protected-tags-list
+ - if @protected_tags.empty?
+ .panel-heading
+ %h3.panel-title
+ Protected tag (#{@protected_tags.size})
+ %p.settings-message.text-center
+ There are currently no protected tags, protect a tag with the form above.
+ - else
+ - can_admin_project = can?(current_user, :admin_project, @project)
+
+ %table.table.table-bordered
+ %colgroup
+ %col{ width: "25%" }
+ %col{ width: "25%" }
+ %col{ width: "50%" }
+ - if can_admin_project
+ %col
+ %thead
+ %tr
+ %th Protected tag (#{@protected_tags.size})
+ %th Last commit
+ %th Allowed to create
+ - if can_admin_project
+ %th
+ %tbody
+ %tr
+ %td.flash-container{ colspan: 4 }
+ = yield
+
+ = paginate @protected_tags, theme: 'gitlab'
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 16fc02fe9f4..86629f1753b 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -19,7 +19,7 @@
%th Last commit
%tbody
- @matching_refs.each do |matching_tag|
- = render partial: "matching_tag", object: matching_tag
+ = render partial: "projects/protected_tags/shared/matching_tag", object: matching_tag
- else
%p.settings-message.text-center
Couldn't find any matching tags.
diff --git a/app/views/projects/registry/repositories/_image.html.haml b/app/views/projects/registry/repositories/_image.html.haml
deleted file mode 100644
index dcdc432b654..00000000000
--- a/app/views/projects/registry/repositories/_image.html.haml
+++ /dev/null
@@ -1,33 +0,0 @@
-.container-image.js-toggle-container
- .container-image-head
- = link_to "#", class: "js-toggle-button" do
- = icon('chevron-down', 'aria-hidden': 'true')
- = escape_once(image.path)
-
- = clipboard_button(clipboard_text: "docker pull #{image.location}")
-
- - if can?(current_user, :update_container_image, @project)
- .controls.hidden-xs.pull-right
- = link_to namespace_project_container_registry_path(@project.namespace, @project, image),
- class: 'btn btn-remove has-tooltip',
- title: 'Remove repository',
- data: { confirm: 'Are you sure?' },
- method: :delete do
- = icon('trash cred', 'aria-hidden': 'true')
-
- .container-image-tags.js-toggle-content.hide
- - if image.has_tags?
- .table-holder
- %table.table.tags
- %thead
- %tr
- %th Tag
- %th Tag ID
- %th Size
- %th Created
- - if can?(current_user, :update_container_image, @project)
- %th
- = render partial: 'tag', collection: image.tags
- - else
- .nothing-here-block No tags in Container Registry for this container image.
-
diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
index 378a23f07e6..0b082a2137f 100644
--- a/app/views/projects/registry/repositories/_tag.html.haml
+++ b/app/views/projects/registry/repositories/_tag.html.haml
@@ -25,7 +25,7 @@
- if can?(current_user, :update_container_image, @project)
%td.content
.controls.hidden-xs.pull-right
- = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
+ = link_to project_registry_repository_tag_path(@project, tag.repository, tag.name),
method: :delete,
class: 'btn btn-remove has-tooltip',
title: 'Remove tag',
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 5661af01302..12d56e244ce 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,60 +1,46 @@
- page_title "Container Registry"
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0
+%section
+ .settings-header
+ %h4
= page_title
%p
- With the Docker Container Registry integrated into GitLab, every project
- can have its own space to store its Docker images.
+ = s_('ContainerRegistry|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'
+ = s_('ContainerRegistry|Learn more about')
+ = link_to _('Container Registry'), help_page_path('user/project/container_registry'), target: '_blank'
+ .row.registry-placeholder.prepend-bottom-10
+ .col-lg-12
+ #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } }
- .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
- %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)}
-
- %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
-
- - 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
+ .row.prepend-top-10
+ .col-lg-12
+ .panel.panel-default
+ .panel-heading
+ %h4.panel-title
+ = s_('ContainerRegistry|How to use the Container Registry')
+ .panel-body
+ %p
+ - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank')
+ - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank')
+ = s_('ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:').html_safe % { link_2fa: link_2fa, link_token: link_token }
+ %pre
+ docker login #{Gitlab.config.registry.host_port}
+ %br
+ %p
+ = s_('ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe }
+ %pre
+ :plain
+ docker build -t #{escape_once(@project.container_registry_url)} .
+ docker push #{escape_once(@project.container_registry_url)}
+ %hr
+ %h5.prepend-top-default
+ = s_('ContainerRegistry|Use different image names')
+ %p.light
+ = s_('ContainerRegistry|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
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml
index 93ee9382a6e..4d962f9433f 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/releases/edit.html.haml
@@ -1,6 +1,7 @@
- @no_container = true
+- add_to_breadcrumbs "Tags", project_tags_path(@project)
+- breadcrumb_title @tag.name
- page_title "Edit", @tag.name, "Tags"
-= render "projects/commits/head"
%div{ class: container_class }
.sub-header-block.no-bottom-space
@@ -10,11 +11,11 @@
%strong= @tag.name
- = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
+ = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
= render 'shared/notes/hints'
.error-alert
.prepend-top-default
= f.submit 'Save changes', class: 'btn btn-save'
- = link_to "Cancel", namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-default btn-cancel"
+ = link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn btn-default btn-cancel"
diff --git a/app/views/projects/remove_fork.js.haml b/app/views/projects/remove_fork.js.haml
index 17b9fecfeb1..6d083c5c516 100644
--- a/app/views/projects/remove_fork.js.haml
+++ b/app/views/projects/remove_fork.js.haml
@@ -1,2 +1,2 @@
:plain
- location.href = "#{edit_namespace_project_path(@project.namespace, @project)}";
+ location.href = "#{edit_project_path(@project)}";
diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml
index d9c39fb87b7..87895a15239 100644
--- a/app/views/projects/repositories/_feed.html.haml
+++ b/app/views/projects/repositories/_feed.html.haml
@@ -1,7 +1,7 @@
- commit = update
%tr
%td
- = link_to namespace_project_commits_path(@project.namespace, @project, commit.head.name) do
+ = link_to project_commits_path(@project, commit.head.name) do
%strong
= commit.head.name
- if @project.root_ref?(commit.head.name)
@@ -9,9 +9,9 @@
%td
%div
- = link_to namespace_project_commits_path(@project.namespace, @project, commit.id) do
+ = link_to project_commits_path(@project, commit.id) do
%code= commit.short_id
- = image_tag avatar_icon(commit.author_email), class: "", width: 16, alt: ''
+ = image_tag avatar_icon_for_email(commit.author_email), class: "", width: 16, alt: ''
= markdown(truncate(commit.title, length: 40), pipeline: :single_line, author: commit.author)
%td
%span.pull-right.cgray
diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml
index 2ef1f98ba48..49c90869146 100644
--- a/app/views/projects/runners/_form.html.haml
+++ b/app/views/projects/runners/_form.html.haml
@@ -7,6 +7,12 @@
= f.check_box :active
%span.light Paused Runners don't accept new jobs
.form-group
+ = label :protected, "Protected", class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.check_box :access_level, {}, 'ref_protected', 'not_protected'
+ %span.light This runner will only run on pipelines triggered on protected branches
+ .form-group
= label :run_untagged, 'Run untagged jobs', class: 'control-label'
.col-sm-10
.checkbox
@@ -24,6 +30,11 @@
.col-sm-10
= f.text_field :token, class: 'form-control', readonly: true
.form-group
+ = label_tag :ip_address, class: 'control-label' do
+ IP Address
+ .col-sm-10
+ = f.text_field :ip_address, class: 'form-control', readonly: true
+ .form-group
= label_tag :description, class: 'control-label' do
Description
.col-sm-10
@@ -33,6 +44,6 @@
Tags
.col-sm-10
= f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control'
- .help-block You can setup jobs to only use Runners with specific tags
+ .help-block You can setup jobs to only use Runners with specific tags. Separate tags with commas.
.form-actions
= f.submit 'Save changes', class: 'btn btn-save'
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 674f87e8220..6376496ee1a 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -8,8 +8,8 @@
- 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
+ %small.edit-runner
+ = link_to edit_project_runner_path(@project, runner) do
%i.fa.fa-edit.btn
- else
%span.commit-sha
@@ -17,11 +17,15 @@
.pull-right
- if @project_runners.include?(runner)
+ - if runner.active?
+ = link_to 'Pause', pause_project_runner_path(@project, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: "Are you sure?" }
+ - else
+ = link_to 'Resume', resume_project_runner_path(@project, runner), method: :post, class: 'btn btn-success btn-sm'
- if runner.belongs_to_one_project?
= link_to 'Remove Runner', runner_path(runner), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- else
- runner_project = @project.runner_projects.find_by(runner_id: runner)
- = link_to 'Disable for this project', namespace_project_runner_project_path(@project.namespace, @project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
+ = link_to 'Disable for this project', project_runner_project_path(@project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- elsif runner.specific?
= form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f|
= f.hidden_field :runner_id, value: runner.id
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index 0671dd66e78..4fd4ca355a8 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -1,18 +1,18 @@
%h3 Shared Runners
-.bs-callout.bs-callout-warning.shared-runners-description
- - if current_application_settings.shared_runners_text.present?
- = markdown_field(current_application_settings, :shared_runners_text)
+.bs-callout.shared-runners-description
+ - if Gitlab::CurrentSettings.shared_runners_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text)
- else
GitLab Shared Runners execute code of different projects on the same Runner
unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is
on GitLab.com).
%hr
- if @project.shared_runners_enabled?
- = link_to toggle_shared_runners_namespace_project_runners_path(@project.namespace, @project), class: 'btn btn-warning', method: :post do
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
Disable shared Runners
- else
- = link_to toggle_shared_runners_namespace_project_runners_path(@project.namespace, @project), class: 'btn btn-success', method: :post do
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
Enable shared Runners
&nbsp; for this project
@@ -23,6 +23,3 @@
%h4.underlined-title Available shared Runners : #{@shared_runners_count}
%ul.bordered-list.available-shared-runners
= render partial: 'projects/runners/runner', collection: @shared_runners, as: :runner
- - if @shared_runners_count > 10
- .light
- and #{@shared_runners_count - 10} more...
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index f8835454140..f0813e56b71 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -1,21 +1,7 @@
%h3 Specific Runners
-.bs-callout.help-callout
- %h4 How to setup a specific Runner for a new project
-
- %ol
- %li
- Install a Runner compatible with GitLab CI
- (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= root_url(only_path: false)
- %li
- Use the following registration token during setup:
- %code= @project.runners_token
- %li
- Start the Runner!
-
+= render partial: 'ci/runner/how_to_setup_specific_runner',
+ locals: { registration_token: @project.runners_token }
- if @project_runners.any?
%h4.underlined-title Runners activated for this project
diff --git a/app/views/projects/runners/edit.html.haml b/app/views/projects/runners/edit.html.haml
index 95706888655..78dc4817ed7 100644
--- a/app/views/projects/runners/edit.html.haml
+++ b/app/views/projects/runners/edit.html.haml
@@ -3,4 +3,4 @@
%h4 Runner ##{@runner.id}
%hr
- = render 'form', runner: @runner, runner_form_url: runner_path(@runner)
+ = render 'form', runner: @runner, runner_form_url: runner_path(@runner)
diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml
index 49415ba557b..4e57f5f844d 100644
--- a/app/views/projects/runners/show.html.haml
+++ b/app/views/projects/runners/show.html.haml
@@ -20,6 +20,9 @@
%td Active
%td= @runner.active? ? 'Yes' : 'No'
%tr
+ %td Protected
+ %td= @runner.ref_protected? ? 'Yes' : 'No'
+ %tr
%td Can run untagged jobs
%td= @runner.run_untagged? ? 'Yes' : 'No'
%tr
@@ -38,6 +41,9 @@
%td Version
%td= @runner.version
%tr
+ %td IP Address
+ %td= @runner.ip_address
+ %tr
%td Revision
%td= @runner.revision
%tr
diff --git a/app/views/projects/services/_deprecated_message.html.haml b/app/views/projects/services/_deprecated_message.html.haml
new file mode 100644
index 00000000000..fea9506a4bb
--- /dev/null
+++ b/app/views/projects/services/_deprecated_message.html.haml
@@ -0,0 +1,3 @@
+.flash-container.flash-container-page
+ .flash-alert.deprecated-service
+ %span= @service.deprecation_message
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 6dffc026392..684b082efbb 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,6 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('integrations')
-
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
@@ -8,21 +5,17 @@
= boolean_to_icon @service.activated?
%p= @service.description
+
+ - if @service.respond_to?(:detailed_description)
+ %p= @service.detailed_description
.col-lg-9
- = 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|
+ = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form|
= render 'shared/service_settings', form: form, subject: @service
- .footer-block.row-content-block
- %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 'Cancel', namespace_project_settings_integrations_path(@project.namespace, @project), class: 'btn btn-cancel'
+ - if @service.editable?
+ .footer-block.row-content-block
+ = service_save_button(@service)
+ &nbsp;
+ = link_to 'Cancel', project_settings_integrations_path(@project), class: 'btn btn-cancel'
- if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true)
%hr
diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml
index 997b702da33..915c6b22162 100644
--- a/app/views/projects/services/_index.html.haml
+++ b/app/views/projects/services/_index.html.haml
@@ -21,7 +21,7 @@
%td{ "aria-label" => "#{service.title}: status " + (service.activated? ? "on" : "off") }
= boolean_to_icon service.activated?
%td
- = link_to edit_namespace_project_service_path(@project.namespace, @project, service.to_param) do
+ = link_to edit_project_service_path(@project, service.to_param) do
%strong= service.title
%td.hidden-xs
= service.description
diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml
index 0f1a76a104a..df1fd583670 100644
--- a/app/views/projects/services/edit.html.haml
+++ b/app/views/projects/services/edit.html.haml
@@ -1,3 +1,7 @@
+- breadcrumb_title "Integrations"
- page_title @service.title, "Services"
-= render "projects/settings/head"
+- add_to_breadcrumbs("Settings", edit_project_path(@project))
+
+= render 'deprecated_message' if @service.deprecation_message
+
= render 'form'
diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
index ef3599460f1..2ab0227126a 100644
--- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
@@ -1,4 +1,4 @@
-- run_actions_text = "Perform common operations on GitLab project: #{@project.name_with_namespace}"
+- run_actions_text = "Perform common operations on GitLab project: #{@project.full_name}"
%p To setup this service:
%ul.list-unstyled.indent-list
@@ -20,7 +20,7 @@
.form-group
= label_tag :display_name, 'Display name', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
- = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly'
+ = text_field_tag :display_name, "GitLab / #{@project.full_name}", class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(target: '#display_name')
@@ -39,7 +39,7 @@
Suggestions:
%code= 'gitlab'
%code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ %code= @project.full_path
.form-group
= label_tag :request_url, 'Request URL', class: 'col-sm-2 col-xs-12 control-label'
diff --git a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
index fcc91be11cd..44c0b7a90dc 100644
--- a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
@@ -2,6 +2,6 @@
- unless @service.activated?
.row
.col-sm-9.col-sm-offset-3
- = link_to new_namespace_project_mattermost_path(@project.namespace, @project), class: 'btn btn-lg' do
+ = link_to new_project_mattermost_path(@project), class: 'btn btn-lg' do
= custom_icon('mattermost_logo', size: 15)
Add to Mattermost
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
new file mode 100644
index 00000000000..2cc2a6b2b5b
--- /dev/null
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -0,0 +1,26 @@
+%h4
+ = s_('PrometheusService|Auto configuration')
+
+- if service.manual_configuration?
+ .well
+ = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
+- else
+ .container-fluid
+ .row
+ - if service.prometheus_installed?
+ .col-sm-2
+ .svg-container
+ = image_tag 'illustrations/monitoring/getting_started.svg'
+ .col-sm-10
+ %p.text-success.prepend-top-default
+ = s_('PrometheusService|Prometheus is being automatically managed on your clusters')
+ = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn'
+ - else
+ .col-sm-2
+ = image_tag 'illustrations/monitoring/loading.svg'
+ .col-sm-10
+ %p.prepend-top-default
+ = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
+ = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
+
+%hr
diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml
new file mode 100644
index 00000000000..88acb824ba7
--- /dev/null
+++ b/app/views/projects/services/prometheus/_help.html.haml
@@ -0,0 +1,9 @@
+- if @project
+ = render 'projects/services/prometheus/configuration_banner', project: @project, service: @service
+
+%h4.append-bottom-default
+ = s_('PrometheusService|Manual configuration')
+
+- unless @service.editable?
+ .well
+ = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters')
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index c4ac384ca1a..43e6a173108 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -1,45 +1,37 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('prometheus_metrics')
-
.row.prepend-top-default.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring
.col-lg-3
%h4.prepend-top-0
- Metrics
+ = s_('PrometheusService|Metrics')
%p
- Metrics are automatically configured and monitored
- based on a library of metrics from popular exporters.
- = link_to 'More information', '#'
+ = s_('PrometheusService|Metrics are automatically configured and monitored based on a library of metrics from popular exporters.')
+ = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus')
.col-lg-9
- .panel.panel-default.js-panel-monitored-metrics{ data: { "active-metrics" => "#{namespace_project_prometheus_active_metrics_path(@project.namespace, @project, :json)}" } }
+ .panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } }
.panel-heading
%h3.panel-title
- Monitored
+ = s_('PrometheusService|Common metrics')
%span.badge.js-monitored-count 0
.panel-body
- .loading-metrics.text-center.js-loading-metrics
- = icon('spinner spin 3x', class: 'metrics-load-spinner')
- %p Finding and configuring metrics...
- .empty-metrics.text-center.hidden.js-empty-metrics
- = custom_icon('icon_empty_metrics')
- %p No metrics are being monitored. To start monitoring, deploy to an environment.
- = link_to project_environments_path(@project), title: 'View environments', class: 'btn btn-success' do
- View environments
+ .loading-metrics.js-loading-metrics
+ %p.prepend-top-10.prepend-left-10
+ = icon('spinner spin', class: 'metrics-load-spinner')
+ = s_('PrometheusService|Finding and configuring metrics...')
+ .empty-metrics.hidden.js-empty-metrics
+ %p.text-tertiary.prepend-top-10.prepend-left-10
+ = s_('PrometheusService|Waiting for your first deployment to an environment to find common metrics')
%ul.list-unstyled.metrics-list.hidden.js-metrics-list
.panel.panel-default.hidden.js-panel-missing-env-vars
.panel-heading
%h3.panel-title
= icon('caret-right lg fw', class: 'panel-toggle js-panel-toggle', 'aria-label' => 'Toggle panel')
- Missing environment variable
+ = s_('PrometheusService|Missing environment variable')
%span.badge.js-env-var-count 0
.panel-body.hidden
.flash-container
.flash-notice
.flash-text
- To set up automatic monitoring, add the environment variable
- %code
- $CI_ENVIRONMENT_SLUG
- to exporter&rsquo;s queries.
- = link_to 'More information', '#'
+ = s_("PrometheusService|To set up automatic monitoring, add the environment variable %{variable} to exporter's queries." % { variable: "<code>$CI_ENVIRONMENT_SLUG</code>" }).html_safe
+ = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'metrics-and-labels')
%ul.list-unstyled.metrics-list.js-missing-var-metrics-list
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 73b99453a4b..d592a5e4663 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -1,4 +1,4 @@
-- pretty_name = defined?(@project) ? @project.name_with_namespace : 'namespace / path'
+- pretty_name = defined?(@project) ? @project.full_name : 'namespace / path'
- run_actions_text = "Perform common operations on GitLab project: #{pretty_name}"
.well
@@ -33,7 +33,7 @@
Suggestions:
%code= 'gitlab'
%code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ %code= @project.full_path
.form-group
= label_tag :url, 'URL', class: 'col-sm-2 col-xs-12 control-label'
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
deleted file mode 100644
index 00bd563999f..00000000000
--- a/app/views/projects/settings/_head.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: container_class }
- - can_edit = can?(current_user, :admin_project, @project)
- - if can_edit
- = nav_link(controller: :projects) do
- = link_to edit_project_path(@project), title: 'General' do
- %span
- General
- = nav_link(controller: :members) do
- = link_to project_settings_members_path(@project), title: 'Members' do
- %span
- Members
- - if can_edit
- = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
- = link_to project_settings_integrations_path(@project), title: 'Integrations' do
- %span
- Integrations
- = nav_link(controller: :repository) do
- = link_to namespace_project_settings_repository_path(@project.namespace, @project), title: 'Repository' do
- %span
- 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: 'Pipelines' do
- %span
- Pipelines
- - if Gitlab.config.pages.enabled
- = nav_link(controller: :pages) do
- = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do
- %span
- Pages
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 00ccc3ec41e..756f31f91d9 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,8 +1,52 @@
- @content_class = "limit-container-width" unless fluid_layout
-- page_title "Pipelines"
-= render "projects/settings/head"
+- page_title "CI / CD Settings"
+- page_title "CI / CD"
-= render 'projects/runners/index'
-= render 'projects/variables/index'
-= render 'projects/triggers/index'
-= render 'projects/pipelines_settings/show'
+- expanded = Rails.env.test?
+
+%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ General pipelines settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Update your CI/CD configuration, like job timeout or Auto DevOps.
+ .settings-content
+ = render 'projects/pipelines_settings/show'
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Runners settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Register and see your runners for this project.
+ .settings-content
+ = render 'projects/runners/index'
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Secret variables')
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer'
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p.append-bottom-0
+ = render "ci/variables/content"
+ .settings-content
+ = render 'ci/variables/index', save_endpoint: project_variables_path(@project)
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Pipeline triggers
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will
+ impersonate their associated user including their access to projects and their project
+ permissions.
+ .settings-content
+ = render 'projects/triggers/index'
diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml
index a6640592dba..cd003107d66 100644
--- a/app/views/projects/settings/integrations/_project_hook.html.haml
+++ b/app/views/projects/settings/integrations/_project_hook.html.haml
@@ -3,14 +3,14 @@
.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 job_events pipeline_events wiki_page_events).each do |trigger|
- - if hook.send(trigger)
- %span.label.label-gray.deploy-project-label= trigger.titleize
+ - ProjectHook.triggers.each_value do |event|
+ - if hook.public_send(event)
+ %span.label.label-gray.deploy-project-label= event.to_s.titleize
.col-md-4.col-lg-5.text-right-lg.prepend-top-5
%span.append-right-10.inline
- SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"}
- = link_to "Edit", edit_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm"
- = link_to "Test", test_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm"
- = link_to namespace_project_hook_path(@project.namespace, @project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent" do
+ SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
+ = link_to 'Edit', edit_project_hook_path(@project, hook), class: 'btn btn-sm'
+ = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: hook, button_class: 'btn-sm'
+ = link_to project_hook_path(@project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-transparent' do
%span.sr-only Remove
= icon('trash')
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index 1d1d0849289..2f1a548e119 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -1,5 +1,5 @@
- @content_class = "limit-container-width" unless fluid_layout
+- breadcrumb_title "Integrations Settings"
- page_title 'Integrations'
-= render "projects/settings/head"
= render 'projects/hooks/index'
= render 'projects/services/index'
diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml
index 1e7695ac397..ea2cd36b212 100644
--- a/app/views/projects/settings/members/show.html.haml
+++ b/app/views/projects/settings/members/show.html.haml
@@ -1,6 +1,5 @@
- @content_class = "limit-container-width" unless fluid_layout
- page_title "Members"
-= render "projects/settings/head"
= render "projects/project_members/index"
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 40ea02abce9..6bef4d19434 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,11 +1,11 @@
+- breadcrumb_title "Repository Settings"
- 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')
+-# Protected branches & tags use a lot of nested partials.
+-# The shared parts of the views can be found in the `shared` directory.
+-# Those are used throughout the actual views. These `shared` views are then
+-# reused in EE.
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
= render @deploy_keys
diff --git a/app/views/projects/show.atom.builder b/app/views/projects/show.atom.builder
index ed34f5c0520..39f8cb9a0e0 100644
--- a/app/views/projects/show.atom.builder
+++ b/app/views/projects/show.atom.builder
@@ -1,7 +1,7 @@
xml.title "#{@project.name} activity"
-xml.link href: namespace_project_url(@project.namespace, @project, rss_url_options), rel: "self", type: "application/atom+xml"
-xml.link href: namespace_project_url(@project.namespace, @project), rel: "alternate", type: "text/html"
-xml.id namespace_project_url(@project.namespace, @project)
+xml.link href: project_url(@project, rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: project_url(@project), rel: "alternate", type: "text/html"
+xml.id project_url(@project)
xml.updated @events[0].updated_at.xmlschema if @events[0]
xml << render(@events) if @events.any?
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 7447197ed89..fa281327eb7 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,86 +1,35 @@
- @no_container = true
+- breadcrumb_title "Details"
+- @content_class = "limit-container-width" unless fluid_layout
+- show_auto_devops_callout = show_auto_devops_callout?(@project)
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, namespace_project_path(@project.namespace, @project, rss_url_options), title: "#{@project.name} activity")
+ = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
-= content_for :flash_message do
- - if current_user && can?(current_user, :download_code, @project)
- = render 'shared/no_ssh'
- = render 'shared/no_password'
+= render partial: 'flash_messages', locals: { project: @project }
+
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+ = render "projects/last_push"
-= render "projects/head"
-= render "projects/last_push"
= render "home_panel"
- if can?(current_user, :download_code, @project)
%nav.project-stats{ class: container_class }
- %ul.nav
- %li
- = link_to project_files_path(@project) do
- #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)})
- %li
- = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- #{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
- #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
- %li
- = link_to namespace_project_tags_path(@project.namespace, @project) do
- #{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)
-
- - if @repository.changelog
- %li
- = link_to _('Changelog'), changelog_path(@project)
-
- - if @repository.license_blob
- %li
- = link_to license_short_name(@project), license_path(@project)
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- - if @repository.contribution_guide
- %li
- = link_to _('Contribution guide'), contribution_guide_path(@project)
- - if @repository.gitlab_ci_yml
- %li
- = 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') }
- - unless @repository.license_blob
- %li.missing
- = link_to add_special_file_path(@project, file_name: 'LICENSE') do
- #{ _('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') }
- - unless @repository.gitlab_ci_yml
- %li.missing
- = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
- #{ _('Set up CI') }
- - if koding_enabled? && @repository.koding_yml.blank?
- %li.missing
- = 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') }
-
-%div{ class: container_class }
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- if @project.archived?
.text-warning.center.prepend-top-20
%p
= icon("exclamation-triangle fw")
#{ _('Archived project! Repository is read-only') }
- - view_path = default_project_view
+ - view_path = @project.default_view
+
+ - if show_auto_devops_callout?(@project)
+ = render 'shared/auto_devops_callout'
%div{ class: project_child_container_class(view_path) }
= render view_path
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
index 34ee4ff1937..f09871c7fcc 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -2,16 +2,16 @@
.hidden-xs
- if can?(current_user, :update_project_snippet, @snippet)
- = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped" do
+ = link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do
Edit
- if can?(current_user, :update_project_snippet, @snippet)
- = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
+ = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
Delete
- if can?(current_user, :create_project_snippet, @project)
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do
+ = link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do
New snippet
- if @snippet.submittable_as_spam_by?(current_user)
- = link_to 'Submit as spam', mark_as_spam_namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam'
+ = link_to 'Submit as spam', mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam'
- if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet)
.visible-xs-block.dropdown
%button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
@@ -21,16 +21,16 @@
%ul
- if can?(current_user, :create_project_snippet, @project)
%li
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), title: "New snippet" do
+ = link_to new_project_snippet_path(@project), title: "New snippet" do
New snippet
- if can?(current_user, :update_project_snippet, @snippet)
%li
- = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do
+ = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do
Delete
- if can?(current_user, :update_project_snippet, @snippet)
%li
- = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet) do
+ = link_to edit_project_snippet_path(@project, @snippet) do
Edit
- if @snippet.submittable_as_spam_by?(current_user)
%li
- = link_to 'Submit as spam', mark_as_spam_namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :post
+ = link_to 'Submit as spam', mark_as_spam_project_snippet_path(@project, @snippet), method: :post
diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml
index 24b92094b7d..32844f5204a 100644
--- a/app/views/projects/snippets/edit.html.haml
+++ b/app/views/projects/snippets/edit.html.haml
@@ -1,6 +1,8 @@
+- add_to_breadcrumbs "Snippets", project_snippets_path(@project)
+- breadcrumb_title @snippet.to_reference
- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
%h3.page-title
Edit Snippet
%hr
-= render "shared/snippets/form", url: namespace_project_snippet_path(@project.namespace, @project, @snippet)
+= render "shared/snippets/form", url: project_snippet_path(@project, @snippet)
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index 84e05cd6d88..65efc083fdd 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -5,15 +5,8 @@
- include_private = @project.team.member?(current_user) || current_user.admin?
= render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
- .nav-controls.hidden-xs
+ .nav-controls
- if can?(current_user, :create_project_snippet, @project)
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New snippet" do
- New snippet
-
-- if can?(current_user, :create_project_snippet, @project)
- .visible-xs
- &nbsp;
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-block", title: "New snippet" do
- New snippet
+ = link_to "New snippet", new_project_snippet_path(@project), class: "btn btn-new", title: "New snippet"
= render 'snippets/snippets'
diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml
index cfed3a79bc5..1359a815429 100644
--- a/app/views/projects/snippets/new.html.haml
+++ b/app/views/projects/snippets/new.html.haml
@@ -1,6 +1,8 @@
+- add_to_breadcrumbs "Snippets", project_snippets_path(@project)
+- breadcrumb_title "New"
- page_title "New Snippets"
%h3.page-title
New Snippet
%hr
-= render "shared/snippets/form", url: namespace_project_snippets_path(@project.namespace, @project, @snippet)
+= render "shared/snippets/form", url: project_snippets_path(@project, @snippet)
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index d8e448dd2af..7062c5b765e 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -1,4 +1,6 @@
- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
+- add_to_breadcrumbs "Snippets", project_snippets_path(@project)
+- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
= render 'shared/snippets/header'
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 44cb734d7b9..3d5f92f9aaa 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -2,13 +2,12 @@
- 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), class: 'item-title ref-name' do
- = icon('tag')
- = tag.name
+ = icon('tag')
+ = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name prepend-left-4'
- if protected_tag?(@project, tag)
- %span.label.label-success
- protected
+ %span.label.label-success.prepend-left-4
+ = s_('TagsPage|protected')
- if tag.message.present?
&nbsp;
@@ -19,19 +18,19 @@
= render 'projects/branches/commit', commit: commit, project: @project
- else
%p
- Cant find HEAD commit for this tag
+ = s_("TagsPage|Can't find HEAD commit for this tag")
- if release && release.description.present?
.description.prepend-top-default
.wiki
= markdown_field(release, :description)
- .row-fixed-content.controls
+ .row-fixed-content.controls.flex-row
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :push_code, @project)
- = link_to edit_namespace_project_tag_release_path(@project.namespace, @project, tag.name), class: 'btn has-tooltip', title: "Edit release notes", data: { container: "body" } do
+ = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= icon("pencil")
- if can?(current_user, :admin_project, @project)
- = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do
+ = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= icon("trash-o")
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 56656ea3d86..10415d011d6 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -1,16 +1,15 @@
- @no_container = true
- @sort ||= sort_value_recently_updated
-- page_title "Tags"
-= render "projects/commits/head"
+- page_title s_('TagsPage|Tags')
.flex-list{ class: container_class }
.top-area.adjust
.nav-text.row-main-content
- Tags give the ability to mark specific points in history as being important
+ = s_('TagsPage|Tags give the ability to mark specific points in history as being important')
.nav-controls.row-fixed-content
= form_tag(filter_tags_path, method: :get) do
- = search_field_tag :search, params[:search], { placeholder: 'Filter by tag name', id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false }
+ = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false }
.dropdown
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} }
@@ -19,13 +18,13 @@
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li.dropdown-header
- Sort by
+ = s_('TagsPage|Sort by')
- tags_sort_options_hash.each do |value, title|
%li
= link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
- if can?(current_user, :push_code, @project)
- = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do
- New tag
+ = link_to new_project_tag_path(@project), class: 'btn btn-create new-tag-btn' do
+ = s_('TagsPage|New tag')
.tags
- if @tags.any?
@@ -36,9 +35,9 @@
- else
.nothing-here-block
- Repository has no tags yet.
+ = s_('TagsPage|Repository has no tags yet.')
%br
%small
- Use git tag command to add a new one:
+ = s_('TagsPage|Use git tag command to add a new one:')
%br
%span.monospace git tag -a v1.4 -m 'version 1.4'
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 52af295bddd..1827a3d323c 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -1,4 +1,4 @@
-- page_title "New Tag"
+- page_title s_('TagsPage|New Tag')
- default_ref = params[:ref] || @project.default_branch
- if @error
@@ -7,40 +7,41 @@
= @error
%h3.page-title
- New Tag
+ = s_('TagsPage|New Tag')
%hr
= form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "form-horizontal common-note-form tag-form js-quick-submit js-requires-input" do
.form-group
= label_tag :tag_name, nil, class: 'control-label'
.col-sm-10
- = text_field_tag :tag_name, params[:tag_name], required: true, tabindex: 1, autofocus: true, class: 'form-control'
+ = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control'
.form-group
= label_tag :ref, 'Create from', class: 'control-label'
.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 js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
.text-left.dropdown-toggle-text= default_ref
= render 'shared/ref_dropdown', dropdown_class: 'wide'
- .help-block Existing branch name, tag, or commit SHA
+ .help-block
+ = s_('TagsPage|Existing branch name, tag, or commit SHA')
.form-group
= label_tag :message, nil, class: 'control-label'
.col-sm-10
- = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5
- .help-block Optionally, add a message to the tag.
+ = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5
+ .help-block
+ = s_('TagsPage|Optionally, add a message to the tag.')
%hr
.form-group
- = label_tag :release_description, 'Release notes', class: 'control-label'
+ = label_tag :release_description, s_('TagsPage|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...", current_text: @release_description
+ = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|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.
+ .help-block
+ = s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.')
.form-actions
- = button_tag 'Create tag', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', namespace_project_tags_path(@project.namespace, @project), class: 'btn btn-cancel'
-
-:javascript
- window.gl = window.gl || { };
- window.gl.availableRefs = #{@project.repository.ref_names.to_json};
+ = button_tag s_('TagsPage|Create tag'), class: 'btn btn-create'
+ = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
+-# haml-lint:disable InlineJavaScript
+%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 2b81ce4b9fa..dfe2c37ed8e 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -1,6 +1,7 @@
- @no_container = true
-- page_title @tag.name, "Tags"
-= render "projects/commits/head"
+- add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project)
+- breadcrumb_title @tag.name
+- page_title @tag.name, s_('TagsPage|Tags')
%div{ class: container_class }
.top-area.multi-line
@@ -11,25 +12,25 @@
= @tag.name
- if protected_tag?(@project, @tag)
%span.label.label-success
- protected
+ = s_('TagsPage|protected')
- if @commit
= render 'projects/branches/commit', commit: @commit, project: @project
- else
- Cant find HEAD commit for this tag
+ = s_("TagsPage|Can't find HEAD commit for this tag")
.nav-controls.controls-flex
- if can?(current_user, :push_code, @project)
- = link_to edit_namespace_project_tag_release_path(@project.namespace, @project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Edit release notes' do
+ = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do
= icon("pencil")
- = link_to namespace_project_tree_path(@project.namespace, @project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse files' do
+ = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do
= icon('files-o')
- = link_to namespace_project_commits_path(@project.namespace, @project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse commits' do
+ = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do
= icon('history')
.btn-container.controls-item
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_project, @project)
.btn-container.controls-item-full
- = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do
+ = link_to project_tag_path(@project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do
%i.fa.fa-trash-o
- if @tag.message.present?
@@ -42,4 +43,4 @@
.wiki
= markdown_field(@release, :description)
- else
- This tag has no release notes.
+ = s_('TagsPage|This tag has no release notes.')
diff --git a/app/views/projects/transfer.js.haml b/app/views/projects/transfer.js.haml
index 17b9fecfeb1..6d083c5c516 100644
--- a/app/views/projects/transfer.js.haml
+++ b/app/views/projects/transfer.js.haml
@@ -1,2 +1,2 @@
:plain
- location.href = "#{edit_namespace_project_path(@project.namespace, @project)}";
+ location.href = "#{edit_project_path(@project)}";
diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml
index 425b460eb09..8c1c532cb3e 100644
--- a/app/views/projects/tree/_blob_item.html.haml
+++ b/app/views/projects/tree/_blob_item.html.haml
@@ -1,9 +1,12 @@
+- is_lfs_blob = @lfs_blob_ids.include?(blob_item.id)
%tr{ class: "tree-item #{tree_hex_class(blob_item)}" }
%td.tree-item-file-name
= tree_icon(type, blob_item.mode, blob_item.name)
- file_name = blob_item.name
- = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)), title: file_name do
- %span.str-truncated= file_name
+ = link_to project_blob_path(@project, tree_join(@id || @commit.id, blob_item.name)), class: 'str-truncated', title: file_name do
+ %span= file_name
+ - if is_lfs_blob
+ %span.label.label-lfs.prepend-left-5 LFS
%td.hidden-xs.tree-commit
%td.tree-time-ago.cgray.text-right
= render 'projects/tree/spinner'
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index f9147815427..4daacbe157c 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,9 +1,9 @@
- if readme.rich_viewer
- %article.file-holder.readme-holder{ class: ("limited-width-container" unless fluid_layout) }
+ %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
.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
+ = link_to project_blob_path(@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)
+ = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: project_blob_path(@project, tree_join(@ref, readme.path), viewer: :rich, format: :json)
diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml
index 84da16b6bb1..abb3e918e87 100644
--- a/app/views/projects/tree/_tree_commit_column.html.haml
+++ b/app/views/projects/tree/_tree_commit_column.html.haml
@@ -1,2 +1,2 @@
%span.str-truncated
- = link_to_gfm commit.full_title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link"
+ = link_to_markdown commit.full_title, project_commit_path(@project, commit.id), class: "tree-commit-link"
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index 7854e1305db..6ea78851b8d 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -1,4 +1,4 @@
-.tree-content-holder
+.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
.table-holder
%table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
%thead
@@ -6,11 +6,11 @@
%th= s_('ProjectFileTree|Name')
%th.hidden-xs
.pull-left= _('Last commit')
- %th.text-right= _('Last Update')
+ %th.text-right= _('Last update')
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
- = link_to "..", namespace_project_tree_path(@project.namespace, @project, up_dir_path), class: 'prepend-left-10'
+ = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
%td
%td.hidden-xs
@@ -20,11 +20,5 @@
= 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: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'
-
-:javascript
- // Load last commit log for each file in tree
- $('#tree-slider').waitForImages(function() {
- gl.utils.ajaxGet("#{escape_javascript(@logs_path)}");
- });
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 00da76349da..5ef5e9c09a2 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,81 +1,83 @@
.tree-ref-container
.tree-ref-holder
- = render 'shared/ref_switcher', destination: 'tree', path: @path
+ = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
+
+ - if on_top_of_branch?
+ - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' }
+ - else
+ - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' }
%ul.breadcrumb.repo-breadcrumb
%li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
+ = link_to project_tree_path(@project, @ref) do
= @project.path
- path_breadcrumbs do |title, path|
%li
- = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path))
+ = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
- 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' } }
- = icon('plus')
- - else
- %span.dropdown
- %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown", "data-target" => ".add-to-tree-dropdown" }
- = icon('plus')
+ %a.btn.add-to-tree{ addtotree_toggle_attributes }
+ = sprite_icon('plus', size: 16, css_class: 'pull-left')
+ = sprite_icon('arrow-down', size: 16, css_class: 'pull-left')
+ - if on_top_of_branch?
.add-to-tree-dropdown
%ul.dropdown-menu
- if can_edit_tree?
+ %li.dropdown-header
+ #{ _('This directory') }
%li
- = link_to namespace_project_new_blob_path(@project.namespace, @project, @id) do
- = icon('pencil fw')
+ = link_to project_new_blob_path(@project, @id) do
#{ _('New file') }
%li
= link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
- = icon('file fw')
#{ _('Upload file') }
%li
= link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
- = icon('folder fw')
#{ _('New directory') }
- elsif can?(current_user, :fork_project, @project)
%li
- - continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @id),
+ - continue_params = { to: project_new_blob_path(@project, @id),
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
- = icon('pencil fw')
#{ _('New file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to upload a file again.",
notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
- = icon('file fw')
#{ _('Upload file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to create a new directory again.",
notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
- = icon('folder fw')
#{ _('New directory') }
%li.divider
+ %li.dropdown-header
+ #{ _('This repository') }
%li
- = link_to new_namespace_project_branch_path(@project.namespace, @project) do
- = icon('code-fork fw')
+ = link_to new_project_branch_path(@project) do
#{ _('New branch') }
%li
- = link_to new_namespace_project_tag_path(@project.namespace, @project) do
- = icon('tags fw')
+ = link_to new_project_tag_path(@project) do
#{ _('New tag') }
.tree-controls
+ = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
+
= render 'projects/find_file_link'
- = link_to s_('Commits|History'), namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn'
+ = succeed " " do
+ = link_to ide_edit_path(@project, @id, ""), class: 'btn btn-default' do
+ = _('Web IDE')
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml
index 15c9536133c..af3816fc9f4 100644
--- a/app/views/projects/tree/_tree_item.html.haml
+++ b/app/views/projects/tree/_tree_item.html.haml
@@ -1,9 +1,9 @@
%tr{ class: "tree-item #{tree_hex_class(tree_item)}" }
%td.tree-item-file-name
= tree_icon(type, tree_item.mode, tree_item.name)
- - path = flatten_tree(tree_item)
- = link_to namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)), title: path do
- %span.str-truncated= path
+ - path = flatten_tree(@path, tree_item)
+ = link_to project_tree_path(@project, tree_join(@id || @commit.id, path)), class: 'str-truncated', title: path do
+ %span= path
%td.hidden-xs.tree-commit
%td.tree-time-ago.text-right
= render 'projects/tree/spinner'
diff --git a/app/views/projects/tree/_truncated_notice_tree_row.html.haml b/app/views/projects/tree/_truncated_notice_tree_row.html.haml
new file mode 100644
index 00000000000..693b641888b
--- /dev/null
+++ b/app/views/projects/tree/_truncated_notice_tree_row.html.haml
@@ -0,0 +1,7 @@
+%tr.tree-truncated-warning
+ %td{ colspan: '3' }
+ = icon('exclamation-triangle fw')
+ %span
+ Too many items to show. To preserve performance only
+ %strong #{number_with_delimiter(limit)} of #{number_with_delimiter(total)}
+ items are displayed.
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 96a08f9f8be..3b4057e56d0 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,11 +1,11 @@
- @no_container = true
+- breadcrumb_title _("Repository")
+- @content_class = "limit-container-width" unless fluid_layout
- 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"
+ = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
-= render 'projects/last_push'
-
-%div{ class: container_class }
- = render 'projects/files', commit: @last_commit, project: @project, ref: @ref
+%div{ class: [(container_class), ("limit-container-width" unless fluid_layout)] }
+ = render 'projects/last_push'
+ = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/projects/triggers/_content.html.haml b/app/views/projects/triggers/_content.html.haml
index ea32eac2ae2..6c2d603d95d 100644
--- a/app/views/projects/triggers/_content.html.haml
+++ b/app/views/projects/triggers/_content.html.haml
@@ -1,14 +1,8 @@
-%h4.prepend-top-0
- Triggers
-%p.prepend-top-20
- Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will
- impersonate their associated user including their access to projects and their project
- permissions.
-%p.prepend-top-20
+%p.append-bottom-default
Triggers with the
%span.label.label-primary legacy
label do not have an associated user and only have access to the current project.
-%p.append-bottom-0
+ %br
= succeed '.' do
Learn more in the
= link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank'
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index e9a2f803edd..0f655e4ed83 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,7 +1,6 @@
.row.prepend-top-default.append-bottom-default.triggers-container
- .col-lg-4
+ .col-lg-12
= render "projects/triggers/content"
- .col-lg-8
.panel.panel-default
.panel-heading
%h4.panel-title
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 9b5f63ae81a..6249c32b7cc 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -33,10 +33,10 @@
- take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?"
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
- if trigger.owner != current_user && can?(current_user, :manage_trigger, trigger)
- = link_to 'Take ownership', take_ownership_namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: take_ownership_confirmation }, method: :post, class: "btn btn-default btn-sm btn-trigger-take-ownership"
+ = link_to 'Take ownership', take_ownership_project_trigger_path(@project, trigger), data: { confirm: take_ownership_confirmation }, method: :post, class: "btn btn-default btn-sm btn-trigger-take-ownership"
- if can?(current_user, :admin_trigger, trigger)
- = link_to edit_namespace_project_trigger_path(@project.namespace, @project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
+ = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
%i.fa.fa-pencil
- if can?(current_user, :manage_trigger, trigger)
- = link_to namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
+ = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
%i.fa.fa-trash
diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml
index dcf1f767bf7..1a353953838 100644
--- a/app/views/projects/update.js.haml
+++ b/app/views/projects/update.js.haml
@@ -1,9 +1,9 @@
- if @project.valid?
:plain
- location.href = "#{edit_namespace_project_path(@project.namespace, @project)}";
+ location.href = "#{edit_project_path(@project)}";
- else
:plain
$(".project-edit-errors").html("#{escape_javascript(render('errors'))}");
$('.save-project-loader').hide();
$('.project-edit-container').show();
- $('.edit-project .btn-save').enable();
+ $('.edit-project .js-btn-save-general-project-settings').enable();
diff --git a/app/views/projects/variables/_content.html.haml b/app/views/projects/variables/_content.html.haml
deleted file mode 100644
index 98f618ca3b8..00000000000
--- a/app/views/projects/variables/_content.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-%h4.prepend-top-0
- 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, 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
- The value of the variable can be visible in job log if explicitly asked to do so.
diff --git a/app/views/projects/variables/_form.html.haml b/app/views/projects/variables/_form.html.haml
deleted file mode 100644
index 0a70a301cb4..00000000000
--- a/app/views/projects/variables/_form.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @variable] do |f|
- = form_errors(@variable)
-
- .form-group
- = f.label :key, "Key", class: "label-light"
- = f.text_field :key, class: "form-control", placeholder: "PROJECT_VARIABLE", required: true
- .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/_index.html.haml b/app/views/projects/variables/_index.html.haml
deleted file mode 100644
index 5e6786f6698..00000000000
--- a/app/views/projects/variables/_index.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-.row.prepend-top-default.append-bottom-default
- .col-lg-4
- = render "projects/variables/content"
- .col-lg-8
- %h5.prepend-top-0
- Add a variable
- = render "projects/variables/form", btn_text: "Add new variable"
- %hr
- %h5.prepend-top-0
- Your variables (#{@project.variables.size})
- - if @project.variables.empty?
- %p.settings-message.text-center.append-bottom-0
- No variables found, add one with the form above.
- - else
- = render "projects/variables/table"
- %button.btn.btn-info.js-btn-toggle-reveal-values{ "data-status" => 'hidden' } Reveal Values
diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml
deleted file mode 100644
index 59cd3c4b592..00000000000
--- a/app/views/projects/variables/_table.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-.table-responsive.variables-table
- %table.table
- %colgroup
- %col
- %col
- %col
- %col{ width: 100 }
- %thead
- %th Key
- %th Value
- %th Protected
- %th
- %tbody
- - @project.variables.order_key_asc.each do |variable|
- - if variable.id?
- %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
- Update
- = icon("pencil")
- = link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-delete", method: :delete, data: { confirm: "Are you sure?" } do
- %span.sr-only
- Remove
- = icon("trash")
diff --git a/app/views/projects/variables/show.html.haml b/app/views/projects/variables/show.html.haml
deleted file mode 100644
index 297a53ca98c..00000000000
--- a/app/views/projects/variables/show.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- page_title "Variables"
-
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- = render "content"
- .col-lg-9
- %h5.prepend-top-0
- Update variable
- = render "form", btn_text: "Save variable"
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index c10b3004bc3..d285251d06f 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -1,19 +1,31 @@
-- commit_message = @page.persisted? ? "Update #{@page.title}" : "Create #{@page.title}"
+- commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}")
+- commit_message = commit_message % { page_title: @page.title }
= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'form-horizontal wiki-form common-note-form prepend-top-default js-quick-submit' } do |f|
= form_errors(@page)
- = f.hidden_field :title, value: @page.title
+ - if @page.persisted?
+ = f.hidden_field :last_commit_sha, value: @page.last_commit_sha
+
+ .form-group
+ .col-sm-12= f.label :title, class: 'control-label-full-width'
+ .col-sm-12
+ = f.text_field :title, class: 'form-control', value: @page.title
+ - if @page.persisted?
+ %span.edit-wiki-page-slug-tip
+ = icon('lightbulb-o')
+ = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
+ = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), target: '_blank'
.form-group
.col-sm-12= f.label :format, class: 'control-label-full-width'
.col-sm-12
- = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: "form-control"
+ = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control'
.form-group
.col-sm-12= f.label :content, class: 'control-label-full-width'
.col-sm-12
- = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do
- = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...'
+ = render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do
+ = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here...")
= render 'shared/notes/hints'
.clearfix
@@ -21,12 +33,11 @@
.help-block
= succeed '.' do
- To link to a (new) page, simply type
- %code [Link Title](page-slug)
+ = (s_("WikiMarkdownTip|To link to a (new) page, simply type %{link_example}") % { link_example: '<code>[Link Title](page-slug)</code>' }).html_safe
= succeed '.' do
- More examples are in the
- = link_to 'documentation', help_page_path("user/markdown", anchor: "wiki-specific-markdown")
+ - markdown_link = link_to s_("WikiMarkdownDocs|documentation"), help_page_path('user/markdown', anchor: 'wiki-specific-markdown')
+ = (s_("WikiMarkdownDocs|More examples are in the %{docs_link}") % { docs_link: markdown_link }).html_safe
.form-group
.col-sm-12= f.label :commit_message, class: 'control-label-full-width'
@@ -34,10 +45,10 @@
.form-actions
- if @page && @page.persisted?
- = f.submit 'Save changes', class: "btn-save btn"
+ = f.submit _("Save changes"), class: 'btn-save btn'
.pull-right
- = link_to "Cancel", namespace_project_wiki_path(@project.namespace, @project, @page), class: "btn btn-cancel btn-grouped"
+ = link_to _("Cancel"), project_wiki_path(@project, @page), class: 'btn btn-cancel btn-grouped'
- else
- = f.submit 'Create page', class: "btn-create btn"
+ = f.submit s_("Wiki|Create page"), class: 'btn-create btn'
.pull-right
- = link_to "Cancel", namespace_project_wiki_path(@project.namespace, @project, :home), class: "btn btn-cancel"
+ = link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel'
diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml
index 6a578dbf640..cadda0a33c2 100644
--- a/app/views/projects/wikis/_main_links.html.haml
+++ b/app/views/projects/wikis/_main_links.html.haml
@@ -1,9 +1,9 @@
- if (@page && @page.persisted?)
- if can?(current_user, :create_wiki, @project)
= link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
- New page
- = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do
- Page history
+ = s_("Wiki|New page")
+ = link_to project_wiki_history_path(@project, @page), class: "btn" do
+ = s_("Wiki|Page history")
- if can?(current_user, :create_wiki, @project) && @page.latest?
- = link_to namespace_project_wiki_edit_path(@project.namespace, @project, @page), class: "btn js-wiki-edit" do
- Edit
+ = link_to project_wiki_edit_path(@project, @page), class: "btn js-wiki-edit" do
+ = _("Edit")
diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml
index 1e553940593..06a3cac12d5 100644
--- a/app/views/projects/wikis/_new.html.haml
+++ b/app/views/projects/wikis/_new.html.haml
@@ -3,16 +3,15 @@
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3.page-title New Wiki Page
+ %h3.page-title= s_("WikiNewPageTitle|New Wiki Page")
.modal-body
%form.new-wiki-page
.form-group
= label_tag :new_wiki_path do
- %span Page slug
- = text_field_tag :new_wiki_path, nil, placeholder: 'how-to-setup', class: 'form-control', required: true, :'data-wikis-path' => namespace_project_wikis_path(@project.namespace, @project), autofocus: true
+ %span= s_("WikiPage|Page slug")
+ = text_field_tag :new_wiki_path, nil, placeholder: s_("WikiNewPagePlaceholder|how-to-setup"), class: 'form-control', required: true, :'data-wikis-path' => project_wikis_path(@project), autofocus: true
%span.new-wiki-page-slug-tip
= icon('lightbulb-o')
- Tip: You can specify the full path for the new file.
- We will automatically create any missing directories.
+ = s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.")
.form-actions
- = button_tag 'Create page', class: 'build-new-wiki btn btn-create'
+ = button_tag s_("Wiki|Create page"), class: "build-new-wiki btn btn-create"
diff --git a/app/views/projects/wikis/_pages_wiki_page.html.haml b/app/views/projects/wikis/_pages_wiki_page.html.haml
index 6298cf6c8da..efa16d38f84 100644
--- a/app/views/projects/wikis/_pages_wiki_page.html.haml
+++ b/app/views/projects/wikis/_pages_wiki_page.html.haml
@@ -1,5 +1,5 @@
%li
- = link_to wiki_page.title, namespace_project_wiki_path(@project.namespace, @project, wiki_page)
+ = link_to wiki_page.title, project_wiki_path(@project, wiki_page)
%small (#{wiki_page.format})
.pull-right
- %small Last edited #{time_ago_with_tooltip(wiki_page.commit.authored_date)}
+ %small= (s_("Last edited %{date}") % { date: time_ago_with_tooltip(wiki_page.last_version.authored_date) }).html_safe
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index c2f9e65015d..2c7551c6f8c 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -1,24 +1,21 @@
%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')
+ .sidebar-container
+ .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')
- - git_access_url = namespace_project_wikis_git_access_path(@project.namespace, @project)
- = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '' do
- = succeed '&nbsp;' do
- = icon('cloud-download')
- Clone repository
+ - git_access_url = project_wikis_git_access_path(@project)
+ = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '' do
+ = icon('cloud-download', class: 'append-right-5')
+ %span= _("Clone repository")
- .blocks-container
- .block.block-first
- %ul.wiki-pages
- = render @sidebar_wiki_entries, context: 'sidebar'
+ .blocks-container
+ .block.block-first
+ %ul.wiki-pages
+ = render @sidebar_wiki_entries, context: 'sidebar'
- .block
- = link_to namespace_project_wikis_pages_path(@project.namespace, @project), class: 'btn btn-block' do
- More Pages
+ .block
+ = link_to project_wikis_pages_path(@project), class: 'btn btn-block' do
+ = s_("Wiki|More Pages")
= render 'projects/wikis/new'
-
-:javascript
- new Sidebar();
diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/projects/wikis/_sidebar_wiki_page.html.haml
index 0a61d90177b..2423ac6abce 100644
--- a/app/views/projects/wikis/_sidebar_wiki_page.html.haml
+++ b/app/views/projects/wikis/_sidebar_wiki_page.html.haml
@@ -1,3 +1,3 @@
%li{ class: active_when(params[:id] == wiki_page.slug) }
- = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_page) do
+ = link_to project_wiki_path(@project, wiki_page) do
= wiki_page.title.capitalize
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index fbe192a40ec..9d3d4072027 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -1,5 +1,7 @@
- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
-- page_title "Edit", @page.title.capitalize, "Wiki"
+- page_title _("Edit"), @page.title.capitalize, _("Wiki")
+
+= wiki_page_errors(@error)
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
@@ -8,26 +10,26 @@
.nav-text
%h2.wiki-page-title
- if @page.persisted?
- = link_to @page.title.capitalize, namespace_project_wiki_path(@project.namespace, @project, @page)
+ = link_to @page.title.capitalize, project_wiki_path(@project, @page)
- else
= @page.title.capitalize
%span.light
&middot;
- if @page.persisted?
- Edit Page
+ = s_("Wiki|Edit Page")
- else
- Create Page
+ = s_("Wiki|Create Page")
.nav-controls
- if can?(current_user, :create_wiki, @project)
= link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
- New page
+ = s_("Wiki|New page")
- if @page.persisted?
- = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do
- Page history
+ = link_to project_wiki_history_path(@project, @page), class: "btn" do
+ = s_("Wiki|Page history")
- if can?(current_user, :admin_wiki, @project)
- = link_to namespace_project_wiki_path(@project.namespace, @project, @page), data: { confirm: "Are you sure you want to delete this page?"}, method: :delete, class: "btn btn-danger" do
- Delete
+ = link_to project_wiki_path(@project, @page), data: { confirm: s_("WikiPageConfirmDelete|Are you sure you want to delete this page?")}, method: :delete, class: "btn btn-danger" do
+ = _("Delete")
= render 'form'
diff --git a/app/views/projects/wikis/empty.html.haml b/app/views/projects/wikis/empty.html.haml
index 7dfa405d063..d6e568bac94 100644
--- a/app/views/projects/wikis/empty.html.haml
+++ b/app/views/projects/wikis/empty.html.haml
@@ -1,6 +1,6 @@
-- page_title "Wiki"
+- page_title _("Wiki")
-%h3.page-title Empty page
+%h3.page-title= s_("Wiki|Empty page")
%hr
.error_message
- You are not allowed to create wiki pages
+ = s_("WikiEmptyPageError|You are not allowed to create wiki pages")
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml
index e64dd6085fe..10dbbc0e42c 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/projects/wikis/git_access.html.haml
@@ -1,36 +1,34 @@
- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
-- page_title "Git Access", "Wiki"
+- page_title s_("WikiClone|Git Access"), _("Wiki")
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.visible-xs.visible-sm.pull-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
= icon('angle-double-left')
.git-access-header
- Clone repository
- %strong= @project_wiki.path_with_namespace
+ = _("Clone repository")
+ %strong= @project_wiki.full_path
= render "shared/clone_panel", project: @project_wiki
.wiki-git-access
- %h3 Install Gollum
+ %h3= s_("WikiClone|Install Gollum")
%pre.dark
:preserve
gem install gollum
%p
- It is recommended to install
- %code github-markdown
- so that GFM features render locally:
+ = (s_("WikiClone|It is recommended to install %{markdown} so that GFM features render locally:") % { markdown: "<code>github-markdown</code>" }).html_safe
%pre.dark
:preserve
gem install github-markdown
- %h3 Clone your wiki
+ %h3= s_("WikiClone|Clone your wiki")
%pre.dark
:preserve
git clone #{ content_tag(:span, h(default_url_to_repo(@project_wiki)), class: 'clone')}
cd #{h @project_wiki.path}
- %h3 Start Gollum and edit locally
+ %h3= s_("WikiClone|Start Gollum and edit locally")
%pre.dark
:preserve
gollum
diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml
index 0e47e2a5fa3..969a1677d9a 100644
--- a/app/views/projects/wikis/history.html.haml
+++ b/app/views/projects/wikis/history.html.haml
@@ -1,4 +1,4 @@
-- page_title "History", @page.title.capitalize, "Wiki"
+- page_title _("History"), @page.title.capitalize, _("Wiki")
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
@@ -6,22 +6,22 @@
.nav-text
%h2.wiki-page-title
- = link_to @page.title.capitalize, namespace_project_wiki_path(@project.namespace, @project, @page)
+ = link_to @page.title.capitalize, project_wiki_path(@project, @page)
%span.light
&middot;
- History
+ = _("History")
.table-holder
%table.table
%thead
%tr
- %th Page version
- %th Author
- %th Commit Message
- %th Last updated
- %th Format
+ %th= s_("Wiki|Page version")
+ %th= _("Author")
+ %th= _("Commit Message")
+ %th= _("Last updated")
+ %th= _("Format")
%tbody
- - @page.versions.each_with_index do |version, index|
+ - @page_versions.each_with_index do |version, index|
- commit = version
%tr
%td
@@ -29,13 +29,14 @@
commit.id, index == 0) do
= truncate_sha(commit.id)
%td
- = commit.author.name
+ = commit.author_name
%td
= commit.message
%td
#{time_ago_with_tooltip(version.authored_date)}
%td
%strong
- = @page.page.wiki.page(@page.page.name, commit.id).try(:format)
+ = version.format
+= paginate @page_versions, theme: 'gitlab'
= render 'sidebar'
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml
index 5fba2b1a5ae..aeef64fd7eb 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -1,17 +1,19 @@
- @no_container = true
-- page_title "Pages", "Wiki"
+- add_to_breadcrumbs "Wiki", get_project_wiki_path(@project)
+- breadcrumb_title s_("Wiki|Pages")
+- page_title s_("Wiki|Pages"), _("Wiki")
%div{ class: container_class }
.wiki-page-header
.nav-text
%h2.wiki-page-title
- Wiki Pages
+ = s_("Wiki|Wiki Pages")
.nav-controls
- = link_to namespace_project_wikis_git_access_path(@project.namespace, @project), class: 'btn' do
+ = link_to project_wikis_git_access_path(@project), class: 'btn' do
= icon('cloud-download')
- Clone repository
+ = _("Clone repository")
%ul.wiki-pages-list.content-list
= render @wiki_entries, context: 'pages'
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index f003ff6b63f..b3b83cee81a 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -1,28 +1,28 @@
- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
-- page_title @page.title.capitalize, "Wiki"
+- breadcrumb_title @page.title.capitalize
+- wiki_breadcrumb_dropdown_links(@page.slug)
+- page_title @page.title.capitalize, _("Wiki")
+- add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project)
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
= icon('angle-double-left')
- .wiki-breadcrumb
- %span= breadcrumb(@page.slug)
-
.nav-text
%h2.wiki-page-title= @page.title.capitalize
%span.wiki-last-edit-by
- Last edited by
- %strong
- #{@page.commit.author.name}
- #{time_ago_with_tooltip(@page.commit.authored_date)}
+ = (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe
+ #{time_ago_with_tooltip(@page.last_version.authored_date)}
.nav-controls
= render 'main_links'
- if @page.historical?
.warning_message
- This is an old version of this page.
- You can view the #{link_to "most recent version", namespace_project_wiki_path(@project.namespace, @project, @page)} or browse the #{link_to "history", namespace_project_wiki_history_path(@project.namespace, @project, @page)}.
+ = s_("WikiHistoricalPage|This is an old version of this page.")
+ - most_recent_link = link_to s_("WikiHistoricalPage|most recent version"), project_wiki_path(@project, @page)
+ - history_link = link_to s_("WikiHistoricalPage|history"), project_wiki_history_path(@project, @page)
+ = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.wiki-holder.prepend-top-default.append-bottom-default
.wiki
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 314d8e9cb25..7d43fd61081 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -14,25 +14,25 @@
= link_to search_filter_path(scope: 'issues') do
Issues
%span.badge
- = @search_results.issues_count
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_notes_count)
- if project_search_tabs?(:wiki)
%li{ class: active_when(@scope == 'wiki_blobs') }
= link_to search_filter_path(scope: 'wiki_blobs') do
@@ -57,25 +57,24 @@
Titles and Filenames
%span.badge
= @search_results.snippet_titles_count
-
- else
%li{ class: active_when(@scope == 'projects') }
= link_to search_filter_path(scope: 'projects') do
Projects
%span.badge
- = @search_results.projects_count
+ = limited_count(@search_results.limited_projects_count)
%li{ class: active_when(@scope == 'issues') }
= link_to search_filter_path(scope: 'issues') do
Issues
%span.badge
- = @search_results.issues_count
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_merge_requests_count)
%li{ class: active_when(@scope == 'milestones') }
= link_to search_filter_path(scope: 'milestones') do
Milestones
%span.badge
- = @search_results.milestones_count
+ = limited_count(@search_results.limited_milestones_count)
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index e43796e9654..e4902d368e7 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -22,7 +22,7 @@
%span.dropdown-toggle-text
Project:
- if @project.present?
- = @project.name_with_namespace
+ = @project.full_name
- else
Any
= icon("chevron-down")
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index 3139be1cd37..a4a5cec1314 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -11,5 +11,5 @@
%span.sr-only
Clear search
- unless params[:snippets].eql? 'true'
- = render 'filter' if current_user
+ = render 'filter'
= button_tag "Search", class: "btn btn-success btn-search"
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 02133d09cdf..ab56f48ba4d 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -2,10 +2,11 @@
= render partial: "search/results/empty"
- else
.row-content-block
- = search_entries_info(@search_objects, @scope, @search_term)
+ - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
+ = search_entries_info(@search_objects, @scope, @search_term)
- unless @show_snippets
- if @project
- in project #{link_to @project.name_with_namespace, [@project.namespace.becomes(Namespace), @project]}
+ in project #{link_to @project.full_name, [@project.namespace.becomes(Namespace), @project]}
- elsif @group
in group #{link_to @group.name, @group}
@@ -22,4 +23,4 @@
= render partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
- = paginate(@search_objects, theme: 'gitlab')
+ = paginate_collection(@search_objects)
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 7f1f807e2e7..de473c23d66 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -3,7 +3,7 @@
.file-holder
.js-file-title.file-title
- ref = @search_results.repository_ref
- - blob_link = namespace_project_blob_path(@project.namespace, @project, tree_join(ref, file_name))
+ - blob_link = project_blob_path(@project, tree_join(ref, file_name))
= link_to blob_link do
%i.fa.fa-file
%strong
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index b4bc8982c05..b7a27ef6be2 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -10,4 +10,4 @@
.description.term
= search_md_sanitize(issue, :description)
%span.light
- #{issue.project.name_with_namespace}
+ #{issue.project.full_name}
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index 1a5499e4d58..8b0fd74f680 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -11,4 +11,4 @@
.description.term
= search_md_sanitize(merge_request, :description)
%span.light
- #{merge_request.project.name_with_namespace}
+ #{merge_request.project.full_name}
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index a7e178dfa71..e4ab7b0541f 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -7,7 +7,7 @@
%i.fa.fa-comment
= link_to_member(project, note.author, avatar: false)
commented on
- = link_to project.name_with_namespace, project
+ = link_to project.full_name, project
&middot;
- if note.for_commit?
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index c4a5131c1a7..57a0b64bfd5 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -7,7 +7,7 @@
= snippet.title
by
= link_to user_snippets_path(snippet.author) do
- = image_tag avatar_icon(snippet.author), class: "avatar avatar-inline s16", alt: ''
+ = image_tag avatar_icon_for_user(snippet.author), class: "avatar avatar-inline s16", alt: ''
= snippet.author_name
%span.light= time_ago_with_tooltip(snippet.created_at)
%h4.snippet-title
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index 026f404ce07..d46c4d11e51 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -11,13 +11,13 @@
%small.pull-right.cgray
- if snippet_title.project_id?
- = link_to snippet_title.project.name_with_namespace, namespace_project_path(snippet_title.project.namespace, snippet_title.project)
+ = link_to snippet_title.project.full_name, project_path(snippet_title.project)
.snippet-info
= snippet_title.to_reference
%span
by
= link_to user_snippets_path(snippet_title.author) do
- = image_tag avatar_icon(snippet_title.author), class: "avatar avatar-inline s16", alt: ''
+ = image_tag avatar_icon_for_user(snippet_title.author), class: "avatar avatar-inline s16", alt: ''
= snippet_title.author_name
%span.light= time_ago_with_tooltip(snippet_title.created_at)
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index d87f9df2677..16a0e432d62 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -2,7 +2,7 @@
.blob-result
.file-holder
.js-file-title.file-title
- = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.basename) do
+ = link_to project_wiki_path(@project, wiki_blob.basename) do
%i.fa.fa-file
%strong
= wiki_blob.basename
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 215dbb3909e..499697f2777 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -1,3 +1,5 @@
+- @hide_top_links = true
+- breadcrumb_title "Search"
- page_title @search_term
.prepend-top-default
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index de52fd00157..7d3e243495f 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,7 +1,7 @@
- noteable = @sent_notification.noteable
- noteable_type = @sent_notification.noteable_type.titleize.downcase
- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
-- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace
+- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.full_name
%h3.page-title
Unsubscribe from #{noteable_type}
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
new file mode 100644
index 00000000000..934d65e8b42
--- /dev/null
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -0,0 +1,16 @@
+.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
+ .banner-graphic
+ = custom_icon('icon_autodevops')
+
+ .prepend-top-10.prepend-left-10.append-bottom-10
+ %h5= s_('AutoDevOps|Auto DevOps (Beta)')
+ %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+ %p
+ - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+ .prepend-top-10
+ = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout'
+
+ %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss Auto DevOps box' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml
index 94295970acf..75c65520350 100644
--- a/app/views/shared/_choose_group_avatar_button.html.haml
+++ b/app/views/shared/_choose_group_avatar_button.html.haml
@@ -1,7 +1,4 @@
-%button.choose-btn.btn.btn-sm.js-choose-group-avatar-button{ type: 'button' }
- %i.fa.fa-paperclip
- %span Choose File ...
-&nbsp;
-%span.file_name.js-avatar-filename File name...
-= f.file_field :avatar, class: 'js-group-avatar-input hidden'
-.light The maximum file size allowed is 200KB.
+%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...")
+%span.file_name.js-avatar-filename= _("No file chosen")
+= f.file_field :avatar, class: "js-group-avatar-input hidden"
+.help-block= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 75704eda361..687cd4d1532 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -3,15 +3,15 @@
.git-clone-holder.input-group
.input-group-btn
- if allowed_protocols_present?
- .clone-dropdown-btn.btn.btn-static
+ .clone-dropdown-btn.btn
%span
= enabled_project_button(project, enabled_protocol)
- else
- %a#clone-dropdown.clone-dropdown-btn.btn{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.btn.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span
= default_clone_protocol.upcase
= icon('caret-down')
- %ul.dropdown-menu.dropdown-menu-right.clone-options-dropdown
+ %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown
%li
= ssh_clone_button(project)
%li
@@ -20,11 +20,3 @@
= 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"), class: "btn-default btn-clipboard")
-
-:javascript
- $('ul.clone-options-dropdown a').on('click',function(e){
- e.preventDefault();
- var $this = $(this);
- $('a.clone-dropdown-btn span').text($this.text());
- $('#project_clone').val($this.attr('href'));
- });
diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml
new file mode 100644
index 00000000000..50e3d80a84d
--- /dev/null
+++ b/app/views/shared/_commit_well.html.haml
@@ -0,0 +1,4 @@
+.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
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
new file mode 100644
index 00000000000..01effefc34d
--- /dev/null
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -0,0 +1,20 @@
+.modal{ id: "modal-delete-label-#{label.id}", tabindex: -1 }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %button.close{ data: {dismiss: 'modal' } } &times;
+ %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ?
+
+ .modal-body
+ %p
+ %strong= label.name
+ %span will be permanently deleted from #{label.is_a?(ProjectLabel)? label.project.name : label.group.name}. This cannot be undone.
+
+ .modal-footer
+ %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
+
+ = link_to 'Delete label',
+ destroy_label_path(label),
+ title: 'Delete',
+ method: :delete,
+ class: 'btn btn-remove'
diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml
new file mode 100644
index 00000000000..b7bbc109238
--- /dev/null
+++ b/app/views/shared/_email_with_badge.html.haml
@@ -0,0 +1,8 @@
+- css_classes = %w(label label-verification-status)
+- css_classes << (verified ? 'verified': 'unverified')
+- text = verified ? 'Verified' : 'Unverified'
+
+.email-badge
+ .email-badge-email= email
+ %div{ class: css_classes }
+ = text
diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml
index e50ab5fea09..e7fa7477e0c 100644
--- a/app/views/shared/_event_filter.html.haml
+++ b/app/views/shared/_event_filter.html.haml
@@ -1,11 +1,14 @@
-%ul.nav-links.event-filter.scrolling-tabs
- = event_filter_link EventFilter.all, 'All'
- - if event_filter_visible(:repository)
- = event_filter_link EventFilter.push, 'Push events'
- - if event_filter_visible(:merge_requests)
- = event_filter_link EventFilter.merged, 'Merge events'
- - if event_filter_visible(:issues)
- = event_filter_link EventFilter.issue, 'Issue events'
- - if comments_visible?
- = event_filter_link EventFilter.comments, 'Comments'
- = event_filter_link EventFilter.team, 'Team'
+.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
+ .fade-left= icon('angle-left')
+ .fade-right= icon('angle-right')
+ %ul.nav-links.event-filter.scrolling-tabs
+ = event_filter_link EventFilter.all, _('All'), s_('EventFilterBy|Filter by all')
+ - if event_filter_visible(:repository)
+ = event_filter_link EventFilter.push, _('Push events'), s_('EventFilterBy|Filter by push events')
+ - if event_filter_visible(:merge_requests)
+ = event_filter_link EventFilter.merged, _('Merge events'), s_('EventFilterBy|Filter by merge events')
+ - if event_filter_visible(:issues)
+ = event_filter_link EventFilter.issue, _('Issue events'), s_('EventFilterBy|Filter by issue events')
+ - if comments_visible?
+ = event_filter_link EventFilter.comments, _('Comments'), s_('EventFilterBy|Filter by comments')
+ = event_filter_link EventFilter.team, _('Team'), s_('EventFilterBy|Filter by team')
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index 795447a9ca6..aea0a8fd8e0 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -7,6 +7,7 @@
- choices = field[:choices]
- default_choice = field[:default_choice]
- help = field[:help]
+- disabled = disable_fields_service?(@service)
.form-group
- if type == "password" && value.present?
@@ -15,14 +16,14 @@
= form.label name, title, class: "control-label"
.col-sm-10
- if type == 'text'
- = form.text_field name, class: "form-control", placeholder: placeholder, required: required
+ = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled
- elsif type == 'textarea'
- = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required
+ = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required, disabled: disabled
- elsif type == 'checkbox'
- = form.check_box name
+ = form.check_box name, disabled: disabled
- elsif type == 'select'
- = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" }
+ = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control", disabled: disabled}
- elsif type == 'password'
- = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && :required
+ = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && required, disabled: disabled
- 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 8d5b5129454..cb21f90696f 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -1,6 +1,4 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('group')
-- parent = GroupFinder.new(current_user).execute(id: params[:parent_id] || @group.parent_id)
+- parent = @group.parent
- group_path = root_url
- group_path << parent.full_path + '/' if parent
@@ -13,21 +11,18 @@
%span>= root_url
- if parent
%strong= parent.full_path + '/'
+ = f.hidden_field :parent_id
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
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
- = f.hidden_field :parent_id, value: parent.id
- if @group.persisted?
.alert.alert-warning.prepend-top-10
- %ul
- %li Changing group path can have unintended side effects.
- %li Renaming group path will rename directory for all related projects
- %li It will change web url for access group and group projects.
- %li It will change the git path to repositories under this group.
+ Changing group path can have unintended side effects.
+ = succeed '.' do
+ = link_to 'Learn more', help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank'
.form-group.group-name-holder
= f.label :name, class: 'control-label' do
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 1c7c73be933..5eaaa1448d5 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -1,16 +1,22 @@
+- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
+
.form-group.import-url-data
- = f.label :import_url, class: 'control-label' do
- %span Git repository URL
- .col-sm-10
- = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
+ = f.label :import_url, class: 'label-light' do
+ %span
+ = _('Git repository URL')
+
+ = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true
+
+ .well.prepend-top-20
+ %ul
+ %li
+ = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe
+ %li
+ = _('If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.').html_safe
+ %li
+ = import_will_timeout_message(ci_cd_only)
+ %li
+ = import_svn_message(ci_cd_only)
- .well.prepend-top-20
- %ul
- %li
- The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.
- %li
- If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.
- %li
- The import will time out after 15 minutes. For repositories that take longer, use a clone/push combination.
- %li
- To migrate an SVN repository, check out #{link_to "this document", help_page_path('workflow/importing/migrating_from_svn')}.
+-# EE-specific start
+-# EE-specific end
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 1d4fd71522d..430d9a9dd76 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -5,21 +5,21 @@
- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count
- if issuable_mr > 0
- %li
+ %li.issuable-mr.hidden-xs.has-tooltip{ title: _('Related merge requests') }
= image_tag('icon-merge-request-unmerged.svg', class: 'icon-merge-request-unmerged')
= issuable_mr
- if upvotes > 0
- %li
+ %li.issuable-upvotes.hidden-xs.has-tooltip{ title: _('Upvotes') }
= icon('thumbs-up')
= upvotes
- if downvotes > 0
- %li
+ %li.issuable-downvotes.hidden-xs.has-tooltip{ title: _('Downvotes') }
= icon('thumbs-down')
= downvotes
-%li
- = link_to issuable_url, class: ('no-comments' if note_count.zero?) do
+%li.issuable-comments.hidden-xs
+ = link_to issuable_url, class: ['has-tooltip', ('no-comments' if note_count.zero?)], title: _('Comments') do
= icon('comments')
= note_count
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index 3a49227961f..49555b6ff4e 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -1,6 +1,6 @@
- if @issues.to_a.any?
.panel.panel-default.panel-small.panel-without-border
- %ul.content-list.issues-list
+ %ul.content-list.issues-list.issuable-list
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index de0281e97c6..5afbc78df53 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -2,28 +2,32 @@
- status = label_subscription_status(label, @project).inquiry if current_user
- subject = local_assigns[:subject]
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
+- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
+- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
-%li{ id: label_css_id, data: { id: label.id } }
+%li.label-list-item{ id: label_css_id, data: { id: label.id } }
= render "shared/label_row", label: label
- .visible-xs.visible-sm-inline-block.visible-md-inline-block.dropdown
+ .visible-xs.visible-sm-inline-block.dropdown
%button.btn.btn-default.label-options-toggle{ type: 'button', data: { toggle: "dropdown" } }
Options
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right
%ul
- %li
- = link_to_label(label, subject: subject, type: :merge_request) do
- view merge requests
- %li
- = link_to_label(label, subject: subject) do
- view open issues
+ - if show_label_merge_requests_link
+ %li
+ = link_to_label(label, subject: subject, type: :merge_request) do
+ View merge requests
+ - if show_label_issues_link
+ %li
+ = link_to_label(label, subject: subject) do
+ View open issues
- if current_user
%li.label-subscription
- if can_subscribe_to_label_in_different_levels?(label)
%a.js-unsubscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path } }
%span Unsubscribe
- %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_project_label_path(@project, label) } }
%span Subscribe at project level
%a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } }
%span Subscribe at group level
@@ -35,14 +39,34 @@
%li
= link_to 'Edit', edit_label_path(label)
%li
- = link_to 'Delete', destroy_label_path(label), title: 'Delete', method: :delete, data: {confirm: 'Remove this label? Are you sure?'}
-
- .pull-right.hidden-xs.hidden-sm.hidden-md
- = link_to_label(label, subject: subject, type: :merge_request, css_class: 'btn btn-transparent btn-action') do
- view merge requests
- = link_to_label(label, subject: subject, css_class: 'btn btn-transparent btn-action') do
- view open issues
+ = link_to 'Delete',
+ destroy_label_path(label),
+ title: 'Delete',
+ method: :delete,
+ data: {confirm: 'Remove this label? Are you sure?'},
+ class: 'text-danger'
+ .pull-right.hidden-xs.hidden-sm
+ - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
+ %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'),
+ disabled: true,
+ type: 'button',
+ data: { url: promote_project_label_path(label.project, label),
+ label_title: label.title,
+ label_color: label.color,
+ label_text_color: label.text_color,
+ target: '#promote-label-modal',
+ container: 'body',
+ toggle: 'modal' } }
+ = sprite_icon('level-up')
+ - if can?(current_user, :admin_label, label)
+ = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do
+ %span.sr-only Edit
+ = sprite_icon('pencil')
+ %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } }
+ = link_to "#", title: "Delete", class: 'btn btn-transparent btn-action remove-row', data: { toggle: "tooltip" } do
+ %span.sr-only Delete
+ = sprite_icon('remove')
- if current_user
.label-subscription.inline
- if can_subscribe_to_label_in_different_levels?(label)
@@ -56,7 +80,7 @@
= icon('chevron-down')
%ul.dropdown-menu
%li
- %a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_project_label_path(@project, label) } }
Project level
%a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } }
Group level
@@ -65,22 +89,4 @@
%span= label_subscription_toggle_button_text(label, @project)
= icon('spinner spin', class: 'label-subscribe-button-loading')
- - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
- = link_to promote_namespace_project_label_path(label.project.namespace, label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting this label will make this label available to all projects inside this group. Existing project labels with the same name will be merged. Are you sure?", toggle: "tooltip"}, method: :post do
- %span.sr-only Promote to Group
- = icon('level-up')
- - if can?(current_user, :admin_label, label)
- = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do
- %span.sr-only Edit
- = icon('pencil-square-o')
- = link_to destroy_label_path(label), title: "Delete", class: 'btn btn-transparent btn-action remove-row', method: :delete, data: {confirm: label_deletion_confirm_text(label), toggle: "tooltip"} do
- %span.sr-only Delete
- = icon('trash-o')
-
- - if current_user
- - if can_subscribe_to_label_in_different_levels?(label)
- :javascript
- new gl.GroupLabelSubscription('##{dom_id(label)} .label-subscription');
- - else
- :javascript
- new gl.ProjectLabelSubscription('##{dom_id(label)} .label-subscription');
+= render 'shared/delete_label_modal', label: label
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 7b599dff0e3..bd4f191502e 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,8 +1,12 @@
+- subject = local_assigns[:subject]
+- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
+- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
+
%span.label-row
- if can?(current_user, :admin_label, @project)
.draggable-handler
= icon('bars')
- .js-toggle-priority.toggle-priority{ data: { url: remove_priority_namespace_project_label_path(@project.namespace, @project, label),
+ .js-toggle-priority.toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
%button.add-priority.btn.has-tooltip{ title: 'Prioritize', type: 'button', :'data-placement' => 'top' }
= icon('star-o')
@@ -13,6 +17,14 @@
- if defined?(@project) && @project.group.present?
%span.label-type
= label.model_name.human.titleize
- - if label.description
- %span.label-description
- = markdown_field(label, :description)
+
+ %span.label-description
+ - if label.description.present?
+ .description-text
+ = markdown_field(label, :description)
+ .hidden-xs.hidden-sm
+ - if show_label_issues_link
+ = link_to_label(label, subject: subject) { 'Issues' }
+ - if show_label_merge_requests_link
+ &middot;
+ = link_to_label(label, subject: subject, type: :merge_request) { 'Merge requests' }
diff --git a/app/views/shared/_logo.svg b/app/views/shared/_logo.svg
index 10e6c49ae9f..0ef9de5fed6 100644
--- a/app/views/shared/_logo.svg
+++ b/app/views/shared/_logo.svg
@@ -1,4 +1,4 @@
-<svg width="28" height="28" class="tanuki-logo" viewBox="0 0 36 36">
+<svg width="24" height="24" class="tanuki-logo" viewBox="0 0 36 36">
<path class="tanuki-shape tanuki-left-ear" fill="#e24329" d="M2 14l9.38 9v-9l-4-12.28c-.205-.632-1.176-.632-1.38 0z"/>
<path class="tanuki-shape tanuki-right-ear" fill="#e24329" d="M34 14l-9.38 9v-9l4-12.28c.205-.632 1.176-.632 1.38 0z"/>
<path class="tanuki-shape tanuki-nose" fill="#e24329" d="M18,34.38 3,14 33,14 Z"/>
diff --git a/app/views/shared/_logo_type.svg b/app/views/shared/_logo_type.svg
new file mode 100644
index 00000000000..cb07e2634a9
--- /dev/null
+++ b/app/views/shared/_logo_type.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 617 169"><path d="M315.26 2.97h-21.8l.1 162.5h88.3v-20.1h-66.5l-.1-142.4M465.89 136.95c-5.5 5.7-14.6 11.4-27 11.4-16.6 0-23.3-8.2-23.3-18.9 0-16.1 11.2-23.8 35-23.8 4.5 0 11.7.5 15.4 1.2v30.1h-.1m-22.6-98.5c-17.6 0-33.8 6.2-46.4 16.7l7.7 13.4c8.9-5.2 19.8-10.4 35.5-10.4 17.9 0 25.8 9.2 25.8 24.6v7.9c-3.5-.7-10.7-1.2-15.1-1.2-38.2 0-57.6 13.4-57.6 41.4 0 25.1 15.4 37.7 38.7 37.7 15.7 0 30.8-7.2 36-18.9l4 15.9h15.4v-83.2c-.1-26.3-11.5-43.9-44-43.9M557.63 149.1c-8.2 0-15.4-1-20.8-3.5V70.5c7.4-6.2 16.6-10.7 28.3-10.7 21.1 0 29.2 14.9 29.2 39 0 34.2-13.1 50.3-36.7 50.3m9.2-110.6c-19.5 0-30 13.3-30 13.3v-21l-.1-27.8h-21.3l.1 158.5c10.7 4.5 25.3 6.9 41.2 6.9 40.7 0 60.3-26 60.3-70.9-.1-35.5-18.2-59-50.2-59M77.9 20.6c19.3 0 31.8 6.4 39.9 12.9l9.4-16.3C114.5 6 97.3 0 78.9 0 32.5 0 0 28.3 0 85.4c0 59.8 35.1 83.1 75.2 83.1 20.1 0 37.2-4.7 48.4-9.4l-.5-63.9V75.1H63.6v20.1h38l.5 48.5c-5 2.5-13.6 4.5-25.3 4.5-32.2 0-53.8-20.3-53.8-63-.1-43.5 22.2-64.6 54.9-64.6M231.43 2.95h-21.3l.1 27.3v94.3c0 26.3 11.4 43.9 43.9 43.9 4.5 0 8.9-.4 13.1-1.2v-19.1c-3.1.5-6.4.7-9.9.7-17.9 0-25.8-9.2-25.8-24.6v-65h35.7v-17.8h-35.7l-.1-38.5M155.96 165.47h21.3v-124h-21.3v124M155.96 24.37h21.3V3.07h-21.3v21.3"/></svg>
diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml
index eecbb32e90e..0517896cfbd 100644
--- a/app/views/shared/_merge_requests.html.haml
+++ b/app/views/shared/_merge_requests.html.haml
@@ -1,6 +1,6 @@
- if @merge_requests.to_a.any?
.panel.panel-default.panel-small.panel-without-border
- %ul.content-list.mr-list
+ %ul.content-list.mr-list.issuable-list
= render partial: 'projects/merge_requests/merge_request', collection: @merge_requests
= paginate @merge_requests, theme: "gitlab"
diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml
index db2ac1e1d12..034b76b978f 100644
--- a/app/views/shared/_milestones_filter.html.haml
+++ b/app/views/shared/_milestones_filter.html.haml
@@ -1,4 +1,4 @@
-%ul.nav-links
+%ul.nav-links.mobile-separator
%li{ class: milestone_class_for_state(params[:state], 'opened', true) }>
= link_to milestones_filter_path(state: 'opened') do
Open
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index aa93572bf94..901a177323b 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -6,8 +6,8 @@
- status_klass = "ci-status-icon ci-status-icon-#{detailed_status.group}"
.stage-container.dropdown{ class: klass }
- %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: stage.name) } }
- = custom_icon(icon_status)
+ %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
+ = sprite_icon(icon_status)
= icon('caret-down')
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
diff --git a/app/views/shared/_mr_head.html.haml b/app/views/shared/_mr_head.html.haml
deleted file mode 100644
index 4211ec6351d..00000000000
--- a/app/views/shared/_mr_head.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- if @project.default_issues_tracker?
- = render "projects/issues/head"
-- else
- = render "projects/merge_requests/head"
diff --git a/app/views/shared/_nav_scroll.html.haml b/app/views/shared/_nav_scroll.html.haml
deleted file mode 100644
index 61646f150c1..00000000000
--- a/app/views/shared/_nav_scroll.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.fade-left
- = icon('angle-left')
-.fade-right
- = icon('angle-right')
diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml
index 0a4a24ae807..9221fd1e025 100644
--- a/app/views/shared/_new_commit_form.html.haml
+++ b/app/views/shared/_new_commit_form.html.haml
@@ -1,3 +1,6 @@
+- project = @project.present(current_user: current_user)
+- branch_name = selected_branch
+
= render 'shared/commit_message_container', placeholder: placeholder
- if @project.empty_repo?
@@ -7,12 +10,14 @@
.form-group.branch
= label_tag 'branch_name', _('Target Branch'), class: 'control-label'
.col-sm-10
- = text_field_tag 'branch_name', @branch_name || tree_edit_branch, required: true, class: "form-control js-branch-name ref-name"
+ = text_field_tag 'branch_name', branch_name, required: true, class: "form-control js-branch-name ref-name"
.js-create-merge-request-container
= render 'shared/new_merge_request_checkbox'
+ - elsif project.can_current_user_push_to_branch?(branch_name)
+ = hidden_field_tag 'branch_name', branch_name
- else
- = hidden_field_tag 'branch_name', @branch_name || tree_edit_branch
+ = hidden_field_tag 'branch_name', branch_name
= hidden_field_tag 'create_merge_request', 1
= hidden_field_tag 'original_branch', @ref, class: 'js-original-branch'
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index 9ed844cf5e7..ac2ebb701a5 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,19 +1,7 @@
-- 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' }, with_feature_enabled: local_assigns[:with_feature_enabled]
- %a.btn.btn-new.new-project-item-select-button
- = local_assigns[:label]
+- if any_projects?(@projects)
+ .project-item-select-holder.btn-group
+ %a.btn.btn-new.new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } }
+ = icon('spinner spin')
+ = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path] }, with_feature_enabled: local_assigns[:with_feature_enabled]
+ %button.btn.btn-new.new-project-item-select-button
= icon('caret-down')
-
- :javascript
- $('.new-project-item-select-button').on('click', function() {
- $('.project-item-select').select2('open');
- });
-
- var relativePath = '#{local_assigns[:path]}';
-
- $('.project-item-select').on('click', function() {
- window.location = $(this).val() + '/' + relativePath;
- });
-
- new ProjectSelect()
diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml
index c06d1ffa59b..8ddb1b2bc99 100644
--- a/app/views/shared/_outdated_browser.html.haml
+++ b/app/views/shared/_outdated_browser.html.haml
@@ -1,7 +1,8 @@
- if outdated_browser?
- .browser-alert
- GitLab may not work properly because you are using an outdated web browser.
- %br
- Please install a
- = link_to 'supported web browser', help_page_url('install/requirements', anchor: 'supported-web-browsers')
- for a better experience.
+ .flash-container
+ .flash-alert.text-center
+ GitLab may not work properly because you are using an outdated web browser.
+ %br
+ Please install a
+ = link_to 'supported web browser', help_page_path('install/requirements', anchor: 'supported-web-browsers')
+ for a better experience.
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
index b20055a564e..b8b1f4ca42f 100644
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ b/app/views/shared/_personal_access_tokens_form.html.haml
@@ -1,9 +1,9 @@
- type = impersonation ? "impersonation" : "personal access"
%h5.prepend-top-0
- Add a #{type} Token
+ Add a #{type} token
%p.profile-settings-content
- Pick a name for the application, and we'll give you a unique #{type} Token.
+ Pick a name for the application, and we'll give you a unique #{type} token.
= form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
@@ -23,18 +23,3 @@
.prepend-top-default
= f.submit "Create #{type} token", class: "btn btn-create"
-
-:javascript
- var $dateField = $('.datepicker');
- var date = $dateField.val();
-
- new Pikaday({
- field: $dateField.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- minDate: new Date(),
- container: $dateField.parent().get(0),
- onSelect: function(dateText) {
- $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
- }
- });
diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml
index ab7a2db002e..c5e4d6e2871 100644
--- a/app/views/shared/_personal_access_tokens_table.html.haml
+++ b/app/views/shared/_personal_access_tokens_table.html.haml
@@ -39,22 +39,3 @@
- else
.settings-message.text-center
This user has no active #{type} Tokens.
-
-%hr
-
-%h5 Inactive #{type} Tokens (#{inactive_tokens.length})
-- if inactive_tokens.present?
- .table-responsive
- %table.table.inactive-tokens
- %thead
- %tr
- %th Name
- %th Created
- %tbody
- - inactive_tokens.each do |token|
- %tr
- %td= token.name
- %td= token.created_at.to_date.to_s(:medium)
-- else
- .settings-message.text-center
- This user has no inactive #{type} Tokens.
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
new file mode 100644
index 00000000000..93a4301f366
--- /dev/null
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -0,0 +1,20 @@
+- resource_name = spammable.class.model_name.singular
+- humanized_resource_name = spammable.class.model_name.human.downcase
+- script = local_assigns.fetch(:script, true)
+- method = params[:action] == 'create' ? :post : :put
+- has_submit = local_assigns.fetch(:has_submit, true)
+
+= form_for resource_name, method: method, html: { class: 'recaptcha-form js-recaptcha-form' } do |f|
+ .recaptcha
+ - params[resource_name].each do |field, value|
+ = hidden_field(resource_name, field, value: value)
+ = hidden_field_tag(:spam_log_id, spammable.spam_log.id)
+ = hidden_field_tag(:recaptcha_verification, true)
+ = recaptcha_tags script: script, callback: 'recaptchaDialogCallback'
+
+ -# Yields a block with given extra params.
+ = yield
+
+ - if has_submit
+ .row-content-block.footer-block
+ = f.submit "Submit #{humanized_resource_name}", class: 'btn btn-create'
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index d52bb6b4dd7..4c8c92d722a 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -1,14 +1,17 @@
+- show_create = local_assigns.fetch(:show_create, false)
+
- dropdown_toggle_text = @ref || @project.default_branch
-= form_tag switch_namespace_project_refs_path(@project.namespace, @project), method: :get, class: "project-refs-form" do
+= form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do
= hidden_field_tag :destination, destination
- if defined?(path)
= hidden_field_tag :path, path
- @options && @options.each do |key, value|
= 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.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
+ = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown" }
+ .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
+ .dropdown-page-one
+ = dropdown_title _("Switch branch/tag")
+ = dropdown_filter _("Search branches and tags")
+ = dropdown_content
+ = dropdown_loading
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index b200e5fc528..a41aaed66a3 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -7,17 +7,18 @@
= markdown @service.help
.service-settings
- .form-group
- = form.label :active, "Active", class: "control-label"
- .col-sm-10
- = form.check_box :active
+ - if @service.show_active_box?
+ .form-group
+ = form.label :active, "Active", class: "control-label"
+ .col-sm-10
+ = form.check_box :active, disabled: disable_fields_service?(@service)
- - if @service.supported_events.present?
+ - if @service.configurable_events.present?
.form-group
= form.label :url, "Trigger", class: 'control-label'
.col-sm-10
- - @service.supported_events.each do |event|
+ - @service.configurable_events.each do |event|
%div
= form.check_box service_event_field_name(event), class: 'pull-left'
.prepend-left-20
@@ -32,7 +33,7 @@
= form.text_field field[:name], class: "form-control", placeholder: field[:placeholder]
%p.light
- = service_event_description(event)
+ = @service.class.event_description(event)
- @service.global_fields.each do |field|
- type = field[:type]
diff --git a/app/views/shared/_show_aside.html.haml b/app/views/shared/_show_aside.html.haml
deleted file mode 100644
index 3ac9b11b4fa..00000000000
--- a/app/views/shared/_show_aside.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-= link_to '#aside', class: 'show-aside' do
- %i.fa.fa-angle-left
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
new file mode 100644
index 00000000000..2530db986e0
--- /dev/null
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -0,0 +1,8 @@
+%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
+ = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left')
+ = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right')
+ %span.collapse-text Collapse sidebar
+
+= button_tag class: 'close-nav-button', type: 'button' do
+ = sprite_icon('close', size: 16)
+ %span.collapse-text Close sidebar
diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml
index a212c714826..7ff5e679f17 100644
--- a/app/views/shared/_sort_dropdown.html.haml
+++ b/app/views/shared/_sort_dropdown.html.haml
@@ -1,34 +1,16 @@
+- sorted_by = sort_options_hash[@sort]
+- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
+
.dropdown.inline.prepend-left-10
- %button.dropdown-toggle{ type: 'button', data: {toggle: 'dropdown' } }
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
+ %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } }
+ = sorted_by
= icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-sort
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable.dropdown-menu-sort
%li
- = link_to page_filter_path(sort: sort_value_priority, label: true) do
- = sort_title_priority
- = link_to page_filter_path(sort: sort_value_label_priority, label: true) do
- = sort_title_label_priority
- = link_to page_filter_path(sort: sort_value_recently_created, label: true) do
- = sort_title_recently_created
- = link_to page_filter_path(sort: sort_value_oldest_created, label: true) do
- = sort_title_oldest_created
- = link_to page_filter_path(sort: sort_value_recently_updated, label: true) do
- = sort_title_recently_updated
- = link_to page_filter_path(sort: sort_value_oldest_updated, label: true) do
- = sort_title_oldest_updated
- = link_to page_filter_path(sort: sort_value_milestone_soon, label: true) do
- = sort_title_milestone_soon
- = link_to page_filter_path(sort: sort_value_milestone_later, label: true) do
- = sort_title_milestone_later
- - if controller.controller_name == 'issues' || controller.action_name == 'issues'
- = link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do
- = sort_title_due_date_soon
- = link_to page_filter_path(sort: sort_value_due_date_later, label: true) do
- = sort_title_due_date_later
- = link_to page_filter_path(sort: sort_value_upvotes, label: true) do
- = sort_title_upvotes
- = link_to page_filter_path(sort: sort_value_downvotes, label: true) do
- = sort_title_downvotes
+ = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sorted_by)
+ = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by)
+ = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sorted_by)
+ = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sorted_by)
+ = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sorted_by) if viewing_issues
+ = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sorted_by)
+ = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sorted_by)
diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml
deleted file mode 100644
index 17ffcba69d8..00000000000
--- a/app/views/shared/_user_callout.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-.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' }
- = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
- .svg-container
- = custom_icon('icon_customization')
- .user-callout-copy
- %h4
- Customize your experience
- %p
- Change syntax themes, default project pages, and more in preferences.
- = link_to 'Check it out', profile_preferences_path, class: 'btn btn-primary js-close-callout'
diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml
index 73efec88bb1..192d2502aaf 100644
--- a/app/views/shared/_visibility_level.html.haml
+++ b/app/views/shared/_visibility_level.html.haml
@@ -1,6 +1,6 @@
- with_label = local_assigns.fetch(:with_label, true)
-.form-group.project-visibility-level-holder
+.form-group.visibility-level-setting
- if with_label
= f.label :visibility_level, class: 'control-label' do
Visibility Level
diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml
index 182c4eebd50..0ec7677a566 100644
--- a/app/views/shared/_visibility_radios.html.haml
+++ b/app/views/shared/_visibility_radios.html.haml
@@ -1,15 +1,17 @@
- Gitlab::VisibilityLevel.values.each do |level|
- - next if skip_level?(form_model, level)
- .radio
- - restricted = restricted_visibility_levels.include?(level)
+ - disallowed = disallowed_visibility_level?(form_model, level)
+ - restricted = restricted_visibility_levels.include?(level)
+ - disabled = disallowed || restricted
+ .radio{ class: [('disabled' if disabled), ('restricted' if restricted)] }
= form.label "#{model_method}_#{level}" do
- = form.radio_button model_method, level, checked: (selected_level == level), disabled: restricted
+ = form.radio_button model_method, level, checked: (selected_level == level), disabled: disabled
= visibility_level_icon(level)
.option-title
= visibility_level_label(level)
- .option-descr
+ .option-description
= visibility_level_description(level, form_model)
-- unless restricted_visibility_levels.empty?
- %div
- %span.info
- Some visibility level settings have been restricted by the administrator.
+ .option-disabled-reason
+ - if restricted
+ = restricted_visibility_level_description(level)
+ - elsif disallowed
+ = disallowed_visibility_level_description(level, form_model)
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
new file mode 100644
index 00000000000..dac60094686
--- /dev/null
+++ b/app/views/shared/boards/_show.html.haml
@@ -0,0 +1,39 @@
+- board = local_assigns.fetch(:board, nil)
+- group = local_assigns.fetch(:group, false)
+- @no_breadcrumb_container = true
+- @no_container = true
+- @content_class = "issue-boards-content"
+- breadcrumb_title "Issue Board"
+- page_title "Boards"
+
+- content_for :page_specific_javascripts do
+
+ -# haml-lint:disable InlineJavaScript
+ %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
+ %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
+
+#board-app.boards-app{ "v-cloak" => true, data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
+ .hidden-xs.hidden-sm
+ = render 'shared/issuable/search_bar', type: :boards
+
+ .boards-list
+ .boards-app-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ %board{ "v-cloak" => true,
+ "v-for" => "list in state.lists",
+ "ref" => "board",
+ ":list" => "list",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase",
+ ":root-path" => "rootPath",
+ ":board-id" => "boardId",
+ ":key" => "list.id" }
+ = render "shared/boards/components/sidebar", group: group
+ - if @project
+ %board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project),
+ "milestone-path" => milestones_filter_dropdown_path,
+ "label-path" => labels_filter_path,
+ "empty-state-svg" => image_path('illustrations/issues.svg'),
+ ":issue-link-base" => "issueLinkBase",
+ ":root-path" => "rootPath",
+ ":project-id" => @project.id }
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
new file mode 100644
index 00000000000..149bf8da4b9
--- /dev/null
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -0,0 +1,48 @@
+.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 && 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 }",
+ "aria-hidden": "true" }
+
+ %span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
+ ":title" => '(list.label ? list.label.description : "")', data: { container: "body" } }
+ {{ list.title }}
+
+ %span.has-tooltip{ "v-if": "list.type === \"label\"",
+ ":title" => '(list.label ? list.label.description : "")',
+ data: { container: "body", placement: "bottom" },
+ class: "label color-label title board-title-text",
+ ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" }
+ {{ list.title }}
+ - if can?(current_user, :admin_list, current_board_parent)
+ %board-delete{ "inline-template" => true,
+ ":list" => "list",
+ "v-if" => "!list.preset && list.id" }
+ %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
+ = icon("trash")
+ .issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank"' }
+ %span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
+ {{ list.issuesSize }}
+ - if can?(current_user, :admin_list, current_board_parent)
+ %button.issue-count-badge-add-button.btn.btn-sm.btn-default.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", class: "js-no-trigger-collapse")
+
+ %board-list{ "v-if" => 'list.type !== "blank"',
+ ":list" => "list",
+ ":issues" => "list.issues",
+ ":loading" => "list.loading",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase",
+ ":root-path" => "rootPath",
+ ":groupId" => ((current_board_parent.id if @group) || 'null'),
+ "ref" => "board-list" }
+ - if can?(current_user, :admin_list, current_board_parent)
+ %board-blank-state{ "v-if" => 'list.id == "blank"' }
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
new file mode 100644
index 00000000000..8e5e32e9f16
--- /dev/null
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -0,0 +1,27 @@
+%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json }
+ %transition{ name: "boards-sidebar-slide" }
+ %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" }
+ .issuable-sidebar
+ .block.issuable-sidebar-header
+ %span.issuable-header-text.hide-collapsed.pull-left
+ %strong
+ {{ issue.title }}
+ %br/
+ %span
+ = precede "#" do
+ {{ issue.iid }}
+ %a.gutter-toggle.pull-right{ role: "button",
+ href: "#",
+ "@click.prevent" => "closeSidebar",
+ "aria-label" => "Toggle sidebar" }
+ = custom_icon("icon_close", size: 15)
+ .js-issuable-update
+ = render "shared/boards/components/sidebar/assignee"
+ = render "shared/boards/components/sidebar/milestone"
+ = render "shared/boards/components/sidebar/due_date"
+ = render "shared/boards/components/sidebar/labels"
+ = render "shared/boards/components/sidebar/notifications"
+ %remove-btn{ ":issue" => "issue",
+ ":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
+ ":list" => "list",
+ "v-if" => "canRemove" }
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
new file mode 100644
index 00000000000..3d2e8471a60
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml
@@ -0,0 +1,32 @@
+.block.assignee{ ref: "assigneeBlock" }
+ %template{ "v-if" => "issue.assignees" }
+ %assignee-title{ ":number-of-assignees" => "issue.assignees.length",
+ ":loading" => "loadingAssignees",
+ ":editable" => can_admin_issue? }
+ %assignees.value{ "root-path" => "#{root_url}",
+ ":users" => "issue.assignees",
+ ":editable" => can_admin_issue?,
+ "@assign-self" => "assignSelf" }
+
+ - if can_admin_issue?
+ .selectbox.hide-collapsed
+ %input.js-vue{ type: "hidden",
+ name: "issue[assignee_ids][]",
+ ":value" => "assignee.id",
+ "v-if" => "issue.assignees",
+ "v-for" => "assignee in issue.assignees",
+ ":data-avatar_url" => "assignee.avatar",
+ ":data-name" => "assignee.name",
+ ":data-username" => "assignee.username" }
+ .dropdown
+ - dropdown_options = issue_assignees_dropdown_options
+ %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: board_sidebar_user_data,
+ ":data-issuable-id" => "issue.iid",
+ ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ = dropdown_options[:title]
+ = icon("chevron-down")
+ .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
+ = dropdown_title("Assign to")
+ = dropdown_filter("Search users")
+ = dropdown_content
+ = dropdown_loading
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
new file mode 100644
index 00000000000..db794d6f855
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml
@@ -0,0 +1,32 @@
+.block.due_date
+ .title
+ Due date
+ - if can_admin_issue?
+ = icon("spinner spin", class: "block-loading")
+ = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
+ .value
+ .value-content
+ %span.no-value{ "v-if" => "!issue.dueDate" }
+ No due date
+ %span.bold{ "v-if" => "issue.dueDate" }
+ {{ issue.dueDate | due-date }}
+ - if can_admin_issue?
+ %span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
+ \-
+ %a.js-remove-due-date{ href: "#", role: "button" }
+ remove due date
+ - if can_admin_issue?
+ .selectbox
+ %input{ type: "hidden",
+ name: "issue[due_date]",
+ ":value" => "issue.dueDate" }
+ .dropdown
+ %button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
+ data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
+ ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ %span.dropdown-toggle-text Due date
+ = icon('chevron-down')
+ .dropdown-menu.dropdown-menu-due-date
+ = dropdown_title('Due date')
+ = dropdown_content do
+ .js-due-date-calendar
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
new file mode 100644
index 00000000000..dfc0f9be321
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -0,0 +1,37 @@
+.block.labels
+ .title
+ Labels
+ - if can_admin_issue?
+ = icon("spinner spin", class: "block-loading")
+ = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
+ .value.issuable-show-labels
+ %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
+ None
+ %a{ href: "#",
+ "v-for" => "label in issue.labels" }
+ %span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ {{ label.title }}
+ - if can_admin_issue?
+ .selectbox
+ %input{ type: "hidden",
+ name: "issue[label_names][]",
+ "v-for" => "label in issue.labels",
+ ":value" => "label.id" }
+ .dropdown
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
+ data: { toggle: "dropdown",
+ field_name: "issue[label_names][]",
+ show_no: "true",
+ show_any: "true",
+ project_id: @project&.try(:id),
+ labels: labels_filter_path(false),
+ namespace_path: @namespace_path,
+ project_path: @project.try(:path) },
+ ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ %span.dropdown-toggle-text
+ Label
+ = icon('chevron-down')
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
+ = render partial: "shared/issuable/label_page_default"
+ - if can?(current_user, :admin_label, current_board_parent)
+ = render partial: "shared/issuable/label_page_create"
diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml
new file mode 100644
index 00000000000..d09c7c218e0
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml
@@ -0,0 +1,29 @@
+.block.milestone
+ .title
+ Milestone
+ - if can_admin_issue?
+ = icon("spinner spin", class: "block-loading")
+ = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
+ .value
+ %span.no-value{ "v-if" => "!issue.milestone" }
+ None
+ %span.bold.has-tooltip{ "v-if" => "issue.milestone" }
+ {{ issue.milestone.title }}
+ - if can_admin_issue?
+ .selectbox
+ %input{ type: "hidden",
+ ":value" => "issue.milestone.id",
+ 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]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" },
+ ":data-selected" => "milestoneTitle",
+ ":data-issuable-id" => "issue.iid",
+ ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ Milestone
+ = icon("chevron-down")
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ = dropdown_title("Assign milestone")
+ = dropdown_filter("Search milestones")
+ = dropdown_content
+ = dropdown_loading
diff --git a/app/views/shared/boards/components/sidebar/_notifications.html.haml b/app/views/shared/boards/components/sidebar/_notifications.html.haml
new file mode 100644
index 00000000000..333dd1a00b4
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_notifications.html.haml
@@ -0,0 +1,5 @@
+- if current_user
+ .block.subscriptions
+ %subscriptions{ ":loading" => "issue.isFetching && issue.isFetching.subscriptions",
+ ":subscribed" => "issue.subscribed",
+ ":id" => "issue.id" }
diff --git a/app/views/projects/merge_requests/diffs.html.haml b/app/views/shared/boards/index.html.haml
index 2a5b8b1441e..2a5b8b1441e 100644
--- a/app/views/projects/merge_requests/diffs.html.haml
+++ b/app/views/shared/boards/index.html.haml
diff --git a/app/views/shared/boards/show.html.haml b/app/views/shared/boards/show.html.haml
new file mode 100644
index 00000000000..2a5b8b1441e
--- /dev/null
+++ b/app/views/shared/boards/show.html.haml
@@ -0,0 +1 @@
+= render "show"
diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml
index 3baa956b910..0b003125912 100644
--- a/app/views/shared/builds/_tabs.html.haml
+++ b/app/views/shared/builds/_tabs.html.haml
@@ -1,24 +1,24 @@
-%ul.nav-links
+%ul.nav-links.mobile-separator
%li{ class: active_when(scope.nil?) }>
= link_to build_path_proc.call(nil) do
All
%span.badge.js-totalbuilds-count
- = number_with_delimiter(all_builds.count(:id))
+ = limited_counter_with_delimiter(all_builds)
%li{ class: active_when(scope == 'pending') }>
= link_to build_path_proc.call('pending') do
Pending
%span.badge
- = number_with_delimiter(all_builds.pending.count(:id))
+ = limited_counter_with_delimiter(all_builds.pending)
%li{ class: active_when(scope == 'running') }>
= link_to build_path_proc.call('running') do
Running
%span.badge
- = number_with_delimiter(all_builds.running.count(:id))
+ = limited_counter_with_delimiter(all_builds.running)
%li{ class: active_when(scope == 'finished') }>
= link_to build_path_proc.call('finished') do
Finished
%span.badge
- = number_with_delimiter(all_builds.finished.count(:id))
+ = limited_counter_with_delimiter(all_builds.finished)
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index e6075c3ae3a..87c2965bb21 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -1,5 +1,6 @@
- form = local_assigns.fetch(:form)
- deploy_key = local_assigns.fetch(:deploy_key)
+- deploy_keys_project = deploy_key.deploy_keys_project_for(@project)
= form_errors(deploy_key)
@@ -20,11 +21,13 @@
.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.)
+- if deploy_keys_project.present?
+ = form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form|
+ .form-group
+ .control-label
+ .col-sm-10
+ = deploy_keys_project_form.label :can_push do
+ = deploy_keys_project_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 046b127f73c..62437f5fc9d 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -5,18 +5,23 @@
.row.empty-state
.col-xs-12
.svg-content
- = render 'shared/empty_states/icons/issues.svg'
- .col-xs-12.text-center
+ = image_tag 'illustrations/issues.svg'
+ .col-xs-12
.text-content
- - if has_button && current_user
+ - if current_user
%h4
- The Issue Tracker is the place to add things that need to be improved or solved in a project
+ = _("The Issue Tracker is the place to add things that need to be improved or solved in a project")
%p
- Issues can be bugs, tasks or ideas to be discussed.
- Also, issues are searchable and filterable.
- - if project_select_button
- = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue'
- = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
+ = _("Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.")
+ - if has_button
+ .text-center
+ - if project_select_button
+ = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues
+ - else
+ = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link'
- else
+ %h4.text-center= _("There are no issues to show")
+ %p
+ = _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
.text-center
- %h4 There are no issues to show.
+ = link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index 5e2f4cf109d..04db9de3606 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -1,11 +1,11 @@
.row.empty-state.labels
.col-xs-12
.svg-content
- = render 'shared/empty_states/icons/labels.svg'
- .col-xs-12.text-center
+ = image_tag 'illustrations/labels.svg'
+ .col-xs-12
.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.
+ %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.")
- if can?(current_user, :admin_label, @project)
- = link_to 'New label', new_namespace_project_label_path(@project.namespace, @project), class: 'btn btn-new', title: 'New label', id: 'new_label_link'
- = link_to 'Generate a default set of labels', generate_namespace_project_labels_path(@project.namespace, @project), method: :post, class: 'btn btn-success btn-inverted', title: 'Generate a default set of labels', id: 'generate_labels_link'
+ = link_to _('New label'), new_project_label_path(@project), class: 'btn btn-new', title: _('New label'), id: 'new_label_link'
+ = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn btn-success btn-inverted', title: _('Generate a default set of labels'), id: 'generate_labels_link'
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 3e64f403b8b..2edf3557df4 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -5,18 +5,19 @@
.row.empty-state.merge-requests
.col-xs-12
.svg-content
- = render 'shared/empty_states/icons/merge_requests.svg'
- .col-xs-12.text-center
+ = image_tag 'illustrations/merge_requests.svg'
+ .col-xs-12
.text-content
- if has_button
%h4
- Merge requests are a place to propose changes you've made to a project and discuss those changes with others.
+ = _("Merge requests are a place to propose changes you've made to a project and discuss those changes with others")
%p
- Interested parties can even contribute by pushing commits if they want to.
- - if project_select_button
- = render 'shared/new_project_item_select', path: 'merge_requests/new', label: 'New merge request'
- - else
- = link_to 'New merge request', button_path, class: 'btn btn-new', title: 'New merge request', id: 'new_merge_request_link'
+ = _("Interested parties can even contribute by pushing commits if they want to.")
+ .text-center
+ - if project_select_button
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests
+ - else
+ = link_to _('New merge request'), button_path, class: 'btn btn-new', title: _('New merge request'), id: 'new_merge_request_link'
- else
%h4.text-center
- There are no merge requests to show.
+ = _("There are no merge requests to show")
diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml
index bc268301a97..555cb4f4af9 100644
--- a/app/views/shared/empty_states/_priority_labels.html.haml
+++ b/app/views/shared/empty_states/_priority_labels.html.haml
@@ -1,3 +1,4 @@
.text-center
- = render 'shared/empty_states/icons/priority_labels.svg'
+ .svg-content
+ = image_tag 'illustrations/priority_labels.svg'
%p Star labels to start sorting by priority
diff --git a/app/views/shared/empty_states/icons/_issues.svg b/app/views/shared/empty_states/icons/_issues.svg
deleted file mode 100644
index 2e92bf19579..00000000000
--- a/app/views/shared/empty_states/icons/_issues.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="790 253 425 254" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="25" height="8.9423" x="25" y="88.4231" rx="2"/><mask id="h" width="25" height="8.9423" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M16 29.8013h43V91.404H16z"/><mask id="i" width="43" height="61.6026" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M57 60.6026l13.1868 9.3587c.449.3188.876 1.0142.9556 1.5673l3.5747 24.863c.1564 1.0866-.253 1.2572-.912.384L66 86.436l-9-6.9552"/><mask id="j" width="17.7504" height="36.7306" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><path id="d" d="M.2496 60.6026l13.1868 9.3587c.449.3188.876 1.0142.9556 1.5673l3.5748 24.863c.1562 1.0866-.2532 1.2572-.9123.384L9.2495 86.436l-9-6.9552"/><mask id="k" width="17.7504" height="36.7306" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><path id="e" d="M16 29.8013L35.786 1.4556c.9466-1.3562 2.4792-1.3594 3.428 0L59 29.8013"/><mask id="l" width="43" height="29.364" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><rect id="f" width="26.2653" height="35.5088" x="6.3673" rx="13.1327"/><mask id="m" width="26.2653" height="35.5088" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><rect id="g" width="16.8367" height="22.386" x="4.0816" rx="8.4184"/><mask id="n" width="16.8367" height="22.386" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(792.000000, 255.000000)"><g fill="#FDE5D8"><path d="M225.4372 59.5866c-.059.5897-.1323 1.2698-.2203 2.0305-.252 2.1764-.5717 4.559-.9653 7.07-.1283.8185.4312 1.586 1.2496 1.7143.8185.1283 1.586-.4312 1.7142-1.2497.4-2.5528.7253-4.975.9815-7.1898.0898-.7762.1646-1.4715.2252-2.0762.0366-.365.0604-.62.0722-.7557.0717-.8254-.539-1.5526-1.3645-1.6244-.8254-.0717-1.5526.539-1.6244 1.3645-.0106.1228-.0332.365-.0684.7166zM219.8738 87.9413c-.2563.7878.1745 1.6342.9622 1.8906.7878.2562 1.6342-.1745 1.8906-.9623.975-2.9962 1.849-6.2827 2.6287-9.797.1794-.8086-.3308-1.6097-1.1395-1.789-.8088-.1795-1.61.3306-1.7893 1.1394-.76 3.4256-1.6096 6.6206-2.5527 9.5183zM209.9266 103.166c-.781.2766-1.1897 1.134-.913 1.9148.2765.781 1.1338 1.1897 1.9147.913 2.9792-1.0552 5.5414-3.679 7.7796-7.6272.4084-.7207.1554-1.636-.5653-2.0447-.7207-.4086-1.636-.1556-2.0446.565-1.9152 3.3786-3.9945 5.508-6.1714 6.279zM190.439 107.5834c-.7636.3214-1.122 1.201-.8005 1.9645.3215.7634 1.201 1.1217 1.9645.8003 3.1204-1.314 6.2717-2.3243 9.258-2.9816.809-.178 1.3205-.9783 1.1424-1.7874-.178-.809-.9783-1.3205-1.7874-1.1424-3.1666.697-6.4914 1.763-9.777 3.1464zM173.231 118.6257c-.6005.5706-.6248 1.52-.0542 2.1206s1.52.625 2.1206.0543c2.282-2.1682 4.8656-4.162 7.6758-5.946.6994-.444.9064-1.371.4624-2.0704-.444-.6994-1.371-.9064-2.0704-.4624-2.9698 1.8854-5.707 3.998-8.1342 6.304zM162.4543 136.2492c-.2022.8034.2852 1.6185 1.0885 1.8207.8034.202 1.6186-.2853 1.8208-1.0886.7688-3.0547 2.0416-5.9768 3.781-8.7486.4403-.7018.2284-1.6276-.4733-2.068-.7017-.4402-1.6275-.2283-2.068.4734-1.9026 3.0322-3.3016 6.2438-4.149 9.611zM162.1894 156.693c.1036.822.854 1.4042 1.676 1.3006.8218-.1037 1.404-.854 1.3004-1.676-.367-2.9097-.5796-6.1364-.6444-9.8167-.0146-.8284-.698-1.488-1.5262-1.4734-.8283.0146-1.488.698-1.4733 1.5262.0665 3.783.286 7.1162.6674 10.1393zM168.408 176.1653c.3876.7322 1.2953 1.0117 2.0275.6242.7322-.3875 1.0117-1.2952.6242-2.0274-1.6733-3.162-2.9028-5.9954-3.8477-8.943-.2528-.789-1.0973-1.2235-1.8862-.9706-.789.2528-1.2234 1.0974-.9706 1.8863 1.0025 3.1275 2.3014 6.121 4.053 9.4306zM175.9738 188.9357c1.056 1.7165 1.8892 3.0806 2.7307 4.474.4283.709 1.3503.9368 2.0595.5085.709-.4283.9368-1.3503.5085-2.0595-.8464-1.4014-1.6836-2.772-2.7434-4.4948.0808.131-1.9545-3.1733-2.486-4.0405-.4328-.7063-1.3563-.928-2.0627-.495-.7063.4327-.928 1.3563-.495 2.0626.5334.8707 2.5708 4.1785 2.4885 4.0447zM184.83 211.3822c.011.8284.6912 1.491 1.5196 1.4803.8283-.0108 1.491-.691 1.4803-1.5194-.046-3.519-.6604-6.996-1.8367-10.3262-.276-.7812-1.1328-1.1908-1.914-.915-.781.276-1.1906 1.133-.9147 1.914 1.0668 3.0206 1.624 6.1733 1.6655 9.3664zM179.3467 229.4095c-.459.6896-.2723 1.6208.4173 2.08.6896.459 1.6208.272 2.08-.4175 1.966-2.9533 3.4756-6.124 4.4877-9.4165.2434-.7918-.2012-1.631-.993-1.8745-.792-.2434-1.6312.2012-1.8746.993-.9264 3.014-2.3108 5.922-4.1173 8.6355z"/></g><g transform="translate(336.866969, 147.225953) rotate(-300.000000) translate(-336.866969, -147.225953) translate(299.366969, 69.725953)"><path stroke="#FDE5D8" stroke-width="3" d="M19 154l10-52.6603m16 0L55 154" stroke-linecap="round"/><rect width="3" height="38.75" x="35" y="99.3526" fill="#FDE5D8" rx="1.5"/><use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#h)" xlink:href="#a"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#i)" xlink:href="#b"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#j)" xlink:href="#c"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#k)" transform="translate(9.124810, 78.967887) scale(-1, 1) translate(-9.124810, -78.967887)" xlink:href="#d"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#l)" xlink:href="#e"/><ellipse cx="28.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="34.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="40.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="46.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="37.5" cy="55.1378" stroke="#FDE5D8" stroke-width="3" rx="10.5" ry="10.4327"/><ellipse cx="37.5" cy="55.1378" stroke="#FDE5D8" stroke-width="3" rx="5.5" ry="5.4647"/></g><path fill="#EEE" d="M96.0426 37.2106c-.1512 1.6874.0814 3.815.997 6.146.2046.5207.7936.7774 1.3155.5733.522-.2043.7793-.792.5747-1.313-.7912-2.0142-.99-3.832-.865-5.226.0102-.1143.0195-.186.0238-.2113.092-.552-.2814-1.0738-.8344-1.1658-.553-.092-1.076.2808-1.168.8326-.0126.075-.0285.1975-.0434.364zM107.5302 52.8934c.4913.239 1.098.0626 1.355-.394.2572-.4566.0674-1.0205-.4238-1.2595-1.8668-.9083-3.4584-1.9152-4.7943-3.0075-.4162-.3404-1.0506-.3026-1.4168.0843-.3663.387-.3256.9766.0907 1.317 1.4583 1.1925 3.1828 2.2835 5.1893 3.2596zM120.661 58.9533c.5467.171 1.1257-.1425 1.2933-.7003.1675-.5577-.1397-1.1484-.6864-1.3194-3.0283-.9472-4.1984-1.3178-5.915-1.8824-.544-.179-1.1274.126-1.3028.6813-.1754.5552.1235 1.1504.6677 1.3294 1.729.5686 2.9053.941 5.943 1.8913zM132.5954 62.881c.449.246 1.022.0983 1.2798-.33.258-.4282.103-.975-.3458-1.221-1.4942-.819-3.1928-1.545-5.2675-2.2746-.486-.1708-1.025.0664-1.204.53-.179.4634.0697.9776.5555 1.1484 1.9832.6973 3.5892 1.3838 4.982 2.1472zM141.9774 73.383c.205.4938.809.742 1.3485.5543.5395-.1878.8106-.7404.6055-1.2344-.8504-2.0482-1.853-3.7962-3.0375-5.3046-.337-.429-.99-.527-1.4588-.2184-.4687.3085-.5755.9064-.2386 1.3354 1.0743 1.368 1.9926 2.9692 2.7808 4.8675zM144.609 87.025c.0183.5535.5682.99 1.2283.9746.66-.0153 1.1805-.4764 1.1622-1.03-.0725-2.2033-.2693-4.206-.622-6.1198-.1008-.5473-.7115-.9225-1.3642-.838-.6526.0846-1.1.597-.999 1.1442.336 1.8248.5248 3.745.5947 5.869z"/><path fill="#E5E5E5" d="M144.1423 95.7297c-.0863 2.5442-.1214 3.769-.1422 5.2548-.0076.5523.3963 1.007.9022 1.0154.506.0083.9223-.4326.93-.985.0205-1.4668.0554-2.6812.1412-5.2113l.026-.7667c.0185-.552-.3764-1.016-.882-1.0363-.5056-.0203-.9306.411-.949.963l-.026.766zM144.939 115.201c.1196.5447.6727.8925 1.2355.7768.5628-.1157.922-.651.8026-1.1957-.417-1.9-.7104-3.84-.8976-5.8637-.0513-.5545-.5574-.964-1.1305-.9142-.573.0497-.996.5396-.9448 1.0942.1944 2.1015.4998 4.121.9348 6.103zM149.995 127.5248c.296.454.9528.61 1.4668.3485.514-.2614.6907-.8413.3947-1.2952-1.0787-1.6535-2.0046-3.3145-2.7896-4.9916-.2266-.484-.8547-.7143-1.403-.5142-.548.2-.809.7546-.5823 1.2387.8208 1.7534 1.788 3.4886 2.9134 5.2138zM154.8088 135.226c1.0587 1.232 2.242 2.4097 3.543 3.531.404.3482 1.0276.3186 1.393-.066.3657-.3843.3346-.978-.0692-1.3262-1.2296-1.0597-2.345-2.17-3.3402-3.328-.195-.227-.3872-.4542-.5764-.6813-.3385-.4063-.9588-.4744-1.3856-.1522-.4267.3223-.4983.913-.1598 1.3192.1954.2346.3938.469.5952.7034zM170.634 146.9026c.4806.242 1.0517.0176 1.2758-.501.224-.5188.0162-1.1354-.4642-1.3773-1.7563-.8842-3.422-1.8432-4.9857-2.8726-.4527-.298-1.0434-.1435-1.3195.3452-.276.4885-.133 1.126.3198 1.424 1.6256 1.0704 3.354 2.0655 5.1738 2.9816z"/><path fill="#EEE" d="M184.7334 151.9698c.5527.1412 1.1072-.2262 1.2385-.8206.1312-.5944-.2104-1.1908-.763-1.332-2.001-.5114-3.9602-1.1002-5.8632-1.763-.5405-.1883-1.1205.1303-1.2955.7115-.175.5813.1212 1.205.6616 1.3934 1.9557.6813 3.9676 1.286 6.0214 1.8108zM197.9337 153.9977c.5532.04 1.0297-.445 1.0643-1.083.0346-.6383-.3857-1.188-.939-1.228-1.973-.1424-3.952-.3682-5.9206-.676-.5492-.086-1.0547.358-1.1292.9917-.0744.6336.3105 1.2168.8597 1.3027 2.0164.3154 4.0433.5467 6.0647.6927zM212.1213 152.6062c.5493-.055.9392-.4576.871-.8994-.0684-.442-.569-.7555-1.1184-.7006-1.9168.1917-3.893.3194-5.9104.382-.553.0173-.9842.392-.9628.8368.0213.445.487.7916 1.0402.7744 2.0737-.0645 4.1064-.1957 6.0803-.3932zM226.3665 149.949c.5293-.22.7755-.8162.5497-1.332-.2257-.5155-.838-.7553-1.3672-.5354-1.7815.74-3.7143 1.3827-5.7772 1.923-.5558.1454-.8852.7023-.7358 1.2436.1494.5414.721.8623 1.2768.7168 2.1547-.5643 4.1797-1.2376 6.0537-2.016zM237.8486 140.4168c.292-.4344.1488-1.006-.3202-1.2766-.469-.2706-1.086-.1378-1.3782.2967-.9575 1.4237-2.225 2.7337-3.7847 3.9202-.427.3248-.4888.9087-.138 1.3042.3505.3955.981.4528 1.408.128 1.723-1.3107 3.1363-2.7714 4.213-4.3726zM245.6725 130.6874c.3987-.3503.439-.9587.09-1.3588-.3492-.4-.9554-.4405-1.3542-.0902-1.5048 1.3222-2.8978 2.7094-4.1698 4.1635-.3497.3995-.3102 1.008.088 1.3587.3983.3508 1.0046.3113 1.3542-.0884 1.2153-1.389 2.5487-2.717 3.9918-3.985zM257.4814 122.8697c.476-.2568.657-.8577.4047-1.342-.2523-.4843-.8428-.6687-1.3188-.4118-1.7682.9542-3.4795 1.973-5.1228 3.0587-.4518.2985-.5803.9133-.287 1.373.2934.46.8975.5906 1.3494.292 1.5938-1.0528 3.2557-2.0423 4.9746-2.97zM270.276 116.9216c.5503-.1682.8513-.724.6723-1.241-.179-.5173-.77-.8003-1.3204-.632-1.9296.5898-3.932 1.2728-5.975 2.054-.536.205-.7936.7797-.5754 1.2835.218.504.8294.746 1.3654.541 1.9947-.7628 3.95-1.4298 5.833-2.0054z"/><circle cx="145" cy="90" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><circle cx="238" cy="138" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><path stroke="#B5A7DD" stroke-width="3" d="M20.0605 56s-17.4698 33-12 53c5.4697 20 17 32 38 44S78.5 148 107 159s29 43 29 43" stroke-linecap="round" stroke-dasharray="8 10"/><g stroke="#EEE" stroke-width="3" transform="translate(108.000000, 173.000000)"><path fill="#FFF" d="M154 77c0-42.526-34.474-77-77-77S0 34.474 0 77" stroke-linecap="round"/><circle cx="108" cy="41" r="16"/><circle cx="42.5" cy="30.5" r="8.5"/><circle cx="22" cy="58" r="5"/></g><g><g fill="#FC8A51" transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(19.897959, -0.000000)"><path d="M.398 11.2982h2.3877c0-4.234 3.3853-7.6666 7.5612-7.6666v-2.421C4.8522 1.2105.398 5.727.398 11.298z"/><ellipse cx="10.7449" cy="2.0175" rx="1.9898" ry="2.0175"/></g><g fill="#FC8A51" transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(12.602041, 6.000000) scale(-1, 1) translate(-12.602041, -6.000000) translate(6.102041, -0.000000)"><path d="M.398 11.2982h2.3877c0-4.234 3.3853-7.6666 7.5612-7.6666v-2.421C4.8522 1.2105.398 5.727.398 11.298z"/><ellipse cx="10.7449" cy="2.0175" rx="1.9898" ry="2.0175"/></g><g transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(0.000000, 10.491228)"><g fill="#FC8A51" transform="translate(29.448980, 11.298246)"><rect width="7.9592" height="2" x=".7959" y="8.8772" rx="1"/><rect width="7.9592" height="2" x=".7959" y="16.1404" transform="translate(4.775510, 17.140351) rotate(-345.000000) translate(-4.775510, -17.140351)" rx="1"/><rect width="7.9592" height="2" x=".9151" y="1.8072" transform="translate(4.894667, 2.807217) rotate(-15.000000) translate(-4.894667, -2.807217)" rx="1"/></g><g fill="#FC8A51" transform="translate(5.051020, 21.298246) scale(-1, 1) translate(-5.051020, -21.298246) translate(0.551020, 11.298246)"><rect width="7.9592" height="2" x=".7959" y="8.8772" rx="1"/><rect width="7.9592" height="2" x=".7959" y="16.1404" transform="translate(4.775510, 17.140351) rotate(-345.000000) translate(-4.775510, -17.140351)" rx="1"/><rect width="7.9592" height="2" x=".9151" y="1.8072" transform="translate(4.894667, 2.807217) rotate(-15.000000) translate(-4.894667, -2.807217)" rx="1"/></g><use stroke="#FC8A51" stroke-width="6" mask="url(#m)" xlink:href="#f"/><path fill="#FC8A51" d="M7.1633 12.9123H31.041v3H7.1632z"/></g></g><g><g fill="#EEE" transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(12.755102, 0.000000)"><path d="M.255 7.1228h1.5307c0-2.6694 2.17-4.8333 4.847-4.8333V.7632C3.1104.7632.255 3.6105.255 7.1228z"/><ellipse cx="6.8878" cy="1.2719" rx="1.2755" ry="1.2719"/></g><g fill="#EEE" transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(7.744898, 4.000000) scale(-1, 1) translate(-7.744898, -4.000000) translate(3.244898, 0.000000)"><path d="M.255 7.1228h1.5307c0-2.6694 2.17-4.8333 4.847-4.8333V.7632C3.1104.7632.255 3.6105.255 7.1228z"/><ellipse cx="6.8878" cy="1.2719" rx="1.2755" ry="1.2719"/></g><g transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(0.000000, 6.614035)"><g fill="#EEE" transform="translate(18.877551, 7.122807)"><rect width="5.102" height="2" x=".5102" y="5.5965" rx="1"/><rect width="5.102" height="2" x=".5102" y="10.1754" transform="translate(3.061224, 11.175439) rotate(-345.000000) translate(-3.061224, -11.175439)" rx="1"/><rect width="5.102" height="2" x=".5866" y="1.1393" transform="translate(3.137607, 2.139333) rotate(-15.000000) translate(-3.137607, -2.139333)" rx="1"/></g><g fill="#EEE" transform="translate(3.122449, 13.622807) scale(-1, 1) translate(-3.122449, -13.622807) translate(0.122449, 7.122807)"><rect width="5.102" height="2" x=".5102" y="5.5965" rx="1"/><rect width="5.102" height="2" x=".5102" y="10.1754" transform="translate(3.061224, 11.175439) rotate(-345.000000) translate(-3.061224, -11.175439)" rx="1"/><rect width="5.102" height="2" x=".5866" y="1.1393" transform="translate(3.137607, 2.139333) rotate(-15.000000) translate(-3.137607, -2.139333)" rx="1"/></g><use stroke="#EEE" stroke-width="4" mask="url(#n)" xlink:href="#g"/><path fill="#EEE" d="M4.5918 8.1404h15.306v2H4.592z"/></g></g><g fill="#FFF" transform="translate(0.000000, 103.000000)"><circle cx="8.5" cy="8.5" r="8.5" stroke="#B5A7DD" stroke-width="4"/><circle cx="171.5" cy="20.5" r="6.5"/></g><g><g transform="translate(39.000000, 142.000000)"><ellipse cx="12.5" cy="12.5" fill="#FFF" stroke="#6B4FBB" stroke-width="4" rx="12.5" ry="12.5"/><path fill="#FC8A51" d="M10.7322 13.475l-1.7665-1.7667c-.5873-.5873-1.5368-.587-2.1226-.0012-.5897.59-.585 1.5362.0013 2.1226l2.826 2.826.0007.0007.0006.0006c.5898.5897 1.534.587 2.118.003l6.3704-6.3703c.577-.577.5826-1.5323-.003-2.118-.59-.59-1.5343-.5873-2.1183-.0033l-5.3065 5.3065z"/></g></g><circle cx="171.5" cy="122.5" r="6.5" fill="#FFF" stroke="#FC8A51" stroke-width="3"/><circle cx="22" cy="52" r="6" fill="#FFF" stroke="#B5A7DD" stroke-width="3"/><path fill="#FFF" stroke="#B5A7DD" stroke-width="3.6" d="M188.151 141.596c8.7045-7.7456 11.0126-20.9255 4.8625-31.5777-7.0208-12.1604-22.4055-16.422-34.363-9.5183-11.9572 6.9036-15.959 22.358-8.9382 34.5183 6.2353 10.8 19.068 15.3695 30.2375 11.4206l10.8992 18.8778c1.3167 2.2807 4.2302 3.063 6.5078 1.748 2.273-1.3122 3.0567-4.2295 1.74-6.51l-10.9458-18.9587zm-8.4343-4.6086c7.8576-4.5366 10.4874-14.6923 5.8738-22.6834-4.6137-7.991-14.7237-10.7915-22.5814-6.255-7.8575 4.5368-10.4873 14.6925-5.8737 22.6836 4.6137 7.991 14.7237 10.7915 22.5814 6.2548z"/></g></svg>
diff --git a/app/views/shared/empty_states/icons/_labels.svg b/app/views/shared/empty_states/icons/_labels.svg
deleted file mode 100644
index dc041a4a78b..00000000000
--- a/app/views/shared/empty_states/icons/_labels.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="787 240 386 274" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><circle id="a" cx="37" cy="107" r="8"/><mask id="e" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><circle id="b" cx="37" cy="75" r="8"/><mask id="f" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><circle id="c" cx="42" cy="93" r="8"/><mask id="g" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><circle id="d" cx="43" cy="75" r="8"/><mask id="h" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(791 244)"><g transform="rotate(30 49.554 229.722)"><rect width="74" height="124" x="8.6" y="95.9" fill="#FAFAFA" rx="8"/><rect width="74" height="124" y="87" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><circle cx="26.5" cy="178.5" r="3.5" fill="#FC8A51"/><circle cx="47.5" cy="178.5" r="3.5" fill="#FC8A51"/><rect width="50" height="4" x="12" y="127" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="18" y="139" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#e)" stroke-linecap="round" xlink:href="#a"/><path stroke="#EEE" stroke-width="4" d="M37.3 107S10.5 18.3 81 .6" stroke-linecap="round"/><path fill="#FDE5D8" d="M31 189c0 3.3 2.7 6 6 6s6-2.7 6-6"/></g><g transform="translate(105 47)"><rect width="74" height="124" y="64" fill="#FAFAFA" rx="8"/><rect width="74" height="124" y="55" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><rect width="50" height="4" x="12" y="95" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="18" y="107" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#f)" stroke-linecap="round" xlink:href="#b"/><path fill="#B5A7DD" d="M56 149.7c-.6-1-.2-2 .7-2.7l1.8-1c1-.6 2-.2 2.7.7.5 1 .2 2.2-.7 2.8l-1.8 1c-1 .5-2 .2-2.7-.8zm-37.8 0c.5-1 .2-2-.7-2.7l-1.8-1c-1-.6-2-.2-2.7.7-.6 1-.2 2.2.7 2.8l1.8 1c1 .5 2 .2 2.7-.8zM33 151h9v4h-9v-4z"/><path fill="#6B4FBB" d="M59 153c0-5.5-4.6-10-10-10-5.7 0-10 4.5-10 10s4.3 10 10 10c5.4 0 10-4.5 10-10zm-16 0c0-3.3 2.6-6 6-6 3.2 0 6 2.7 6 6s-2.8 6-6 6c-3.4 0-6-2.7-6-6zM35 153c0-5.5-4.6-10-10-10-5.7 0-10 4.5-10 10s4.3 10 10 10c5.4 0 10-4.5 10-10zm-16 0c0-3.3 2.6-6 6-6 3.2 0 6 2.7 6 6s-2.8 6-6 6c-3.4 0-6-2.7-6-6z"/><path stroke="#EEE" stroke-width="4" d="M37 75S30 0 80 0" stroke-linecap="round"/></g><g transform="rotate(15 -82.507 752.644)"><rect width="74" height="124" x="14.6" y="81.8" fill="#FAFAFA" rx="8"/><rect width="74" height="124" x="5" y="73" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><path fill="#FDE5D8" d="M41 147c0-1 1-2 2-2s2 1 2 2v3c0 1-1 2-2 2s-2-1-2-2v-3zm16.8 6.2c.8-.7 2-.6 2.8.3.7.8.5 2-.3 2.8L58 158c-1 .8-2.2.7-3 0-.6-1-.4-2.3.4-3l2.4-1.8zm-32 3c-1-.6-1-2-.4-2.7.7-1 2-1 2.8-.3l2.4 1.8c.8.7 1 2 .3 3-.8.7-2 1-3 0l-2.3-1.7z"/><rect width="2" height="7" x="39" y="168" fill="#FC8A51" rx="1"/><rect width="2" height="7" x="45" y="168" fill="#FC8A51" rx="1"/><circle cx="40" cy="169" r="2" fill="#FC8A51"/><circle cx="46" cy="169" r="2" fill="#FC8A51"/><rect width="22" height="18" x="32" y="158" stroke="#FC8A51" stroke-width="4" rx="8"/><rect width="34" height="5" x="26" y="174" fill="#FC8A51" rx="2.5"/><rect width="50" height="4" x="17" y="113" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="23" y="125" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#g)" stroke-linecap="round" xlink:href="#c"/><path stroke="#EEE" stroke-width="4" d="M42 93S50 0 0 0" stroke-linecap="round"/></g><g transform="rotate(-15 276.18 -697.744)"><rect width="74" height="124" x="18.7" y="65.6" fill="#FAFAFA" rx="8"/><rect width="74" height="124" x="6" y="55" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><g transform="translate(25 129)"><path stroke="#B5A7DD" stroke-width="4" d="M32 14c0-7.7-6.3-14-14-14S4 6.3 4 14" stroke-linecap="round"/><path stroke="#B5A7DD" stroke-width="2" d="M33 15v13c0 4.4-3.6 8-8 8" stroke-linecap="round"/><rect width="7" height="4" x="20" y="34" fill="#6B4FBB" rx="2"/><rect width="7" height="13" y="15" fill="#FFF" stroke="#6B4FBB" stroke-width="3" stroke-linejoin="round" rx="3.5"/><rect width="7" height="13" x="29" y="15" fill="#FFF" stroke="#6B4FBB" stroke-width="3" stroke-linejoin="round" transform="matrix(-1 0 0 1 65 0)" rx="3.5"/></g><rect width="50" height="4" x="18" y="95" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="24" y="107" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#h)" stroke-linecap="round" xlink:href="#d"/><path stroke="#EEE" stroke-width="4" d="M43 75S50 0 0 0" stroke-linecap="round"/></g><circle cx="193" cy="47" r="12" fill="#FFF" stroke="#FDE5D8" stroke-width="4"/><circle cx="193" cy="47" r="5" fill="#FFF" stroke="#FDE5D8" stroke-width="4"/><g opacity=".2"><path fill="#FC8A51" d="M30.7 254.8l-2.6 1c-1 .5-1.7 0-1.7-1v-3l-1-2.7c-.4-1 .2-1.7 1.2-1.7h3l2.6-1c1.2-.4 2 .2 2 1.2l-.2 3 1 2.6c.5 1.2 0 2-1 2l-3-.2zM374.7 133.8l-2.6 1c-1 .5-1.7 0-1.7-1v-3l-1-2.7c-.4-1 .2-1.7 1.2-1.7h3l2.6-1c1.2-.4 2 .2 2 1.2l-.2 3 1 2.6c.5 1.2 0 2-1 2l-3-.2zM5.6 95H1.8c-1.3.2-2-.8-1.4-2l1.4-3.4-.2-3.8c0-1.3 1-2 2-1.4l3.6 1.4 3.7-.2c1.2 0 2 1 1.4 2L11 91.3V95c.2 1.2-.8 2-2 1.4L5.6 95z"/><path fill="#6B4FBB" d="M308.8 62l-2-2.3c-.7-.8-.5-1.7.6-2l2.8-1 2-2c1-.6 1.8-.4 2.2.7l.8 2.8 2 2c.8 1 .5 1.8-.5 2.2l-2.8.8-2.3 2c-.8.8-1.7.5-2-.5l-1-2.8zM318 226.6h-3c-1-.2-1.4-1-1-2l1.4-2.5v-3c.2-1 1-1.4 2-1l2.6 1.4h3c1 .2 1.5 1 1 2l-1.4 2.6v3c-.2 1-1 1.5-2 1l-2.5-1.4zM121.8 8l-2-2.3c-.7-.8-.5-1.7.6-2l2.8-1 2-2c1-.6 1.8-.4 2.2.7l.8 2.8 2 2c.8 1 .5 1.8-.5 2.2l-2.8.8-2.3 2c-.8.8-1.7.5-2-.5l-1-2.8z"/></g></g></svg>
diff --git a/app/views/shared/empty_states/icons/_merge_requests.svg b/app/views/shared/empty_states/icons/_merge_requests.svg
deleted file mode 100644
index e77f6319a95..00000000000
--- a/app/views/shared/empty_states/icons/_merge_requests.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="755 221 385 225" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="278" height="179" rx="10"/><mask id="d" width="278" height="179" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M13.6 49H57c5.5 0 10-4.5 10-10V10c0-5.5-4.5-10-10-10H10C4.5 0 0 4.5 0 10v42c0 5.5 3.2 7 7.2 3l6.4-6z"/><mask id="e" width="67" height="57.2" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M13.6 49H57c5.5 0 10-4.5 10-10V10c0-5.5-4.5-10-10-10H10C4.5 0 0 4.5 0 10v42c0 5.5 3.2 7 7.2 3l6.4-6z"/><mask id="f" width="67" height="57.2" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#F9F9F9" transform="translate(752 227)"><rect width="120" height="22" x="30" rx="11"/><rect width="132" height="22" y="44" rx="11"/><rect width="190" height="22" x="208" y="66" rx="11"/><rect width="158" height="22" x="129" y="197" rx="11"/><rect width="158" height="22" x="66" y="154" rx="11"/><rect width="350" height="22" x="31" y="110" rx="11"/><path d="M153 22H21h21.5c6 0 11 5 11 11s-5 11-11 11H21h132-36.5c-6 0-11-5-11-11s5-11 11-11H153zm252 66H288h36.5c6 0 11 5 11 11s-5 11-11 11H288h117-36.5c-6 0-11-5-11-11s5-11 11-11H405zm-244 44H44h36.5c6 0 11 5 11 11s-5 11-11 11H44h117-36.5c-6 0-11-5-11-11s5-11 11-11H161zm75 44H119h21.5c6 0 11 5 11 11s-5 11-11 11H119h117-51.5c-6 0-11-5-11-11s5-11 11-11H236z"/></g><g transform="translate(812 240)"><use fill="#FFF" stroke="#EEE" stroke-width="8" mask="url(#d)" xlink:href="#a"/><path fill="#EEE" d="M4 29h271v4H4z"/><g transform="translate(34 60)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(34 93)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#FC6D26" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#FC6D26" opacity=".5" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#EEE" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" opacity=".5" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(34 126)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#EEE" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" opacity=".5" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(157 59)"><rect width="6" height="2" y="1" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="48" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="23" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="34" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" y="33" fill="#EEE" rx="2"/><rect width="15" height="4" x="58" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="55" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="15" height="4" x="29" y="44" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="48" y="33" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="15" y="55" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" y="33" fill="#EEE" rx="2"/><rect width="10" height="4" x="15" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="48" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="62" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="77" y="22" fill="#EEE" rx="2"/><rect width="6" height="2" y="45" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="56" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="67" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" y="66" fill="#6B4FBB" rx="2"/><rect width="15" height="4" x="39" y="88" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="77" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="88" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="77" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="34" y="66" fill="#EEE" rx="2"/><rect width="10" height="4" x="72" y="77" fill="#EEE" rx="2"/><rect width="10" height="4" x="15" y="77" fill="#EEE" rx="2"/><rect width="6" height="2" y="78" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="89" fill="#FDE5D8" rx="1"/></g></g><g transform="translate(1057 221)"><use fill="#FFF" stroke="#FDE5D8" stroke-width="8" mask="url(#e)" xlink:href="#b"/><rect width="29" height="3" x="14" y="14" fill="#FDB692" rx="1.5"/><rect width="39" height="3" x="14" y="23" fill="#FDB692" rx="1.5"/><rect width="29" height="3" x="14" y="32" fill="#FDB692" rx="1.5"/></g><g transform="translate(1046 285)"><circle cx="16" cy="15" r="15" fill="#FFF7F4" stroke="#FC6D26" stroke-width="3"/><path stroke="#FC6D26" stroke-width="2" d="M0 14h1c5 0 9.2-2.7 11.4-6.7M14 1V0"/><path stroke="#FC6D26" stroke-width="2" d="M7.8 3c3 4.3 7.8 7 13.2 7 3.3 0 6.3-1 9-2.7"/><circle cx="10.5" cy="17.5" r="1.5" fill="#FC6D26"/><circle cx="21.5" cy="17.5" r="1.5" fill="#FC6D26"/></g><g transform="translate(825 370)"><circle cx="15" cy="16" r="15" fill="#F4F1FA" stroke="#6B4FBB" stroke-width="3"/><path fill="#6B4FBB" d="M25 7h2.7C25 2.8 20.4 0 15 0 9.6 0 5 2.8 2.3 7H5l2.5-3L10 7l2.5-3L15 7l2.5-3L20 7l2.5-3L25 7z"/><circle cx="9.5" cy="17.5" r="1.5" fill="#6B4FBB"/><circle cx="20.5" cy="17.5" r="1.5" fill="#6B4FBB"/></g><g transform="matrix(-1 0 0 1 840 306)"><use fill="#FFF" stroke="#E2DCF2" stroke-width="8" mask="url(#f)" xlink:href="#c"/><rect width="29" height="3" x="24" y="14" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="19" height="3" x="34" y="23" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="19" height="3" x="34" y="32" fill="#6B4FBB" opacity=".5" rx="1.5"/></g></g></svg>
diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg
deleted file mode 100644
index 7c672538097..00000000000
--- a/app/views/shared/empty_states/icons/_pipelines_empty.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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/empty_states/icons/_pipelines_failed.svg b/app/views/shared/empty_states/icons/_pipelines_failed.svg
deleted file mode 100644
index 7dbabf7e4ef..00000000000
--- a/app/views/shared/empty_states/icons/_pipelines_failed.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 446 249" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="0" d="m260.03 114h23.972v-.013c19.972-.53 36-16.887 36-36.987 0-20.435-16.565-37-37-37-.993 0-1.977.039-2.95.116-4.95-14.605-18.773-25.12-35.05-25.12-5.464 0-10.652 1.185-15.32 3.311-6.649-9.841-17.909-16.311-30.68-16.311-20.435 0-37 16.565-37 37 0 .701.019 1.397.058 2.088-16.11 3.999-28.06 18.561-28.06 35.912 0 20.435 16.565 37 37 37 .324 0 .646-.004.968-.012"/><ellipse id="2" cx="41" cy="41" rx="41" ry="41"/><mask id="1" width="186" height="112" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="3" width="82" height="82" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="matrix(.86603.5-.5.86603 228.11 137.43)"><path stroke="#b5a7dd" stroke-width="4" d="m.445.161c15.89 10.636 34.998 16.839 55.55 16.839"/><g transform="translate(56 4)"><path fill="#fb722e" d="m16 8c0-1.105.902-2 2.01-2h7.983c1.109 0 2.01.888 2.01 2 0 1.105-.902 2-2.01 2h-7.983c-1.109 0-2.01-.888-2.01-2m0 10c0-1.105.902-2 2.01-2h7.983c1.109 0 2.01.888 2.01 2 0 1.105-.902 2-2.01 2h-7.983c-1.109 0-2.01-.888-2.01-2"/><path fill="#fde5d8" fill-rule="nonzero" d="m4 22h6c3.315 0 6-2.685 6-5.997v-6.01c0-3.315-2.684-5.997-6-5.997h-6v18m-4-18.992c0-1.661 1.343-3.01 2.994-3.01h7.01c5.523 0 10 4.47 10 9.997v6.01c0 5.521-4.476 9.997-10 9.997h-7.01c-1.654 0-2.994-1.343-2.994-3.01v-19.984"/></g></g><g fill-rule="nonzero" transform="translate(257)"><path fill="#e5e5e5" d="m3.597 18.747c5.611-9.09 15.519-14.747 26.403-14.747 17.12 0 31 13.879 31 31 0 7.02-2.34 13.685-6.58 19.1l3.149 2.466c4.786-6.111 7.431-13.639 7.431-21.565 0-19.33-15.67-35-35-35-12.286 0-23.476 6.384-29.808 16.647l3.404 2.1"/><g transform="matrix(.96593.25882-.25882.96593 15.98 9.578)"><path fill="#b5a7dd" d="m12.426 11.592l-2.142 1.768-3.664-2.116c-.186-.107-.43-.042-.543.154l-1.229 2.129c-.116.2-.052.438.138.547l3.658 2.112-.455 2.735c-.109.657-.165 1.327-.165 2.01 0 .678.055 1.348.165 2.01l.455 2.735-3.658 2.112c-.186.107-.251.351-.138.547l1.229 2.129c.116.2.353.264.543.154l3.664-2.116 2.142 1.768c1.036.855 2.205 1.533 3.462 2l2.6.972v4.225c0 .215.179.393.405.393h2.458c.231 0 .405-.174.405-.393v-4.225l2.6-.972c1.257-.47 2.426-1.147 3.462-2l2.142-1.768 3.664 2.116c.186.107.43.042.543-.154l1.229-2.129c.116-.2.052-.438-.138-.547l-3.658-2.112.455-2.735c.109-.657.165-1.327.165-2.01 0-.678-.055-1.348-.165-2.01l-.455-2.735 3.658-2.112c.186-.107.251-.351.138-.547l-1.229-2.129c-.116-.2-.353-.264-.543-.154l-3.664 2.116-2.142-1.768c-1.036-.855-2.205-1.533-3.462-2l-2.6-.972v-4.225c0-.215-.179-.393-.405-.393h-2.458c-.231 0-.405.174-.405.393v4.225l-2.6.972c-1.257.47-2.426 1.147-3.462 2m2.062-5.749v-1.45c0-2.426 1.963-4.393 4.405-4.393h2.458c2.433 0 4.405 1.967 4.405 4.393v1.45c1.689.631 3.243 1.538 4.608 2.665l1.259-.727c2.101-1.213 4.786-.497 6.01 1.618l1.229 2.129c1.216 2.107.499 4.798-1.602 6.01l-1.257.726c.144.866.219 1.755.219 2.662 0 .907-.075 1.796-.219 2.662l1.257.726c2.101 1.213 2.823 3.896 1.602 6.01l-1.229 2.129c-1.216 2.107-3.906 2.832-6.01 1.618l-1.259-.727c-1.365 1.127-2.92 2.034-4.608 2.665v1.45c0 2.426-1.963 4.393-4.405 4.393h-2.458c-2.433 0-4.405-1.967-4.405-4.393v-1.45c-1.689-.631-3.243-1.538-4.608-2.665l-1.259.727c-2.101 1.213-4.786.497-6.01-1.618l-1.229-2.129c-1.216-2.107-.499-4.798 1.602-6.01l1.257-.726c-.144-.866-.219-1.755-.219-2.662 0-.907.075-1.796.219-2.662l-1.257-.726c-2.101-1.213-2.823-3.896-1.602-6.01l1.229-2.129c1.216-2.107 3.906-2.832 6.01-1.618l1.259.727c1.365-1.127 2.92-2.034 4.608-2.665"/><path fill="#6b4fbb" d="m20.12 23.366c1.347 0 2.439-1.092 2.439-2.439 0-1.347-1.092-2.439-2.439-2.439-1.347 0-2.439 1.092-2.439 2.439 0 1.347 1.092 2.439 2.439 2.439m0 4c-3.556 0-6.439-2.883-6.439-6.439 0-3.556 2.883-6.439 6.439-6.439 3.556 0 6.439 2.883 6.439 6.439 0 3.556-2.883 6.439-6.439 6.439"/></g></g><use fill="#fff" stroke="#e5e5e5" stroke-width="8" mask="url(#1)" stroke-linejoin="round" xlink:href="#0"/><g transform="translate(175 58)"><use fill="#fff" stroke="#e5e5e5" stroke-width="8" mask="url(#3)" xlink:href="#2"/><g fill-rule="nonzero"><path fill="#e5e5e5" d="m41 78c20.435 0 37-16.565 37-37 0-20.435-16.565-37-37-37-20.435 0-37 16.565-37 37 0 20.435 16.565 37 37 37m0 4c-22.644 0-41-18.356-41-41 0-22.644 18.356-41 41-41 22.644 0 41 18.356 41 41 0 22.644-18.356 41-41 41"/><g transform="matrix(.96593.25882-.25882.96593 23.581 9.415)"><path fill="#b5a7dd" d="m14.821 13.655l-2.142 1.768-3.933-2.271c-.72-.416-1.634-.171-2.046.543l-1.507 2.61c-.409.708-.161 1.631.553 2.043l3.926 2.267-.455 2.735c-.145.869-.218 1.754-.218 2.65 0 .896.073 1.782.218 2.65l.455 2.735-3.926 2.267c-.72.416-.965 1.329-.553 2.043l1.507 2.61c.409.708 1.332.955 2.046.543l3.933-2.271 2.142 1.768c1.369 1.131 2.916 2.027 4.579 2.648l2.6.972v4.534c0 .831.669 1.5 1.493 1.5h3.01c.817 0 1.493-.676 1.493-1.5v-4.534l2.6-.972c1.663-.621 3.21-1.518 4.579-2.648l2.142-1.768 3.933 2.271c.72.416 1.634.171 2.046-.543l1.507-2.61c.409-.708.161-1.631-.553-2.043l-3.926-2.267.455-2.735c.145-.869.218-1.754.218-2.65 0-.896-.073-1.782-.218-2.65l-.455-2.735 3.926-2.267c.72-.416.965-1.329.553-2.043l-1.507-2.61c-.409-.708-1.332-.955-2.046-.543l-3.933 2.271-2.142-1.768c-1.369-1.131-2.916-2.027-4.579-2.648l-2.6-.972v-4.534c0-.831-.669-1.5-1.493-1.5h-3.01c-.817 0-1.493.676-1.493 1.5v4.534l-2.6.972c-1.663.621-3.21 1.518-4.579 2.648m3.179-6.395v-1.759c0-3.038 2.471-5.5 5.493-5.5h3.01c3.034 0 5.493 2.46 5.493 5.5v1.759c2.098.784 4.03 1.91 5.725 3.311l1.528-.882c2.631-1.519 5.999-.61 7.51 2.01l1.507 2.61c1.517 2.627.616 5.987-2.02 7.507l-1.525.881c.179 1.076.272 2.18.272 3.307 0 1.127-.093 2.231-.272 3.307l1.525.881c2.631 1.519 3.528 4.89 2.02 7.507l-1.507 2.61c-1.517 2.627-4.877 3.527-7.51 2.01l-1.528-.882c-1.696 1.401-3.627 2.527-5.725 3.311v1.759c0 3.038-2.471 5.5-5.493 5.5h-3.01c-3.034 0-5.493-2.46-5.493-5.5v-1.759c-2.098-.784-4.03-1.91-5.725-3.311l-1.528.882c-2.631 1.519-5.999.61-7.51-2.01l-1.507-2.61c-1.517-2.627-.616-5.987 2.02-7.507l1.525-.881c-.179-1.076-.272-2.18-.272-3.307 0-1.127.093-2.231.272-3.307l-1.525-.881c-2.631-1.519-3.528-4.89-2.02-7.507l1.507-2.61c1.517-2.627 4.877-3.527 7.51-2.01l1.528.882c1.696-1.401 3.627-2.527 5.725-3.311"/><path fill="#6b4fbb" d="m25 30c2.209 0 4-1.791 4-4 0-2.209-1.791-4-4-4-2.209 0-4 1.791-4 4 0 2.209 1.791 4 4 4m0 4c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8"/></g></g></g><g transform="translate(140 161)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 8.541v30.01c0 2.202 1.793 3.995 4 3.995h20c2.209 0 4-1.789 4-3.995v-30.01c0-2.202-1.793-3.995-4-3.995h-20c-2.209 0-4 1.789-4 3.995m-4 0c0-4.416 3.583-7.995 8-7.995h20c4.416 0 8 3.584 8 7.995v30.01c0 4.416-3.583 7.995-8 7.995h-20c-4.416 0-8-3.584-8-7.995v-30.01"/><g fill="#fb722e"><rect width="4" height="11" x="10" y="18.545" rx="2"/><rect width="4" height="11" x="21" y="18.545" rx="2"/></g></g><path fill="#e5e5e5" fill-rule="nonzero" d="m445.16 245.34c-16.874-11.778-110.62-20.336-222.14-20.336-111.61 0-205.4 8.571-222.18 20.364-.904.635-1.121 1.883-.486 2.786.635.904 1.883 1.121 2.786.486 15.756-11.07 109.46-19.636 219.88-19.636 110.34 0 203.99 8.55 219.85 19.617.906.632 2.153.41 2.785-.495.632-.906.41-2.153-.495-2.785"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/empty_states/icons/_priority_labels.svg b/app/views/shared/empty_states/icons/_priority_labels.svg
deleted file mode 100644
index 7282c2b215e..00000000000
--- a/app/views/shared/empty_states/icons/_priority_labels.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="116" height="68" viewBox="181 0 116 68"><g fill="none" fill-rule="evenodd" transform="translate(182)"><rect width="78" height="34" x="37" y="34" fill="#FAFAFA" rx="3"/><rect width="78" height="34" x="31" y="28" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="3"/><path fill="#FFF" stroke="#FC6D26" stroke-width="3" d="M34 35.8c-.6 0-1.4 0-1.8.4L29 38.8c-1 .7-1.7.4-2-.7l-.6-4c0-.5-.5-1.2-1-1.5L22 30.2c-1-.6-1-1.5 0-2l3.7-2c.5-.2 1-.8 1.2-1.3l1-4.2c.3-1 1-1.3 2-.5l3 3c.3.3 1 .6 1.6.6l4.2-.3c1 0 1.5.7 1 1.7L38 29c-.3.6-.3 1.4 0 2l1.3 3.8c.4 1 0 1.8-1.2 1.6l-4-.6z" stroke-linecap="round"/><path fill="#FDE5D8" d="M51.6 14.3c-.2-.2-.8-.4-1-.3l-2.8.5c-.7 0-1-.4-.8-1l1-2.8V9.5L46.6 7c-.3-.7 0-1.2.8-1h2.7c.3 0 .8-.2 1-.5l2-2c.6-.5 1-.4 1.3.3l.7 2.8c0 .3.4.8.7 1l2.3 1.2c.7.3.7 1 0 1.3l-2.2 1.7-.6 1-.4 3c-.2.6-.7.8-1.3.4l-2-1.7zM5.4 43.2c-.2-.2-.5-.2-.7-.2l-1.8.3c-.6 0-1-.2-.7-.7l.7-1.8V40l-1-1.7c0-.4 0-.7.6-.7h1.8c.3 0 .6 0 .8-.2L6.5 36c.3-.3.7-.2.8.2l.5 2 .5.5 1.6.8c.3.2.3.7 0 1l-1.6 1c-.2 0-.4.4-.4.7l-.4 2c0 .3-.4.5-.8.2l-1.4-1.2zM10.4 9.2C10.2 9 10 9 9.7 9L8 9.3c-.6 0-1-.2-.7-.7L8 6.8V6L7 4.3c0-.4 0-.7.6-.7h1.8c.3 0 .6 0 .8-.2L11.5 2c.3-.3.7-.2.8.2l.5 2 .5.5 1.6.8c.3.2.3.7 0 1l-1.6 1c-.2 0-.4.4-.4.7l-.4 2c0 .3-.4.5-.8.2l-1.4-1.2z"/><rect width="52" height="4" x="43" y="38" fill="#EEE" rx="2"/><rect width="36" height="4" x="43" y="48" fill="#EEE" rx="2"/></g></svg>
diff --git a/app/views/shared/empty_states/icons/_todos_all_done.svg b/app/views/shared/empty_states/icons/_todos_all_done.svg
deleted file mode 100644
index 94b5c2e0ea0..00000000000
--- a/app/views/shared/empty_states/icons/_todos_all_done.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg viewBox="0 0 293 216"><g fill="none" fill-rule="evenodd"><g transform="rotate(-5 211.388 -693.89)"><rect width="163.6" height="200" x=".2" fill="#FFF" stroke="#EEE" stroke-width="3" stroke-linecap="round" stroke-dasharray="6 9" rx="6"/><g transform="translate(24 38)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="76" height="3" x="40" y="11" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#6B4FBB" opacity=".5" rx="1.5"/></g><g transform="translate(24 83)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="76" height="3" x="40" y="11" fill="#B5A7DD" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#B5A7DD" rx="1.5"/></g><g transform="translate(24 130)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="76" height="3" x="40" y="11" fill="#B5A7DD" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#B5A7DD" rx="1.5"/></g></g><path fill="#FFCE29" d="M30 11l-1.8 4-2-4-4-1.8 4-2 2-4 2 4 4 2M286 60l-2.7 6.3-3-6-6-3 6-3 3-6 2.8 6.2 6.6 2.8M263 97l-2 4-2-4-4-2 4-2 2-4 2 4 4 2M12 85l-2.7 6.3-3-6-6-3 6-3 3-6 2.8 6.2 6.6 2.8"/></g></svg>
diff --git a/app/views/shared/empty_states/icons/_todos_empty.svg b/app/views/shared/empty_states/icons/_todos_empty.svg
deleted file mode 100644
index b1e661268fb..00000000000
--- a/app/views/shared/empty_states/icons/_todos_empty.svg
+++ /dev/null
@@ -1,110 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 284 337" xmlns:xlink="http://www.w3.org/1999/xlink">
- <defs>
- <rect id="a" width="180" height="220" x="66.2" y="74.4" rx="6"/>
- <mask id="l" width="180" height="220" x="0" y="0" fill="#fff">
- <use xlink:href="#a"/>
- </mask>
- <rect id="b" width="180" height="220" rx="6"/>
- <mask id="m" width="180" height="220" x="0" y="0" fill="#fff">
- <use xlink:href="#b"/>
- </mask>
- <rect id="c" width="28" height="28" rx="4"/>
- <mask id="n" width="28" height="28" x="0" y="0" fill="#fff">
- <use xlink:href="#c"/>
- </mask>
- <rect id="d" width="28" height="28" rx="4"/>
- <mask id="o" width="28" height="28" x="0" y="0" fill="#fff">
- <use xlink:href="#d"/>
- </mask>
- <circle id="e" cx="21.5" cy="21.5" r="21.5"/>
- <mask id="p" width="43" height="43" x="0" y="0" fill="#fff">
- <use xlink:href="#e"/>
- </mask>
- <circle id="f" cx="26.5" cy="26.5" r="26.5"/>
- <mask id="q" width="53" height="53" x="0" y="0" fill="#fff">
- <use xlink:href="#f"/>
- </mask>
- <circle id="g" cx="9.5" cy="4.5" r="4.5"/>
- <mask id="r" width="13" height="13" x="-2" y="-2">
- <path fill="#fff" d="M3-2h13v13H3z"/>
- <use xlink:href="#g"/>
- </mask>
- <circle id="h" cx="26.5" cy="26.5" r="26.5"/>
- <mask id="s" width="53" height="53" x="0" y="0" fill="#fff">
- <use xlink:href="#h"/>
- </mask>
- <circle id="i" cx="21.5" cy="21.5" r="21.5"/>
- <mask id="t" width="43" height="43" x="0" y="0" fill="#fff">
- <use xlink:href="#i"/>
- </mask>
- <path id="j" d="M18 38h15c10.5 0 19-8.5 19-19S43.5 0 33 0H19C8.5 0 0 8.5 0 19c0 6.3 3 12 7.8 15.3l5.2 9c.6 1 1.4 1 2 0l3-5.3z"/>
- <mask id="u" width="52" height="44" x="0" y="0" fill="#fff">
- <use xlink:href="#j"/>
- </mask>
- <circle id="k" cx="18.5" cy="18.5" r="18.5"/>
- <mask id="v" width="37" height="37" x="0" y="0" fill="#fff">
- <use xlink:href="#k"/>
- </mask>
- </defs>
- <g fill="none" fill-rule="evenodd" transform="translate(-6 -4)">
- <use stroke="#EEE" stroke-width="6" mask="url(#l)" transform="rotate(-5 156.245 184.425)" xlink:href="#a"/>
- <g transform="rotate(5 -707.333 618.042)">
- <use fill="#FFF" stroke="#EEE" stroke-width="6" mask="url(#m)" xlink:href="#b"/>
- <g transform="translate(29 24)">
- <path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/>
- <path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/>
- <rect width="86" height="3" x="40" y="11" fill="#6B4FBB" opacity=".5" rx="1.5"/>
- <rect width="43" height="3" x="40" y="21" fill="#6B4FBB" opacity=".5" rx="1.5"/>
- </g>
- <g transform="translate(29 69)">
- <path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/>
- <path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/>
- <rect width="86" height="3" x="40" y="11" fill="#B5A7DD" rx="1.5"/>
- <rect width="43" height="3" x="40" y="21" fill="#B5A7DD" rx="1.5"/>
- </g>
- <g transform="translate(28 160)">
- <use stroke="#E5E5E5" stroke-width="6" mask="url(#n)" opacity=".7" xlink:href="#c"/>
- <rect width="26" height="3" x="41" y="7" fill="#ECECEC" rx="1.5"/>
- <rect width="43" height="3" x="41" y="17" fill="#ECECEC" rx="1.5"/>
- </g>
- <g transform="translate(28 116)">
- <use stroke="#E5E5E5" stroke-width="6" mask="url(#o)" xlink:href="#d"/>
- <rect width="86" height="3" x="41" y="7" fill="#E5E5E5" rx="1.5"/>
- <rect width="43" height="3" x="41" y="17" fill="#E5E5E5" rx="1.5"/>
- </g>
- </g>
- <g transform="rotate(-15 601.917 -782.362)">
- <use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#p)" xlink:href="#e"/>
- <text fill="#6B4FBB" font-family="SourceSansPro-Black, Source Sans Pro" font-size="20" font-weight="700" letter-spacing="-.1">
- <tspan x="12" y="27">@</tspan>
- </text>
- </g>
- <g transform="rotate(15 -686.59 1035.907)">
- <use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#q)" xlink:href="#f"/>
- <path fill="#FC6D26" d="M26.5 38.2c3.3 0 9.5-2.5 9.5-9.6 0-7-2.4-6.6-9.5-6.6-7 0-9.5-.4-9.5 6.6s6.2 9.6 9.5 9.6z"/>
- <g transform="translate(17 14)">
- <use fill="#FC6D26" xlink:href="#g"/>
- <use stroke="#FFF" stroke-width="4" mask="url(#r)" xlink:href="#g"/>
- </g>
- </g>
- <g transform="rotate(15 -85.125 65.185)">
- <use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#s)" xlink:href="#h"/>
- <path fill="#6B4FBB" d="M24 18.5c0-1.4 1-2.5 2.5-2.5 1.4 0 2.5 1 2.5 2.5v9c0 1.4-1 2.5-2.5 2.5-1.4 0-2.5-1-2.5-2.5v-9zM26.5 37c1.4 0 2.5-1 2.5-2.5 0-1.4-1-2.5-2.5-2.5-1.4 0-2.5 1-2.5 2.5 0 1.4 1 2.5 2.5 2.5z"/>
- </g>
- <g transform="rotate(-15 716.492 78.873)">
- <use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#t)" xlink:href="#i"/>
- <path fill="#FC6D26" d="M20 23v-3h3v3h-3zm0 3v1.5c0 .8-.7 1.5-1.5 1.5s-1.5-.7-1.5-1.5V26h-2.5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5H17v-3h-1.5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5H17v-2.5c0-.8.7-1.5 1.5-1.5s1.5.7 1.5 1.5V17h3v-1.5c0-.8.7-1.5 1.5-1.5s1.5.7 1.5 1.5V17h2.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H26v3h1.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H26v2.5c0 .8-.7 1.5-1.5 1.5s-1.5-.7-1.5-1.5V26h-3z"/>
- </g>
- <g transform="rotate(-15 129.114 -585.74)">
- <use stroke="#FDE5D8" stroke-width="6" mask="url(#u)" xlink:href="#j"/>
- <circle cx="16" cy="20" r="2" fill="#FC6D26"/>
- <circle cx="27" cy="20" r="2" fill="#FC6D26"/>
- <circle cx="38" cy="20" r="2" fill="#FC6D26"/>
- </g>
- <g transform="rotate(-15 1254.8 -458.986)">
- <use stroke="#FDE5D8" stroke-width="6" mask="url(#v)" xlink:href="#k"/>
- <path fill="#FC6D26" d="M10.6 19l2-2c.5-.5.5-1 0-1.5-.3-.4-1-.4-1.3 0l-2.8 2.8c-.2.2-.3.4-.3.7 0 .3 0 .5.3.7l2.8 2.8c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-2-2zm14.8 0l-2-2c-.5-.5-.5-1 0-1.5.3-.4 1-.4 1.3 0l2.8 2.8c.2.2.3.4.3.7 0 .3 0 .5-.3.7l-2.8 2.8c-.4.4-1 .4-1.4 0-.4-.4-.4-1 0-1.4l2-2z"/>
- <rect width="2" height="7" x="17" y="15.1" fill="#FC6D26" opacity=".5" transform="rotate(15 18.002 18.64)" rx="1"/>
- </g>
- </g>
-</svg>
diff --git a/app/views/shared/empty_states/monitoring/_getting_started.svg b/app/views/shared/empty_states/monitoring/_getting_started.svg
deleted file mode 100644
index db7a1c2e708..00000000000
--- a/app/views/shared/empty_states/monitoring/_getting_started.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="159.8" height="127.81" x=".196" y="5" rx="10"/><rect id="2" width="160" height="128" x=".666" y=".41" rx="10"/><rect id="4" width="160.19" height="128.19" x=".339" y=".59" rx="10"/><mask id="1" width="159.8" height="127.81" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="3" width="160" height="128" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="5" width="160.19" height="128.19" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(12 3)"><rect width="160" height="128" x="122.08" y="146.08" fill="#f9f9f9" transform="matrix(.99619.08716-.08716.99619 19.08-16.813)" rx="10"/><g transform="matrix(.96593.25882-.25882.96593 227.1 57.47)"><rect width="159.8" height="127.81" x="1.64" y="10.06" fill="#f9f9f9" rx="8"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g transform="translate(24.368 36.951)"><path fill="#d2caea" fill-rule="nonzero" d="m71.785 44.2c.761.296 1.625.099 2.184-.496l35.956-38.34c.756-.806.715-2.071-.091-2.827-.806-.756-2.071-.715-2.827.091l-35.03 37.36-41.888-16.285c-.749-.291-1.6-.106-2.16.471l-26.368 27.16c-.769.793-.751 2.059.042 2.828.793.769 2.059.751 2.828-.042l25.444-26.21 41.911 16.294"/><g fill="#fff"><circle cx="5.716" cy="5.104" r="5" stroke="#6b4fbb" stroke-width="4" transform="translate(65.917 34.945)"/><g stroke="#fb722e"><ellipse cx="4.632" cy="50.05" stroke-width="3.2" rx="4" ry="3.999"/><g stroke-width="4"><ellipse cx="29.632" cy="27.05" rx="4" ry="3.999"/><ellipse cx="107.63" cy="4.048" rx="4" ry="3.999"/></g></g></g></g></g><rect width="160.19" height="128.19" x="36.28" y="86.74" fill="#f9f9f9" transform="matrix(.99619-.08716.08716.99619-12.703 10.717)" rx="10"/><g transform="matrix(.99619.08716-.08716.99619 126.61 137.8)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width="3.2" d="m84.67 28.41c18.225 0 33 15.07 33 33.651h-33v-33.651" stroke-linecap="round" stroke-linejoin="round"/><path fill="#d2caea" fill-rule="nonzero" d="m78.67 66.41h30c1.105 0 2 .895 2 2 0 18.778-15.222 34-34 34-18.778 0-34-15.222-34-34 0-18.778 15.222-34 34-34 1.105 0 2 .895 2 2v30m-32 2c0 16.569 13.431 30 30 30 15.896 0 28.905-12.364 29.934-28h-29.934c-1.105 0-2-.895-2-2v-29.934c-15.636 1.029-28 14.04-28 29.934"/></g><g transform="matrix(.99619-.08716.08716.99619 30 88.03)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g transform="translate(42 34)"><path fill="#fef0ea" d="m0 13.391c0-.768.628-1.391 1.4-1.391h9.2c.773 0 1.4.626 1.4 1.391v49.609h-12v-49.609"/><path fill="#fb722e" d="m66 21.406c0-.777.628-1.406 1.4-1.406h9.2c.773 0 1.4.624 1.4 1.406v41.594h-12v-41.594"/><path fill="#6b4fbb" d="m22 1.404c0-.776.628-1.404 1.4-1.404h9.2c.773 0 1.4.624 1.4 1.404v61.6h-12v-61.6"/><path fill="#d2caea" d="m44 39.4c0-.772.628-1.398 1.4-1.398h9.2c.773 0 1.4.618 1.4 1.398v23.602h-12v-23.602"/></g></g><g fill="#fee8dc"><path d="m6.226 94.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" transform="matrix(.70711.70711-.70711.70711 66.33 22.317)"/><path d="m312.78 53.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 126.1-206.88)"/></g><path fill="#e1dcf1" d="m124.78 12.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711 31.05 90.51)"/><path fill="#d2caea" d="m374.78 244.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711-59.779 335.24)"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/empty_states/monitoring/_loading.svg b/app/views/shared/empty_states/monitoring/_loading.svg
deleted file mode 100644
index 6bbd7a6c5b9..00000000000
--- a/app/views/shared/empty_states/monitoring/_loading.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="C" width="161" height="100" x="92" y="181" rx="10"/><rect id="E" width="151" height="32" x="20" rx="10"/><rect id="G" width="191" height="62" y="10" rx="10"/><circle id="I" cx="23" cy="41" r="9"/><circle id="4" cx="36.5" cy="36.5" r="36.5"/><circle id="8" cx="262.5" cy="169.5" r="15.5"/><circle id="A" cx="79.5" cy="169.5" r="15.5"/><circle id="K" cx="45" cy="41" r="9"/><circle id="0" cx="30.5" cy="30.5" r="30.5"/><circle id="2" cx="18" cy="34" r="3"/><ellipse id="6" cx="43.5" cy="43.5" rx="43.5" ry="43.5"/><mask id="H" width="191" height="62" x="0" y="0" fill="#fff"><use xlink:href="#G"/></mask><mask id="J" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#I"/></mask><mask id="D" width="161" height="100" x="0" y="0" fill="#fff"><use xlink:href="#C"/></mask><mask id="F" width="151" height="32" x="0" y="0" fill="#fff"><use xlink:href="#E"/></mask><mask id="9" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#8"/></mask><mask id="1" width="61" height="61" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="B" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#A"/></mask><mask id="3" width="6" height="6" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="7" width="87" height="87" x="0" y="0" fill="#fff"><use xlink:href="#6"/></mask><mask id="L" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#K"/></mask><mask id="5" width="73" height="73" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(28 2)"><g transform="translate(133 87)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><path stroke="#d2caea" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="m19 32l2-9 5 17 4-12 4 5 6-10 3 5"/><g fill="#fff" stroke="#fb722e"><use stroke-width="4" mask="url(#3)" xlink:href="#2"/><circle cx="44" cy="30" r="2" stroke-width="2"/></g></g><g transform="translate(188 29)"><circle cx="36.5" cy="41.5" r="36.5" fill="#f9f9f9"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><rect width="27" height="4" x="23" y="27" fill="#d2caea" rx="2"/><rect width="10.5" height="4" x="23" y="27" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="36" fill="#d2caea" rx="2"/><rect width="19" height="4" x="23" y="36" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="45" fill="#d2caea" rx="2"/><rect width="7" height="4" x="23" y="45" fill="#6b4fbb" rx="2"/></g><path fill="#eee" fill-rule="nonzero" d="m247 292v1c0 5.519-4.469 9.993-10.01 9.993h-125.99c-5.177 0-9.436-3.927-9.954-8.96 1.348.998 2.957 1.666 4.705 1.883 1.027 1.835 2.992 3.077 5.248 3.077h125.99c2.485 0 4.611-1.497 5.526-3.637 1.796-.675 3.347-1.852 4.48-3.359m1.947-8.962c-.518 5.03-4.774 8.958-9.95 8.958h-131.99c-4.929 0-9.03-3.563-9.851-8.25 1.382.767 2.964 1.216 4.649 1.248 1.037 1.794 2.978 3 5.202 3h131.99c2.255 0 4.219-1.241 5.245-3.076 1.748-.216 3.356-.883 4.705-1.882"/><g transform="translate(79)"><ellipse cx="43.5" cy="47.5" fill="#f9f9f9" rx="43.5" ry="43.5"/><g fill="#fff"><g stroke="#eee"><use stroke-width="8" mask="url(#7)" xlink:href="#6"/><path stroke-width="4" d="m18.595 49c2.515 11.44 12.71 20 24.905 20 14.08 0 25.5-11.417 25.5-25.5 0-12.195-8.56-22.391-20-24.905v15.959c3 1.848 5 5.164 5 8.946 0 5.799-4.701 10.5-10.5 10.5-3.782 0-7.098-2-8.946-5h-15.959" stroke-linejoin="round"/></g><path stroke="#d2caea" stroke-width="4" d="m18 44c-.003-.166-.005-.333-.005-.5 0-14.08 11.417-25.5 25.5-25.5.167 0 .334.002.5.005v15.01c-.166-.008-.332-.012-.5-.012-5.799 0-10.5 4.701-10.5 10.5 0 .168.004.334.012.5h-15.01" stroke-linejoin="round"/></g></g><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#9)" xlink:href="#8"/><use mask="url(#B)" xlink:href="#A"/><use mask="url(#D)" xlink:href="#C"/></g><g fill="#eee"><rect width="15" height="2" x="226" y="247" rx="1"/><rect width="15" height="2" x="226" y="242" rx="1"/><rect width="15" height="2" x="226" y="252" rx="1"/></g><rect width="10" height="52" x="118" y="196" fill="#d2caea" rx="2"/><rect width="10" height="47" x="154" y="196" fill="#6b4fbb" rx="2"/><rect width="10" height="37" x="190" y="196" fill="#d2caea" rx="2"/><g fill="#fee8dc"><rect width="10" height="52" x="132" y="185" rx="2"/><rect width="10" height="38" x="168" y="185" rx="2"/></g><rect width="10" height="58" x="204" y="185" fill="#fb722e" rx="2"/><g transform="translate(76 128)"><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#F)" xlink:href="#E"/><use mask="url(#H)" xlink:href="#G"/></g><g fill="#d2caea"><rect width="16" height="4" x="156" y="35" rx="2"/><rect width="16" height="4" x="156" y="43" rx="2"/></g><g fill="#fff" stroke-width="8"><use stroke="#fee8dc" mask="url(#J)" xlink:href="#I"/><use stroke="#fb722e" mask="url(#L)" xlink:href="#K"/></g></g><g fill="#fb722e"><path d="m6.226 220.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" opacity=".2" transform="matrix(.70711.70711-.70711.70711 155.43 59.22)"/><path d="m256.23 9.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" opacity=".2" transform="matrix(.70711.70711-.70711.70711 79.45-179.36)"/></g><path fill="#fee8dc" d="m312.78 150.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 194.69-178.47)"/><path fill="#6b4fbb" d="m43.778 80.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" opacity=".2" transform="matrix(.70711-.70711.70711.70711-40.761 53.15)"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/empty_states/monitoring/_unable_to_connect.svg b/app/views/shared/empty_states/monitoring/_unable_to_connect.svg
deleted file mode 100644
index 62537d87d5d..00000000000
--- a/app/views/shared/empty_states/monitoring/_unable_to_connect.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><use id="0" xlink:href="#E"/><use id="2" xlink:href="#E"/><use id="4" xlink:href="#E"/><path id="6" d="m74 93h26v47h-26z"/><path id="8" d="m74 93h26v47h-26z"/><rect id="A" width="65" height="14" x="55" y="135" rx="4"/><rect id="C" width="175" height="118" rx="10"/><rect id="E" width="159" rx="10" height="56"/><rect id="F" width="160" y="2" rx="10" height="56" fill="#f9f9f9"/><mask id="B" width="65" height="14" x="0" y="0" fill="#fff"><use xlink:href="#A"/></mask><mask id="9" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#8"/></mask><mask id="D" width="175" height="118" x="0" y="0" fill="#fff"><use xlink:href="#C"/></mask><mask id="7" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#6"/></mask><mask id="3" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="5" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(1 65)"><g transform="translate(244)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g fill-rule="nonzero"><path fill="#fb722e" d="m134 31c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m117 31c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6m-17-4c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><g fill="#d2caea"><rect width="50" height="4" x="19" y="20" rx="2"/><rect width="50" height="4" x="19" y="34" rx="2"/></g><g transform="translate(0 59)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><g fill-rule="nonzero"><path fill="#fee8dc" d="m134 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fb722e" d="m117 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m100 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><rect width="50" height="4" x="19" y="19" fill="#d2caea" rx="2" id="G"/><rect width="50" height="4" x="19" y="33" fill="#d2caea" rx="2" id="H"/></g><g transform="translate(0 118)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g fill-rule="nonzero"><path fill="#fb722e" d="m134 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m117 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6m-17-4c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><use xlink:href="#G"/><use xlink:href="#H"/></g></g><g transform="translate(163 55)"><g fill="#eee"><rect width="29" height="4" y="29" rx="2"/><rect width="28" height="4" x="55" y="29" rx="2"/></g><g transform="translate(16)"><circle cx="30" cy="30" r="24" fill="#fef0ea"/><g fill="#fb722e"><circle cx="30.5" cy="30.5" r="30.5" opacity=".1"/><circle cx="30.5" cy="30.5" r="19.5" opacity=".1"/></g><circle cx="30.5" cy="30.5" r="13.5" fill="#fff"/><path fill="#fb722e" d="m32.621 30.5l2.481-2.481c.586-.586.58-1.529-.006-2.115-.59-.59-1.533-.589-2.115-.006l-2.481 2.481-2.481-2.481c-.586-.586-1.529-.58-2.115.006-.59.59-.589 1.533-.006 2.115l2.481 2.481-2.481 2.481c-.586.586-.58 1.529.006 2.115.59.59 1.533.589 2.115.006l2.481-2.481 2.481 2.481c.586.586 1.529.58 2.115-.006.59-.59.589-1.533.006-2.115l-2.481-2.481"/></g></g><g transform="translate(0 13)"><rect width="65" height="14" x="55" y="137" fill="#f9f9f9" rx="4"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#7)" xlink:href="#6"/><rect width="175" height="118" y="3" fill="#f9f9f9" rx="10"/><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#9)" xlink:href="#8"/><use mask="url(#B)" xlink:href="#A"/><use mask="url(#D)" xlink:href="#C"/></g><g fill-rule="nonzero"><path fill="#eee" d="m163 105v-93h-152v93h152m-156-93.01c0-2.204 1.797-3.99 3.995-3.99h152.01c2.206 0 3.995 1.796 3.995 3.99v93.02c0 2.204-1.797 3.99-3.995 3.99h-152.01c-2.206 0-3.995-1.796-3.995-3.99v-93.02"/><path fill="#d2caea" d="m86 92c-11.598 0-21-9.402-21-21 0-11.598 9.402-21 21-21 11.598 0 21 9.402 21 21 0 11.598-9.402 21-21 21m0-4c9.389 0 17-7.611 17-17 0-9.389-7.611-17-17-17-9.389 0-17 7.611-17 17 0 9.389 7.611 17 17 17"/></g><path fill="#6b4fbb" d="m83 63c0-1.659 1.347-3 3-3 1.657 0 3 1.342 3 3v7.993c0 1.659-1.347 3-3 3-1.657 0-3-1.342-3-3v-7.993m3 18.997c-1.657 0-3-1.343-3-3 0-1.657 1.343-3 3-3 1.657 0 3 1.343 3 3 0 1.657-1.343 3-3 3"/><g fill="#eee"><rect width="134" height="4" x="20" y="30" rx="2"/><rect width="14" height="4" x="20" y="20" rx="2"/><circle cx="87" cy="21" r="5"/></g></g></g></svg> \ No newline at end of file
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index f65bb6a29e6..38e9899ca4b 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -15,7 +15,7 @@
= render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description,
- classes: 'note-textarea',
+ classes: 'note-textarea qa-issuable-form-description',
placeholder: "Write a comment or drag your files here...",
supports_quick_actions: supports_quick_actions
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 760370a6984..8607be9cd06 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,18 +1,33 @@
-.dropdown.inline.js-group-filter-dropdown-wrap
+- options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash)
+- show_archive_options = local_assigns.fetch(:show_archive_options, false)
+- if @sort.present?
+ - default_sort_by = @sort
+- else
+ - if params[:sort]
+ - default_sort_by = params[:sort]
+ - else
+ - default_sort_by = sort_value_recently_created
+
+.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
+ = options_hash[default_sort_by]
= icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- %li
- = link_to filter_groups_path(sort: sort_value_recently_created) do
- = sort_title_recently_created
- = link_to filter_groups_path(sort: sort_value_oldest_created) do
- = sort_title_oldest_created
- = link_to filter_groups_path(sort: sort_value_recently_updated) do
- = sort_title_recently_updated
- = link_to filter_groups_path(sort: sort_value_oldest_updated) do
- = sort_title_oldest_updated
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = _("Sort by")
+ - options_hash.each do |value, title|
+ %li.js-filter-sort-order
+ = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
+ = title
+ - if show_archive_options
+ %li.divider
+ %li.js-filter-archived-projects
+ = link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
+ Hide archived projects
+ %li.js-filter-archived-projects
+ = link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+ Show archived projects
+ %li.js-filter-archived-projects
+ = link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ Show archived projects only
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
new file mode 100644
index 00000000000..13bb4baee3f
--- /dev/null
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -0,0 +1,7 @@
+.groups-empty-state
+ = custom_icon("icon_empty_groups")
+
+ .text-content
+ %h4= s_("GroupsEmptyState|A group is a collection of several projects.")
+ %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
+ %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index b361ec86ced..90395600d4e 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -1,19 +1,7 @@
-- group_member = local_assigns[:group_member]
-- full_name = true unless local_assigns[:full_name] == false
-- group_name = full_name ? group.full_name : group.name
-- css_class = '' unless local_assigns[:css_class]
-- css_class += " no-description" if group.description.blank?
-
-%li.group-row{ class: css_class }
- - if group_member
- .controls.hidden-xs
- - if can?(current_user, :admin_group, group)
- = link_to edit_group_path(group), class: "btn" do
- = icon('cogs')
-
- = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do
- = icon('sign-out')
+- user = local_assigns.fetch(:user, current_user)
+- access = user&.max_member_access_for_group(group.id)
+%li.group-row{ class: ('no-description' if group.description.blank?) }
.stats
%span
= icon('bookmark')
@@ -28,13 +16,12 @@
.avatar-container.s40
= link_to group do
- = image_tag group_icon(group), class: "avatar s40 hidden-xs"
+ = group_icon(group, class: "avatar s40 hidden-xs")
.title
- = link_to group_name, group, class: 'group-name'
+ = link_to group.full_name, group, class: 'group-name'
- - if group_member
- as
- %span= group_member.human_access
+ - if access&.nonzero?
+ %span.user-access-role= Gitlab::Access.human_access(access)
- if group.description.present?
.description
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index 427595c47a5..f50a6bd4d6a 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -1,6 +1,8 @@
- if groups.any?
+ - user = local_assigns[:user]
+
%ul.content-list
- groups.each_with_index do |group, i|
- = render "shared/groups/group", group: group
+ = render "shared/groups/group", group: group, user: user
- else
- .nothing-here-block No groups found
+ .nothing-here-block= s_("GroupsEmptyState|No groups found")
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index ad7a7faedf1..3f91263089a 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
-= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f|
- = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
+= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
+ = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
index af6a499fadb..c80b179d525 100644
--- a/app/views/shared/hook_logs/_content.html.haml
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -11,7 +11,7 @@
= hook_log.trigger.singularize.titleize
%p
%strong Elapsed time:
- #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} sec
%p
%strong Request time:
= time_ago_with_tooltip(hook_log.created_at)
diff --git a/app/views/shared/icons/_abuse_reports.svg b/app/views/shared/icons/_abuse_reports.svg
new file mode 100644
index 00000000000..fb16b269150
--- /dev/null
+++ b/app/views/shared/icons/_abuse_reports.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12zm-2.163-3.275a2.499 2.499 0 0 1 4.343.03.5.5 0 0 1-.871.49 1.5 1.5 0 0 0-2.607-.018.5.5 0 1 1-.865-.502zM5 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm6 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_access_tokens.svg b/app/views/shared/icons/_access_tokens.svg
new file mode 100644
index 00000000000..07ea6dab715
--- /dev/null
+++ b/app/views/shared/icons/_access_tokens.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m13 2h-10c-1.7 0-3 1.3-3 3v6c0 1.7 1.3 3 3 3h10c1.7 0 3-1.3 3-3v-6c0-1.7-1.3-3-3-3m1 9c0 .6-.4 1-1 1h-10c-.6 0-1-.4-1-1v-6c0-.6.4-1 1-1h10c.6 0 1 .4 1 1v6"/><circle cx="4" cy="8" r="1"/><circle cx="8" cy="8" r="1"/><circle cx="12" cy="8" r="1"/></svg>
diff --git a/app/views/shared/icons/_account.svg b/app/views/shared/icons/_account.svg
new file mode 100644
index 00000000000..d47e4f59914
--- /dev/null
+++ b/app/views/shared/icons/_account.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m6.8 8c-.3 0-.5 0-.8 0-5 0-6 2.7-6 4.5s.1 2.5 6 2.5c.6 0 1.1 0 1.5 0-1-1.1-1.5-2.5-1.5-4 0-1.1.3-2.1.8-3"/><circle cx="6" cy="4" r="3"/><path d="m15.9 11.5l-.9-.6c0-.3-.1-.7-.2-.9l.6-.9c.1-.1.1-.2 0-.3l-.4-.5c-.1-.1-.2-.1-.3-.1l-.9.4c-.3-.2-.5-.3-.9-.4l-.3-1c0-.1-.1-.2-.2-.2h-.6c-.1 0-.2.1-.2.2l-.3 1c-.3.1-.6.2-.9.4l-1.1-.4c-.1 0-.2 0-.3.1l-.4.5c0 .1 0 .2 0 .3l.6.9c-.1.3-.2.6-.2.9l-.9.5c-.1.1-.1.2-.1.3l.1.6c0 .1.1.2.2.2l1.1.1c.1.2.3.4.5.6l-.2 1.2c0 .1 0 .2.1.3l.6.3c.1 0 .2 0 .3-.1l.9-.9c.2 0 .4 0 .6 0l.9.9c.1.1.2.1.3 0l.6-.3c.1 0 .2-.2.1-.3l-.1-1.1c.2-.2.4-.4.5-.6l1.1-.1c.1 0 .2-.1.2-.2l.1-.6c.1-.1.1-.2 0-.2m-3.9.5c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1-.4 1-1 1"/></svg>
diff --git a/app/views/shared/icons/_add_new_group.svg b/app/views/shared/icons/_add_new_group.svg
new file mode 100644
index 00000000000..ecd52c5e99f
--- /dev/null
+++ b/app/views/shared/icons/_add_new_group.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".03" d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/>
+ <path fill="#E1DBF2" fill-rule="nonzero" d="M59.65 32.65H60l-2-2.42-2 2.4-2-2.4-2 2.4-2-2.4-2 2.4-2-2.4-2 2.42h.77C45.57 34.6 46 36.75 46 39c0 2.84-.7 5.5-1.92 7.86 1.97 2.28 4.83 3.64 7.92 3.64 5.8 0 10.5-4.74 10.5-10.6 0-2.8-1.08-5.36-2.85-7.25zM43.18 29.6c2.4-2.1 5.52-3.3 8.82-3.3 7.46 0 13.5 6.1 13.5 13.6S59.46 53.5 52 53.5c-3.68 0-7.1-1.5-9.6-4.04C39.3 53.44 34.44 56 29 56c-9.4 0-17-7.6-17-17s7.6-17 17-17c3.22 0 6.23.9 8.8 2.45 2.13 1.3 3.97 3.05 5.38 5.16zM17 34c-.65 1.54-1 3.23-1 5 0 7.18 5.82 13 13 13s13-5.82 13-13c0-1.77-.35-3.46-1-5h-9c-.53 0-1.04-.2-1.4-.6L29 31.84l-1.6 1.58c-.36.4-.87.6-1.4.6h-9zm21.38-4c-2.4-2.5-5.76-4-9.38-4-3.62 0-6.98 1.5-9.38 4h5.55l2.42-2.4c.74-.8 2-.8 2.8 0l2.4 2.4h5.54z"/>
+ <path fill="#6B4FBB" d="M47.6 42.32c-.66 0-1.2-.54-1.2-1.2 0-.68.54-1.22 1.2-1.22.66 0 1.2.54 1.2 1.2 0 .68-.54 1.22-1.2 1.22zm8.8 0c-.66 0-1.2-.54-1.2-1.2 0-.68.54-1.22 1.2-1.22.66 0 1.2.54 1.2 1.2 0 .68-.54 1.22-1.2 1.22zM25 44h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-1c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_add_new_project.svg b/app/views/shared/icons/_add_new_project.svg
new file mode 100644
index 00000000000..cf8762944ca
--- /dev/null
+++ b/app/views/shared/icons/_add_new_project.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76S3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M30 24c-2.21 0-4 1.79-4 4v22c0 2.21 1.79 4 4 4h18c2.21 0 4-1.79 4-4V28c0-2.21-1.79-4-4-4H30zm0-4h18c4.418 0 8 3.582 8 8v22c0 4.418-3.582 8-8 8H30c-4.418 0-8-3.582-8-8V28c0-4.418 3.582-8 8-8z"/><path fill="#6B4FBB" d="M33 30h8c1.105 0 2 .895 2 2s-.895 2-2 2h-8c-1.105 0-2-.895-2-2s.895-2 2-2zm0 7h12c1.105 0 2 .895 2 2s-.895 2-2 2H33c-1.105 0-2-.895-2-2s.895-2 2-2zm0 7h12c1.105 0 2 .895 2 2s-.895 2-2 2H33c-1.105 0-2-.895-2-2s.895-2 2-2z"/></g></svg>
diff --git a/app/views/shared/icons/_add_new_user.svg b/app/views/shared/icons/_add_new_user.svg
new file mode 100644
index 00000000000..0ad40498d7b
--- /dev/null
+++ b/app/views/shared/icons/_add_new_user.svg
@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".03" d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/>
+ <path fill="#E1DBF2" d="M44 31l-2.5-3-2.5 3-2.5-3-2.5 3-2.5-3-2.5 3h-2.72c2.65-4.2 7.36-7 12.72-7s10.07 2.8 12.72 7H49l-2.5-3-2.5 3z"/>
+ <path fill="#E1DBF2" fill-rule="nonzero" d="M39 57c-9.4 0-17-7.6-17-17s7.6-17 17-17 17 7.6 17 17-7.6 17-17 17zm0-4c7.18 0 13-5.82 13-13s-5.82-13-13-13-13 5.82-13 13 5.82 13 13 13z"/>
+ <path fill="#6B4FBB" d="M35 45h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_appearance.svg b/app/views/shared/icons/_appearance.svg
new file mode 100644
index 00000000000..8ffeb780cb4
--- /dev/null
+++ b/app/views/shared/icons/_appearance.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11.161 12.456l.232.121c.1.053.175.094.249.137.53.318.844.75.857 1.402.012 1.397-1.116 1.756-3.12 1.858-.411.022-.744.026-1.38.026A8 8 0 0 1 0 8a8 8 0 0 1 8-8c4.417 0 7.998 3.582 7.998 7.977.06 2.621-1.312 3.586-4.48 3.648-.602.008-1.068.043-1.4.104.228.192.598.47 1.043.727zm-3.287-.943c-.019-1.495 1.228-1.856 3.611-1.888C13.67 9.582 14.028 9.33 13.998 8A6 6 0 1 0 8 14c.603 0 .91-.004 1.277-.023.172-.009.332-.02.478-.035-1.172-.738-1.868-1.47-1.88-2.43zM6 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm6 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm-2-3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zM4 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_applications.svg b/app/views/shared/icons/_applications.svg
new file mode 100644
index 00000000000..65442867174
--- /dev/null
+++ b/app/views/shared/icons/_applications.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1 0h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm0 6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm6-6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm0 1v2h2V1H7zm0 5h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm6-6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm0 6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm0 1v2h2V7h-2zM1 12h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1zm0 1v2h2v-2H1zm6-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1zm6 0h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/></svg>
diff --git a/app/views/shared/icons/_authentication_log.svg b/app/views/shared/icons/_authentication_log.svg
new file mode 100644
index 00000000000..0beb84c2912
--- /dev/null
+++ b/app/views/shared/icons/_authentication_log.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4zm1 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3-5h3a1 1 0 0 1 0 2H8a1 1 0 1 1 0-2zm0 3h3a1 1 0 0 1 0 2H8a1 1 0 1 1 0-2zm-3 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3-2h3a1 1 0 0 1 0 2H8a1 1 0 0 1 0-2z"/></svg>
diff --git a/app/views/shared/icons/_caret_down.svg b/app/views/shared/icons/_caret_down.svg
new file mode 100644
index 00000000000..fd80fd0f651
--- /dev/null
+++ b/app/views/shared/icons/_caret_down.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="caret-down" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 10.243l-4.95-4.95a1 1 0 0 0-1.414 1.414l5.657 5.657a.997.997 0 0 0 1.414 0l5.657-5.657a1 1 0 0 0-1.414-1.414L8 10.243z"/></svg>
diff --git a/app/views/shared/icons/_chat.svg b/app/views/shared/icons/_chat.svg
new file mode 100644
index 00000000000..0c474c9f980
--- /dev/null
+++ b/app/views/shared/icons/_chat.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M5.414 12l-3.707 3.707C1.077 16.337 0 15.891 0 15V3a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H5.414zM2 12.586l2.293-2.293A1 1 0 0 1 5 10h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v9.586z"/></svg>
diff --git a/app/views/shared/icons/_configure_server.svg b/app/views/shared/icons/_configure_server.svg
new file mode 100644
index 00000000000..b1137b7ec94
--- /dev/null
+++ b/app/views/shared/icons/_configure_server.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".03" d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/>
+ <path fill="#FEE1D3" fill-rule="nonzero" d="M24.92 35.15c-1.72-1.4-1.98-3.9-.6-5.63l1.26-1.55c1.4-1.72 3.9-2 5.63-.6l.7.56c.7-.4 1.4-.73 2.1-1V26c0-2.2 1.8-4 4-4h2c2.2 0 4 1.8 4 4v.92c.8.28 1.5.62 2.1 1l.7-.55c1.7-1.4 4.3-1.12 5.7.6l1.3 1.55c1.4 1.72 1.2 4.23-.6 5.63l-.7.6c.3.74.4 1.5.5 2.3l.9.2c2.2.5 3.5 2.64 3 4.8L56.4 45c-.5 2.15-2.64 3.5-4.8 3l-.88-.2c-.44.63-.92 1.24-1.46 1.8l.4.82c.9 1.98.1 4.38-1.9 5.35l-1.8.87c-2 .97-4.37.15-5.34-1.84l-.46-.85c-.34.03-.74.05-1.13.05-.4 0-.8-.02-1.2-.05l-.4.85c-.95 2-3.34 2.8-5.33 1.84l-1.8-.87c-1.97-.97-2.8-3.37-1.83-5.35l.4-.8c-.54-.58-1.02-1.2-1.46-1.83l-.8.2c-2.2.5-4.3-.9-4.8-3l-.4-2c-.5-2.2.85-4.3 3-4.8l.9-.2c.1-.8.3-1.6.5-2.3l-.7-.6zm4.95.77c-.53 1.2-.83 2.47-.87 3.8-.02.9-.66 1.68-1.55 1.9l-2.32.53.45 1.94 2.3-.6c.9-.2 1.8.2 2.23 1 .7 1.1 1.5 2.2 2.5 3 .7.6.9 1.6.5 2.4l-1 2.1 1.8.9 1.1-2.1c.4-.8 1.3-1.3 2.2-1.1.7.1 1.3.2 2 .2s1.3-.1 2-.2c.9-.2 1.8.3 2.2 1.1l1 2.1 1.8-.9-1.2-2c-.4-.8-.2-1.8.5-2.4 1-.85 1.84-1.88 2.45-3.05.4-.82 1.33-1.24 2.2-1.04l2.33.54.45-1.95-2.32-.54c-.9-.2-1.52-.97-1.54-1.88-.03-1.4-.33-2.6-.86-3.8-.4-.9-.2-1.8.5-2.4l1.9-1.5-1.3-1.6-1.8 1.5c-.8.5-1.8.6-2.5 0-1.1-.8-2.3-1.4-3.5-1.7-.9-.2-1.5-1-1.5-1.9V26h-2v2.38c0 .9-.6 1.7-1.5 1.93-1.3.4-2.5 1-3.5 1.7-.8.6-1.8.6-2.5 0l-1.9-1.5-1.26 1.6 1.8 1.5c.7.6.94 1.6.6 2.4z"/>
+ <path fill="#FC6D26" fill-rule="nonzero" d="M39 46c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6zm0-4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_container_registry.svg b/app/views/shared/icons/_container_registry.svg
new file mode 100644
index 00000000000..56d62aab670
--- /dev/null
+++ b/app/views/shared/icons/_container_registry.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m16 11.764v-8.764c0-1.657-1.343-3-3-3h-10c-1.657 0-3 1.343-3 3v8.764c.531-.475 1.232-.764 2-.764v-8c0-.552.448-1 1-1h10c.552 0 1 .448 1 1v8c.768 0 1.469.289 2 .764m-14 .236h12c1.105 0 2 .895 2 2 0 1.105-.895 2-2 2h-12c-1.105 0-2-.895-2-2 0-1.105.895-2 2-2m10 1c-.552 0-1 .448-1 1 0 .552.448 1 1 1 .552 0 1-.448 1-1 0-.552-.448-1-1-1"/></svg>
diff --git a/app/views/shared/icons/_doc_text.svg b/app/views/shared/icons/_doc_text.svg
new file mode 100644
index 00000000000..92902a5b449
--- /dev/null
+++ b/app/views/shared/icons/_doc_text.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V7h-3a2 2 0 0 1-2-2V2zm2 .414V5h2.586L10 2.414zM5 0h4.586A2 2 0 0 1 11 .586L14.414 4A2 2 0 0 1 15 5.414V12a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm.5 11h5a.5.5 0 1 1 0 1h-5a.5.5 0 1 1 0-1zm0-2h5a.5.5 0 1 1 0 1h-5a.5.5 0 0 1 0-1zm0-2h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"/></svg>
diff --git a/app/views/shared/icons/_ellipsis_v.svg b/app/views/shared/icons/_ellipsis_v.svg
new file mode 100644
index 00000000000..9117a9bb9ec
--- /dev/null
+++ b/app/views/shared/icons/_ellipsis_v.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1600"><path d="M1088 1248v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h192q40 0 68 28t28 68zm0-512v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm0-512v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V224q0-40 28-68t68-28h192q40 0 68 28t28 68z"/></svg>
diff --git a/app/views/shared/icons/_emails.svg b/app/views/shared/icons/_emails.svg
new file mode 100644
index 00000000000..3ebc64bb03e
--- /dev/null
+++ b/app/views/shared/icons/_emails.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3zm0-2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3z"/><path d="M3.212 4L8 8.31 12.788 4H3.212zm6.126 5.796a2 2 0 0 1-2.676 0L.183 3.965A3.001 3.001 0 0 1 3 2h10c1.293 0 2.395.818 2.817 1.965l-6.48 5.83z"/></svg>
diff --git a/app/views/shared/icons/_express.svg b/app/views/shared/icons/_express.svg
new file mode 100644
index 00000000000..a51e81e5568
--- /dev/null
+++ b/app/views/shared/icons/_express.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="27" height="32" viewBox="0 0 27 32" class="btn-template-icon icon-node-express"><g fill="none" fill-rule="evenodd"><path d="M-3 0h32v32H-3z"/><path fill="#353535" d="M1.192 16.267c.04 2.065.288 3.982.745 5.75.456 1.767 1.16 3.307 2.115 4.618.953 1.31 2.185 2.343 3.694 3.098 1.51.755 3.357 1.132 5.54 1.132 3.22 0 5.89-.844 8.016-2.532 2.125-1.69 3.446-4.22 3.962-7.597h1.192c-.437 3.575-1.847 6.345-4.23 8.312-2.384 1.966-5.324 2.95-8.82 2.95-2.383.04-4.42-.338-6.107-1.133-1.69-.794-3.07-1.917-4.142-3.367-1.073-1.45-1.867-3.158-2.383-5.124C.258 20.408 0 18.294 0 16.028c0-2.542.377-4.806 1.132-6.792C1.887 7.25 2.88 5.57 4.112 4.2 5.34 2.83 6.77 1.79 8.4 1.074 10.03.358 11.698 0 13.406 0c2.383 0 4.44.457 6.167 1.37 1.728.914 3.138 2.126 4.23 3.635 1.093 1.51 1.887 3.238 2.384 5.184.496 1.945.705 3.97.625 6.077H1.193zm24.43-1.192c0-1.867-.26-3.645-.775-5.333-.516-1.688-1.28-3.168-2.294-4.44-1.013-1.27-2.274-2.273-3.784-3.008-1.51-.735-3.258-1.102-5.244-1.102-1.67 0-3.228.317-4.678.953-1.45.636-2.72 1.56-3.813 2.77-1.092 1.212-1.976 2.672-2.652 4.38-.675 1.708-1.072 3.635-1.19 5.78h24.43z"/></g></svg>
diff --git a/app/views/shared/icons/_globe.svg b/app/views/shared/icons/_globe.svg
new file mode 100644
index 00000000000..c2daae5f317
--- /dev/null
+++ b/app/views/shared/icons/_globe.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#E1DBF2" d="M30.24 27.823A14.98 14.98 0 0 0 24 40c0 2.549.636 4.949 1.757 7.051-.297-2.684.644-4.026 2.823-4.026 3.707 0 2.462 5.365 4.473 5.761 2.01.396 4.175.396 4.267 3.29.04 1.257-.265 2.157-.917 2.7a15.095 15.095 0 0 0 8.555-1.006c.035-1.91.303-4.941 2.21-5.61 2.373-.833-.55-1.431.734-3.368 1.17-1.762-3.297-5.2 0-4.832 3.477.388 5.044-.816 6.024-1.456a14.903 14.903 0 0 0-1.373-4.94c-.873.4-2.19.465-3.702-.538-.757-.502-1.084-3.944-2.107-3.944-3.823 0-4.065 3.17-5.994 3.944-1.076.431-4.193 3.773-5.614 3.596-1.126-.14-1.071-4.417-2.45-5.166-1.359-.738-2.174-1.948-2.447-3.633zM39 59c-10.493 0-19-8.507-19-19s8.507-19 19-19 19 8.507 19 19-8.507 19-19 19z"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/icons/_icon_arrow_circle_o_right.svg b/app/views/shared/icons/_icon_arrow_circle_o_right.svg
index 5e45c6c15ce..29bdc8e5754 100644
--- a/app/views/shared/icons/_icon_arrow_circle_o_right.svg
+++ b/app/views/shared/icons/_icon_arrow_circle_o_right.svg
@@ -1 +1,2 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><g fill-rule="evenodd"><path fill-rule="nonzero" d="m0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7m1 0c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6"/><path d="m7 6h-2.702c-.154 0-.298.132-.298.295v1.41c0 .164.133.295.298.295h2.702v1.694c0 .18.095.209.213.09l2.539-2.568c.115-.116.118-.312 0-.432l-2.539-2.568c-.115-.116-.213-.079-.213.09v1.694"/></g></svg>
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m9 6h-7a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586a1 1 0 0 0 -1.707.707z" fill-rule="evenodd"/></svg>
+
diff --git a/app/views/shared/icons/_icon_arrow_right.svg.erb b/app/views/shared/icons/_icon_arrow_right.svg.erb
new file mode 100644
index 00000000000..24d64eb73bd
--- /dev/null
+++ b/app/views/shared/icons/_icon_arrow_right.svg.erb
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 6H2a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586A1 1 0 0 0 9 3.414V6z"/></svg>
diff --git a/app/views/shared/icons/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg
new file mode 100644
index 00000000000..7e47c084bde
--- /dev/null
+++ b/app/views/shared/icons/_icon_autodevops.svg
@@ -0,0 +1,54 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="189" height="110" viewBox="0 0 189 179">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#FFFFFF" fill-rule="nonzero" d="M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
+ <path fill="#EEEEEE" fill-rule="nonzero" d="M110.160166,51.6956996 C106.846457,51.6956996 104.160166,54.3819911 104.160166,57.6956996 L104.160166,117.6957 C104.160166,121.009408 106.846457,123.6957 110.160166,123.6957 L160.160166,123.6957 C163.473874,123.6957 166.160166,121.009408 166.160166,117.6957 L166.160166,57.6956996 C166.160166,54.3819911 163.473874,51.6956996 160.160166,51.6956996 L110.160166,51.6956996 Z M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
+ <rect width="70" height="80" x="80" y="34" fill="#000000" fill-opacity=".03" transform="rotate(5 115 74)" rx="10"/>
+ <path fill="#000000" fill-opacity=".03" fill-rule="nonzero" d="M126.028,178 L150,178 C169.972,177.457 186,161.1 186,141 C186,120.565 169.435,104 149,104 C148.007,104 147.023,104.04 146.05,104.116 C141.1,89.51 127.277,79 111,79 C105.71511,78.9924557 100.490375,80.1212986 95.68,82.31 C89.03,72.47 77.77,66 65,66 C44.565,66 28,82.565 28,103 C28,103.7 28.02,104.397 28.058,105.088 C11.944,109.088 0,123.648 0,141 C0,161.435 16.565,178 37,178 C37.324,178 37.646,177.996 37.968,177.988 L126.028,178 Z"/>
+ <g transform="rotate(5 -300.07 1010.998)">
+ <rect width="70" height="80" fill="#FFFFFF" rx="10"/>
+ <path fill="#EEEEEE" fill-rule="nonzero" d="M10,4 C6.6862915,4 4,6.6862915 4,10 L4,70 C4,73.3137085 6.6862915,76 10,76 L60,76 C63.3137085,76 66,73.3137085 66,70 L66,10 C66,6.6862915 63.3137085,4 60,4 L10,4 Z M10,-2.68673972e-14 L60,-2.68673972e-14 C65.5228475,-2.78819278e-14 70,4.4771525 70,10 L70,70 C70,75.5228475 65.5228475,80 60,80 L10,80 C4.4771525,80 9.15550472e-14,75.5228475 9.08786935e-14,70 L9.08786935e-14,10 C9.02023397e-14,4.4771525 4.4771525,-2.58528666e-14 10,-2.68673972e-14 Z"/>
+ <rect width="8" height="4" x="14" y="18" fill="#FC6D26" rx="2"/>
+ <rect width="8" height="4" x="32" y="38" fill="#E1DBF1" rx="2"/>
+ <rect width="8" height="4" x="45" y="48" fill="#FEE1D3" rx="2"/>
+ <rect width="8" height="4" x="44" y="38" fill="#FEF0E8" rx="2"/>
+ <rect width="12" height="4" x="26" y="18" fill="#EFEDF8" rx="2"/>
+ <rect width="6" height="4" x="15" y="48" fill="#FEF0E8" rx="2"/>
+ <rect width="6" height="4" x="25" y="48" fill="#FC6D26" rx="2"/>
+ <rect width="6" height="4" x="35" y="48" fill="#EEEEEE" rx="2"/>
+ <rect width="12" height="4" x="14" y="28" fill="#EEEEEE" rx="2"/>
+ <rect width="12" height="4" x="44" y="58" fill="#6B4FBB" rx="2"/>
+ <rect width="26" height="4" x="30" y="28" fill="#C3B8E3" rx="2"/>
+ <rect width="26" height="4" x="15" y="58" fill="#FEE1D3" rx="2"/>
+ <rect width="14" height="4" x="42" y="18" fill="#E1DBF1" rx="2"/>
+ <rect width="14" height="4" x="14" y="38" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(3 67)">
+ <path fill="#FFFFFF" fill-rule="nonzero" d="M126.028,112 L150,112 C169.972,111.457 186,95.1 186,75 C186,54.565 169.435,38 149,38 C148.007,38 147.023,38.04 146.05,38.116 C141.1,23.51 127.277,13 111,13 C105.71511,12.9924557 100.490375,14.1212986 95.68,16.31 C89.03,6.47 77.77,0 65,0 C44.565,0 28,16.565 28,37 C28,37.7 28.02,38.397 28.058,39.088 C11.944,43.088 0,57.648 0,75 C0,95.435 16.565,112 37,112 C37.324,112 37.646,111.996 37.968,111.988 L126.028,112 Z"/>
+ <path fill="#FFFFFF" d="M126.028,110 L149.946,110 C168.876,109.486 184,93.976 184,75 C184,55.67 168.33,40 149,40 C148.064,40 147.133,40.037 146.207,40.11 L144.656,40.232 L144.156,38.758 C139.381,24.67 126.116,15 111,15 C106.001056,14.9926759 101.058995,16.0604828 96.509,18.131 L94.969,18.832 L94.022,17.431 C87.552,7.855 76.774,2 65,2 C45.67,2 30,17.67 30,37 C30,37.661 30.018,38.32 30.055,38.977 L30.147,40.63 L28.54,41.029 C13.06,44.87 2,58.827 2,75 C2,94.33 17.67,110 37,110 C37.306,110 37.612,109.996 37.968,109.988 L126.028,110 Z"/>
+ <path fill="#EEEEEE" fill-rule="nonzero" d="M149,38 C169.434569,38 186,54.5654305 186,75 C186,95.0477955 170.024944,111.45554 149.946,112 L126.028,112 L38.0129325,111.987495 C37.6348039,111.995992 37.3157048,112 37,112 C16.5654305,112 0,95.4345695 0,75 C0,57.9772494 11.6188339,43.1669444 28.0580557,39.0879359 C28.0192558,38.39805 28,37.7022134 28,37 C28,16.5654305 44.5654305,0 65,0 C77.3556804,0 88.7905015,6.1156181 95.6789703,16.310978 C100.491636,14.1213221 105.717209,12.9922579 111,13 C126.893041,13 140.974134,23.139867 146.049999,38.1155308 C147.031471,38.0388091 148.014765,38 149,38 Z M149.891715,108.000737 C167.750695,107.515818 182,92.8805667 182,75 C182,56.7745695 167.225431,42 149,42 C148.1197,42 147.241142,42.0346799 146.363833,42.1038413 L144.812833,42.2258413 C143.900567,42.2975992 143.055957,41.7410534 142.762001,40.8744692 L142.261844,39.400007 C137.735045,26.0442906 125.175364,17 110.99707,16.9999979 C106.284902,16.9930939 101.626355,17.9996435 97.3375854,19.9512874 L95.7975854,20.6522874 C94.9091924,21.0566793 93.8586575,20.7607079 93.3120297,19.952022 L92.3648004,18.5506827 C86.2175802,9.45241684 76.0227954,4 65,4 C46.7745695,4 32,18.7745695 32,37 C32,37.6282966 32.0172077,38.249659 32.0519095,38.8658592 L32.1439095,40.5188592 C32.1972908,41.4779816 31.5612439,42.3395846 30.6289443,42.5710641 L29.0216479,42.9701376 C14.3638424,46.6071293 4,59.8177208 4,75 C4,93.2254305 18.7745695,108 37,108 C37.2837952,108 37.5733598,107.996363 37.9682725,107.988 L126.02825,108 L149.891715,108.000737 Z"/>
+ </g>
+ <g fill-rule="nonzero" transform="rotate(15 -315.035 277.714)">
+ <path fill="#FFFFFF" d="M12.275,10.57 C13.986216,9.15630755 15.921048,8.03765363 18,7.26 L18,5.5 C18,2.463 20.47,0 23.493,0 L26.507,0 C27.9648848,0.000530018716 29.3628038,0.580386367 30.3930274,1.61192286 C31.4232511,2.64345935 32.0013267,4.04211574 32,5.5 L32,7.26 C34.098,8.043 36.03,9.17 37.725,10.57 L39.253,9.688 C41.8816141,8.17268496 45.2407537,9.07039379 46.763,11.695 L48.27,14.305 C48.9984289,15.5678669 49.1951495,17.0684426 48.8168566,18.4763972 C48.4385638,19.8843518 47.5162683,21.0842673 46.253,21.812 L44.728,22.693 C44.907,23.769 45,24.873 45,26 C45,27.127 44.907,28.231 44.728,29.307 L46.253,30.187 C48.8800379,31.705769 49.7822744,35.0642181 48.27,37.695 L46.763,40.305 C46.0335844,41.5673849 44.8323832,42.4881439 43.4238487,42.8645658 C42.0153143,43.2409877 40.5149245,43.0422119 39.253,42.312 L37.725,41.43 C36.013784,42.8436924 34.078952,43.9623464 32,44.74 L32,46.5 C32,49.537 29.53,52 26.507,52 L23.493,52 C22.0351152,51.99947 20.6371962,51.4196136 19.6069726,50.3880771 C18.5767489,49.3565406 17.9986733,47.9578843 18,46.5 L18,44.74 C15.921048,43.9623464 13.986216,42.8436924 12.275,41.43 L10.747,42.312 C8.11838594,43.827315 4.75924629,42.9296062 3.237,40.305 L1.73,37.695 C1.00157113,36.4321331 0.804850523,34.9315574 1.18314337,33.5236028 C1.56143621,32.1156482 2.48373172,30.9157327 3.747,30.188 L5.272,29.307 C5.09051204,28.2140265 4.9995366,27.107939 5,26 C5,24.873 5.093,23.769 5.272,22.693 L3.747,21.813 C1.11996213,20.294231 0.217725591,16.9357819 1.73,14.305 L3.237,11.695 C3.96641559,10.4326151 5.16761682,9.51185609 6.57615125,9.13543417 C7.98468568,8.75901226 9.48507553,8.95778814 10.747,9.688 L12.275,10.57 Z"/>
+ <path fill="#E1DBF1" d="M17.9996486,7.25963195 L18.0000013,5.49772675 C18.0034459,2.46713881 20.4561478,0.00952173148 23.493,0 L26.507,0 C29.542757,0 32,2.46161709 32,5.5 L32,7.25850184 C34.0799663,8.03664754 36.0149544,9.15559094 37.7260175,10.5694605 L39.2547869,9.68691874 C41.8812087,8.17416302 45.2363972,9.06948854 46.7630175,11.6949424 L48.270687,14.3061027 C48.9989901,15.569417 49.1952874,17.0704122 48.816349,18.4785295 C48.4374106,19.8866468 47.5143145,21.0864021 46.2530682,21.8120114 L44.7278655,22.6926677 C44.9091017,23.7802451 45,24.8850821 45,26 C45,27.1144218 44.9091826,28.218078 44.7278653,29.3073326 L46.2547984,30.1889888 C48.8778516,31.7070439 49.7801588,35.0599752 48.2700175,37.6950576 L46.7625317,40.3058986 C46.0327098,41.5684739 44.8309328,42.4891542 43.4219037,42.8651509 C42.0128746,43.2411475 40.512172,43.0416186 39.2533538,42.312255 L37.7244858,41.4299789 C36.013753,42.8435912 34.0794396,43.9622923 32.0003514,44.7403681 L31.9999987,46.5022733 C31.9965541,49.5328612 29.5438522,51.9904783 26.507,52 L23.493,52 C20.457243,52 18,49.5383829 18,46.5 L18,44.7414988 C15.9200337,43.9633525 13.9850456,42.8444091 12.2739825,41.4305395 L10.7452131,42.3130813 C8.11879127,43.825837 4.76360277,42.9305115 3.23698247,40.3050576 L1.72931303,37.6938973 C1.0010099,36.430583 0.804712603,34.9295878 1.18365098,33.5214705 C1.56258936,32.1133532 2.48568546,30.9135979 3.74693178,30.1879886 L5.27213454,29.3073323 C5.09089825,28.2197549 5,27.114918 5.00000019,26.0008761 C4.99951488,24.8930059 5.0904571,23.7869854 5.27213502,22.6926675 L3.74520157,21.8110112 C1.12214836,20.2929561 0.219841192,16.9400248 1.72998247,14.3049424 L3.23746831,11.6941014 C3.96729024,10.4315261 5.16906725,9.51084579 6.5780963,9.13484913 C7.98712536,8.75885247 9.48782803,8.95838137 10.7466462,9.687745 L12.2748018,10.56961 C14.0209791,9.13635584 15.9392199,8.03072455 17.9996486,7.25963195 Z M13.7518374,14.537862 C13.108069,15.069723 12.2016163,15.1456339 11.4783538,14.728255 L8.74433999,13.1505123 C8.40103903,12.9516035 7.99274958,12.8973186 7.60940137,12.9996143 C7.22605315,13.10191 6.89909107,13.3523954 6.70101753,13.6950576 L5.19724591,16.2994454 C4.78547321,17.0179634 5.03203388,17.9341714 5.74706822,18.3479886 L8.47306822,19.9219886 C9.19530115,20.3390079 9.58295216,21.1604138 9.44574883,21.983032 L9.21798321,23.3486236 C9.07251948,24.2246212 8.99961081,25.111131 9,26 C9,26.8953847 9.0728258,27.7804297 9.21774883,28.649968 L9.44574883,30.016968 C9.58295216,30.8395862 9.19530115,31.6609921 8.47306822,32.0780114 L5.74435077,33.6535776 C5.40046982,33.851417 5.14932721,34.1778291 5.04623114,34.5609292 C4.94313508,34.9440294 4.9965408,35.3523984 5.19401753,35.6949424 L6.69795587,38.2996585 C7.11427713,39.0156351 8.03110189,39.260288 8.7470791,38.8479035 L11.4770791,37.2719035 C12.200376,36.8543519 13.1069795,36.9302031 13.7508374,37.462138 L14.8210499,38.3463136 C16.1898549,39.4774943 17.737648,40.3725891 19.3990866,40.9941596 L20.6990866,41.4791596 C21.4813437,41.7710017 22,42.5180761 22,43.353 L22,46.5 C22,47.3308348 22.6679761,48 23.493,48 L26.5007228,48.0000099 C27.328845,47.9974107 27.99906,47.3258525 28,46.5 L28,43.353 C28,42.5185702 28.5180515,41.771829 29.2996486,41.4796319 L30.599003,40.9938734 C32.261836,40.3715765 33.8093225,39.4764853 35.1790197,38.3444304 L36.2490197,37.4614304 C36.8927697,36.9301861 37.798736,36.8545694 38.5216462,37.271745 L41.25566,38.8494877 C41.598961,39.0483965 42.0072504,39.1026814 42.3905986,39.0003857 C42.7739468,38.89809 43.1009089,38.6476046 43.2989825,38.3049424 L44.8027541,35.7005546 C45.2145268,34.9820366 44.9679661,34.0658286 44.2529318,33.6520114 L41.5269318,32.0780114 C40.8046988,31.6609921 40.4170478,30.8395862 40.5542512,30.016968 L40.7821577,28.6505288 C40.9272286,27.7792134 41,26.8950523 41,26 C41,25.1046153 40.9271742,24.2195703 40.7822512,23.350032 L40.5542512,21.983032 C40.4170478,21.1604138 40.8046988,20.3390079 41.5269318,19.9219886 L44.2556492,18.3464224 C44.5995302,18.148583 44.8506728,17.8221709 44.9537689,17.4390708 C45.0568649,17.0559706 45.0034592,16.6476016 44.8059825,16.3050576 L43.3020441,13.7003415 C42.8857229,12.9843649 41.9688981,12.739712 41.2529209,13.1520965 L38.5229209,14.7280965 C37.799624,15.1456481 36.8930205,15.0697969 36.2491626,14.537862 L35.1789501,13.6536864 C33.8101451,12.5225057 32.262352,11.6274109 30.6021792,11.0063122 L29.3021792,10.5223122 C28.5192618,10.230826 28,9.48341836 28,8.648 L28,5.5 C28,4.66916515 27.3320239,4 26.507,4 L23.4992772,3.99999015 C22.671155,4.00258933 22.00094,4.67414748 22,5.5 L22,8.647 C22,9.48142977 21.4819485,10.228171 20.7003514,10.5203681 L19.400997,11.0061266 C17.738164,11.6284235 16.1906775,12.5235147 14.822142,13.6546103 C14.8121128,13.6628994 14.4553446,13.9573166 13.7518374,14.537862 Z"/>
+ <g transform="rotate(15 -59.137 82.348)">
+ <circle cx="8" cy="8" r="8" fill="#FFFFFF" transform="translate(.035 6.008)"/>
+ <path fill="#6B4FBB" d="M7.40192379,14.7679492 C2.98364579,14.7679492 -0.598076211,11.1862272 -0.598076211,6.76794919 C-0.598076211,2.34967119 2.98364579,-1.23205081 7.40192379,-1.23205081 C11.8202018,-1.23205081 15.4019238,2.34967119 15.4019238,6.76794919 C15.4019238,11.1862272 11.8202018,14.7679492 7.40192379,14.7679492 Z M7.40192379,10.7679492 C9.61106279,10.7679492 11.4019238,8.97708819 11.4019238,6.76794919 C11.4019238,4.55881019 9.61106279,2.76794919 7.40192379,2.76794919 C5.19278479,2.76794919 3.40192379,4.55881019 3.40192379,6.76794919 C3.40192379,8.97708819 5.19278479,10.7679492 7.40192379,10.7679492 Z"/>
+ </g>
+ </g>
+ <g fill-rule="nonzero" transform="rotate(15 -402.968 460.884)">
+ <path fill="#FFFFFF" d="M9.82,8.53730769 C11.1889728,7.39547918 12.7368384,6.49195101 14.4,5.86384615 L14.4,4.44230769 C14.4,1.98934615 16.376,0 18.7944,0 L21.2056,0 C22.3719078,0.00042809204 23.4902431,0.468773604 24.314422,1.30193769 C25.1386009,2.13510179 25.6010613,3.26478579 25.6,4.44230769 L25.6,5.86384615 C27.2784,6.49626923 28.824,7.40653846 30.18,8.53730769 L31.4024,7.82492308 C33.5052912,6.60101478 36.192603,7.32608729 37.4104,9.44596154 L38.616,11.5540385 C39.1987431,12.5740464 39.3561196,13.7860498 39.0534853,14.9232439 C38.750851,16.060438 38.0130146,17.0296006 37.0024,17.6173846 L35.7824,18.3289615 C35.9256,19.1980385 36,20.0897308 36,21 C36,21.9102692 35.9256,22.8019615 35.7824,23.6710385 L37.0024,24.3818077 C39.1040303,25.6085057 39.8258195,28.3210992 38.616,30.4459615 L37.4104,32.5540385 C36.8268675,33.573657 35.8659065,34.317347 34.739079,34.6213801 C33.6122515,34.9254132 32.4119396,34.7648634 31.4024,34.1750769 L30.18,33.4626923 C28.8110272,34.6045208 27.2631616,35.508049 25.6,36.1361538 L25.6,37.5576923 C25.6,40.0106538 23.624,42 21.2056,42 L18.7944,42 C17.6280922,41.9995719 16.5097569,41.5312264 15.685578,40.6980623 C14.8613991,39.8648982 14.3989387,38.7352142 14.4,37.5576923 L14.4,36.1361538 C12.7368384,35.508049 11.1889728,34.6045208 9.82,33.4626923 L8.5976,34.1750769 C6.49470875,35.3989852 3.80739703,34.6739127 2.5896,32.5540385 L1.384,30.4459615 C0.8012569,29.4259536 0.643880418,28.2139502 0.946514692,27.0767561 C1.24914897,25.939562 1.98698538,24.9703994 2.9976,24.3826154 L4.2176,23.6710385 C4.07240963,22.7882521 3.99962928,21.8948738 4,21 C4,20.0897308 4.0744,19.1980385 4.2176,18.3289615 L2.9976,17.6181923 C0.895969702,16.3914943 0.174180473,13.6789008 1.384,11.5540385 L2.5896,9.44596154 C3.17313247,8.42634297 4.13409345,7.682653 5.260921,7.37861991 C6.38774855,7.07458682 7.58806043,7.23513658 8.5976,7.82492308 L9.82,8.53730769 Z"/>
+ <path fill="#FEE1D3" d="M14.0000007,5.6038043 L14.0000013,4.44005609 C14.0029906,1.78475013 16.1390906,-0.376211234 18.7944,-0.384615385 L21.2056,-0.384615385 C23.8595941,-0.384615385 26,1.78021801 26,4.44230769 L26,5.60295806 C27.5208716,6.20655954 28.9434678,7.03621848 30.2204219,8.06411282 L31.1970056,7.49492104 C33.4941909,6.15907529 36.4301298,6.95005805 37.7609369,9.26076474 L38.9671983,11.3699991 C39.5988409,12.4761812 39.768854,13.7886936 39.4405746,15.0202941 C39.1116282,16.2543969 38.308799,17.3078735 37.2096539,17.946304 L36.2175721,18.5246428 C36.3390841,19.3401617 36.4,20.1667594 36.4,21 C36.4,21.8329668 36.339124,22.6588262 36.2175401,23.4753391 L37.2113882,24.0547082 C39.4944154,25.3886826 40.276605,28.3232105 38.9665369,30.6311583 L37.7604568,32.7400742 C37.1252608,33.8495148 36.0768547,34.6604208 34.8452776,34.9922248 C33.6111681,35.324711 32.2964469,35.1482289 31.195569,34.5042428 L30.2192355,33.9354047 C28.9426535,34.9630196 27.5206806,35.7924453 25.9999993,36.3961957 L25.9999987,37.5599439 C25.9970094,40.2152499 23.8609094,42.3762112 21.2056,42.3846154 L18.7944,42.3846154 C16.1404059,42.3846154 14,40.219782 14,37.5576923 L14,36.3970419 C12.4791284,35.7934405 11.0565322,34.9637815 9.77957815,33.9358872 L8.80299442,34.505079 C6.50580915,35.8409247 3.56987021,35.049942 2.23906313,32.7392353 L1.03280169,30.6300009 C0.401159146,29.5238188 0.231145999,28.2113064 0.559425405,26.9797059 C0.888371786,25.7456031 1.69120101,24.6921265 2.79034606,24.053696 L3.78242779,23.4753573 C3.66091587,22.6598457 3.60000002,21.8333228 3.60000019,21.0008678 C3.59964068,20.1722851 3.66061719,19.3449468 3.78254167,18.5247085 L2.78861183,17.9452918 C0.505584602,16.6113174 -0.276605002,13.6767895 1.03346313,11.3688417 L2.23954317,9.25992583 C2.87473915,8.15048519 3.92314533,7.33957919 5.15472238,7.00777521 C6.38883187,6.67528896 7.70355311,6.85177112 8.80443097,7.49575721 L9.78076186,8.06459377 C11.0573465,7.03698045 12.4793194,6.20755475 14.0000007,5.6038043 Z M11.2634746,12.0326234 C10.617233,12.5716613 9.7026973,12.6485026 8.97556903,12.2248582 L6.78774825,10.9501716 C6.60754053,10.8447551 6.39506809,10.8162338 6.19527576,10.8700606 C5.99295099,10.9245697 5.8183659,11.0596053 5.71133687,11.246543 L4.50892658,13.3490215 C4.28085652,13.7508163 4.41776119,14.2644394 4.80485394,14.4906191 L6.98565394,15.7619268 C7.70254629,16.1798426 8.08690703,16.9970357 7.95165511,17.8157512 L7.76948523,18.9184706 C7.65638664,19.6061109 7.59969735,20.3020342 7.6,21 C7.6,21.7031066 7.65662064,22.3978283 7.76925511,23.0801334 L7.95165511,24.1842488 C8.08690703,25.0029643 7.70254629,25.8201574 6.98565394,26.2380732 L4.80213007,27.5109659 C4.61772321,27.6180778 4.48116147,27.7972748 4.42448029,28.0099246 C4.36713215,28.2250767 4.39688141,28.454743 4.50573687,28.6453801 L5.70831165,30.7481858 C5.93243371,31.1373303 6.41410538,31.2670993 6.79049373,31.0482253 L8.97449373,29.7753023 C9.7016554,29.3514832 10.6163433,29.4282639 11.2626746,29.9673766 L12.1188867,30.6815536 C13.1796505,31.566598 14.3786867,32.2666727 15.6649769,32.7525215 L16.7049769,33.1442523 C17.4841581,33.4377419 18,34.1832625 18,35.0158846 L18,37.5576923 C18,38.02074 18.3597694,38.3846154 18.7944,38.3846154 L21.1992624,38.3846254 C21.6372484,38.3832375 21.9994819,38.0167881 22,37.5576923 L22,35.0158846 C22,34.18376 22.5152346,33.4385758 23.2937506,33.1447321 L24.3331012,32.7524389 C25.620867,32.2658727 26.8196661,31.5658006 27.8813806,30.679856 L28.7373806,29.9666637 C29.3836087,29.4282468 30.2976553,29.3517028 31.024431,29.7751418 L33.2122517,31.0498284 C33.3924595,31.1552449 33.6049319,31.1837662 33.8047242,31.1299394 C34.007049,31.0754303 34.1816341,30.9403947 34.2886631,30.753457 L35.4910734,28.6509785 C35.7191435,28.2491837 35.5822388,27.7355606 35.1951461,27.5093809 L33.0143461,26.2380732 C32.2974537,25.8201574 31.913093,25.0029643 32.0483449,24.1842488 L32.2306531,23.0806893 C32.3434217,22.3968737 32.4,21.7028459 32.4,21 C32.4,20.2968934 32.3433794,19.6021717 32.2307449,18.9198666 L32.0483449,17.8157512 C31.913093,16.9970357 32.2974537,16.1798426 33.0143461,15.7619268 L35.1978699,14.4890341 C35.3822768,14.3819222 35.5188385,14.2027252 35.5755197,13.9900754 C35.6328679,13.7749233 35.6031186,13.545257 35.4942631,13.3546199 L34.2916883,11.2518142 C34.0675663,10.8626697 33.5858946,10.7329007 33.2095063,10.9517747 L31.0255063,12.2246977 C30.2983446,12.6485168 29.3836567,12.5717361 28.7373254,12.0326234 L27.8811133,11.3184464 C26.8203495,10.433402 25.6213133,9.73332732 24.3362966,9.24795765 L23.2962966,8.85703457 C22.5164499,8.56389992 22,7.81804293 22,6.98492308 L22,4.44230769 C22,3.97925995 21.6402306,3.61538462 21.2056,3.61538462 L18.8007376,3.61537457 C18.3627516,3.61676247 18.0005181,3.98321188 18,4.44230769 L18,6.98411538 C18,7.81623999 17.4847654,8.56142419 16.7062494,8.85526793 L15.6668988,9.24756113 C14.379133,9.73412728 13.1803339,10.4341994 12.1197785,11.3191775 C12.1108094,11.3266617 11.8253748,11.564477 11.2634746,12.0326234 Z"/>
+ <g transform="rotate(15 -47.892 66.043)">
+ <ellipse cx="6.4" cy="6.462" fill="#FFFFFF" rx="6.4" ry="6.462" transform="translate(.028 4.853)"/>
+ <path fill="#FC6D26" d="M5.92153903,11.9125743 C2.3834711,11.9125743 -0.478460969,9.0231237 -0.478460969,5.4664205 C-0.478460969,1.9097173 2.3834711,-0.979733345 5.92153903,-0.979733345 C9.45960696,-0.979733345 12.321539,1.9097173 12.321539,5.4664205 C12.321539,9.0231237 9.45960696,11.9125743 5.92153903,11.9125743 Z M5.92153903,8.71257435 C7.6854047,8.71257435 9.12153903,7.26263103 9.12153903,5.4664205 C9.12153903,3.67020997 7.6854047,2.22026666 5.92153903,2.22026666 C4.15767337,2.22026666 2.72153903,3.67020997 2.72153903,5.4664205 C2.72153903,7.26263103 4.15767337,8.71257435 5.92153903,8.71257435 Z"/>
+ </g>
+ </g>
+ <path fill="#000000" fill-opacity=".03" d="M61.6792606,38.251778 C61.8904713,36.8653316 62,35.4454567 62,34 C62,18.536027 49.463973,6 34,6 C18.536027,6 6,18.536027 6,34 C6,49.463973 18.536027,62 34,62 C42.8132237,62 50.6754255,57.9281916 55.8080076,51.5631726 L64.2689981,50.6250607 C64.4699867,50.6027761 64.6664333,50.5501384 64.8516368,50.4689431 C65.8632575,50.0254374 66.3238058,48.8458244 65.8803001,47.8342037 L65.8803001,47.8342037 L61.6792599,38.2517794 Z"/>
+ <path fill="#FFFFFF" d="M63.6792606,34.251778 C63.8904713,32.8653316 64,31.4454567 64,30 C64,14.536027 51.463973,2 36,2 C20.536027,2 8,14.536027 8,30 C8,45.463973 20.536027,58 36,58 C44.8132237,58 52.6754255,53.9281916 57.8080076,47.5631726 L66.2689981,46.6250607 C66.4699867,46.6027761 66.6664333,46.5501384 66.8516368,46.4689431 C67.8632575,46.0254374 68.3238058,44.8458244 67.8803001,43.8342037 L67.8803001,43.8342037 L63.6792599,34.2517794 Z"/>
+ <path fill="#EEEEEE" fill-rule="nonzero" d="M69.7120015,43.0311656 C70.5990128,45.0544071 69.6779163,47.4136331 67.6546748,48.3006445 C67.2842678,48.463035 66.8913746,48.5683104 66.4893975,48.6128796 L58.8313193,49.4619687 C53.1777737,56.0908093 44.9077957,60 36,60 C19.4314575,60 6,46.5685425 6,30 C6,13.4314575 19.4314575,0 36,0 C52.5685425,0 66,13.4314575 66,30 C66,31.335699 65.9125851,32.6609639 65.739427,33.9698636 L69.7120015,43.0311656 Z M61.7020717,33.9505738 C61.8999153,32.6518726 62,31.332589 62,30 C62,15.6405965 50.3594035,4 36,4 C21.6405965,4 10,15.6405965 10,30 C10,44.3594035 21.6405965,56 36,56 C43.969518,56 51.3430155,52.3943837 56.251122,46.3077415 L56.7684631,45.6661764 L66.0485988,44.6372417 L61.8475593,35.054816 L61.6147491,34.5237842 L61.7020717,33.9505738 Z"/>
+ <g fill="#31AF64" fill-rule="nonzero" transform="translate(24 18)">
+ <path d="M12.5,26.5 C4.7680135,26.5 -1.5,20.2319865 -1.5,12.5 C-1.5,4.7680135 4.7680135,-1.5 12.5,-1.5 C20.2319865,-1.5 26.5,4.7680135 26.5,12.5 C26.5,20.2319865 20.2319865,26.5 12.5,26.5 Z M12.5,23.5 C18.5751322,23.5 23.5,18.5751322 23.5,12.5 C23.5,6.42486775 18.5751322,1.5 12.5,1.5 C6.42486775,1.5 1.5,6.42486775 1.5,12.5 C1.5,18.5751322 6.42486775,23.5 12.5,23.5 Z"/>
+ <path d="M11.18,13.81 L9.248,11.878 C8.67483243,11.3054203 7.74616757,11.3054203 7.173,11.878 C6.89709997,12.1525667 6.74198837,12.5257601 6.74198837,12.915 C6.74198837,13.3042399 6.89709997,13.6774333 7.173,13.952 L10.048,16.826 C10.0636337,16.8423622 10.0796378,16.8583663 10.096,16.874 C10.646,17.424 11.526,17.423 12.071,16.879 L17.879,11.071 C18.1403709,10.8085057 18.2866977,10.4528922 18.2857599,10.0824639 C18.2848221,9.71203558 18.1366966,9.35716757 17.874,9.096 C17.6132271,8.83256594 17.2582132,8.68392968 16.8875393,8.68299126 C16.5168653,8.68205285 16.1611034,8.82888967 15.899,9.091 L11.18,13.81 Z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_check_square_o.svg b/app/views/shared/icons/_icon_check_square_o.svg
index 3dfbfc8c0e9..05ed4d715d5 100644
--- a/app/views/shared/icons/_icon_check_square_o.svg
+++ b/app/views/shared/icons/_icon_check_square_o.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1472 930v318q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-10 10-23 10-3 0-9-2-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-254q0-13 9-22l64-64q10-10 23-10 6 0 12 3 20 8 20 29zm231-489l-814 814q-24 24-57 24t-57-24l-430-430q-24-24-24-57t24-57l110-110q24-24 57-24t57 24l263 263 647-647q24-24 57-24t57 24l110 110q24 24 24 57t-24 57z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.536 8.657l2.828-2.829a1 1 0 0 1 1.414 1.415l-3.535 3.535a.997.997 0 0 1-1.415 0l-2.12-2.121A1 1 0 0 1 6.12 7.243l1.415 1.414zM3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_clock_o.svg b/app/views/shared/icons/_icon_clock_o.svg
index 8ddce62614c..9787ff6cb9e 100644
--- a/app/views/shared/icons/_icon_clock_o.svg
+++ b/app/views/shared/icons/_icon_clock_o.svg
@@ -1 +1 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/></svg>
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="M9 7h1c.552 0 1 .448 1 1s-.448 1-1 1H8c-.276 0-.526-.112-.707-.293S7 8.277 7 8V5c0-.552.448-1 1-1s1 .448 1 1zm-1 9c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8zm0-2c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6z"/></svg>
diff --git a/app/views/shared/icons/_icon_clone.svg b/app/views/shared/icons/_icon_clone.svg
new file mode 100644
index 00000000000..faba51b5797
--- /dev/null
+++ b/app/views/shared/icons/_icon_clone.svg
@@ -0,0 +1,2 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.874 2H12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-2c-.918 0-1.74-.413-2.29-1.063a3.987 3.987 0 0 0 1.988-.984A1 1 0 0 0 10 14h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-1V3c0-.345-.044-.68-.126-1zM4 0h3a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_code_fork.svg b/app/views/shared/icons/_icon_code_fork.svg
index 5a0df2eee19..968e0ad3b2b 100644
--- a/app/views/shared/icons/_icon_code_fork.svg
+++ b/app/views/shared/icons/_icon_code_fork.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M672 1472q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm0-1152q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm640 128q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm96 0q0 52-26 96.5t-70 69.5q-2 287-226 414-68 38-203 81-128 40-169.5 71t-41.5 100v26q44 25 70 69.5t26 96.5q0 80-56 136t-136 56-136-56-56-136q0-52 26-96.5t70-69.5v-820q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136q0 52-26 96.5t-70 69.5v497q54-26 154-57 55-17 87.5-29.5t70.5-31 59-39.5 40.5-51 28-69.5 8.5-91.5q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6 11.978v.29a2 2 0 1 1-2 0V3.732a2 2 0 1 1 2 0v3.849c.592-.491 1.31-.854 2.15-1.081 1.308-.353 1.875-.882 1.893-1.743a2 2 0 1 1 2.002-.051C12.053 6.54 10.857 7.84 8.67 8.43 7.056 8.867 6.195 9.98 6 11.978zM5 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm6 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_comment_o.svg b/app/views/shared/icons/_icon_comment_o.svg
index b99bd5f42c8..050d7356f54 100644
--- a/app/views/shared/icons/_icon_comment_o.svg
+++ b/app/views/shared/icons/_icon_comment_o.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M896 384q-204 0-381.5 69.5t-282 187.5-104.5 255q0 112 71.5 213.5t201.5 175.5l87 50-27 96q-24 91-70 172 152-63 275-171l43-38 57 6q69 8 130 8 204 0 381.5-69.5t282-187.5 104.5-255-104.5-255-282-187.5-381.5-69.5zm896 512q0 174-120 321.5t-326 233-450 85.5q-70 0-145-8-198 175-460 242-49 14-114 22h-5q-15 0-27-10.5t-16-27.5v-1q-3-4-.5-12t2-10 4.5-9.5l6-9 7-8.5 8-9q7-8 31-34.5t34.5-38 31-39.5 32.5-51 27-59 26-76q-157-89-247.5-220t-90.5-281q0-174 120-321.5t326-233 450-85.5 450 85.5 326 233 120 321.5z"/></svg>
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m1.707 15.707c-.63.63-1.707.184-1.707-.707v-12a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-7.586zm.293-3.121 2.293-2.293a1 1 0 0 1 .707-.293h8a1 1 0 0 0 1-1v-6a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_commit.svg b/app/views/shared/icons/_icon_commit.svg
index 7e9c0ded04e..6060d2e2aa4 100644
--- a/app/views/shared/icons/_icon_commit.svg
+++ b/app/views/shared/icons/_icon_commit.svg
@@ -1 +1,2 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 18" enable-background="new 0 0 36 18"><path d="m34 7h-7.2c-.9-4-4.5-7-8.8-7s-7.9 3-8.8 7h-7.2c-1.1 0-2 .9-2 2 0 1.1.9 2 2 2h7.2c.9 4 4.5 7 8.8 7s7.9-3 8.8-7h7.2c1.1 0 2-.9 2-2 0-1.1-.9-2-2-2m-16 7c-2.8 0-5-2.2-5-5s2.2-5 5-5 5 2.2 5 5-2.2 5-5 5"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3.876-1.008a4.002 4.002 0 0 1-7.752 0A1.01 1.01 0 0 1 4 9H1a1 1 0 1 1 0-2h3c.042 0 .083.003.124.008a4.002 4.002 0 0 1 7.752 0A1.01 1.01 0 0 1 12 7h3a1 1 0 0 1 0 2h-3a1.01 1.01 0 0 1-.124-.008z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_edit.svg b/app/views/shared/icons/_icon_edit.svg
index cd4e34147e1..1c10ef138b5 100644
--- a/app/views/shared/icons/_icon_edit.svg
+++ b/app/views/shared/icons/_icon_edit.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M888 1184l116-116-152-152-116 116v56h96v96h56zm440-720q-16-16-33 1l-350 350q-17 17-1 33t33-1l350-350q17-17 1-33zm80 594v190q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-14 14-32 8-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-126q0-13 9-22l64-64q15-15 35-7t20 29zm-96-738l288 288-672 672h-288v-288zm444 132l-92 92-288-288 92-92q28-28 68-28t68 28l152 152q28 28 28 68t-28 68z"/></svg>
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m13.436 1.413 1.415 1.414a1 1 0 0 1 0 1.414l-10.316 10.315a1 1 0 0 1 -.703.293l-2.407.008-.008-2.421a1 1 0 0 1 .293-.71l10.312-10.314a1 1 0 0 1 1.414 0zm-9.608 12.436 10.315-10.315-1.413-1.414-10.313 10.312.005 1.422zm7.486-10.313 1.414 1.414-7.778 7.778-1.407.007-.007-1.421zm1.414-1.415 1.414 1.415-.707.707-1.414-1.415z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_eye.svg b/app/views/shared/icons/_icon_eye.svg
index 2e2ae67142f..9fea9841eb3 100644
--- a/app/views/shared/icons/_icon_eye.svg
+++ b/app/views/shared/icons/_icon_eye.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 14C4.816 14 2.253 12.284.393 8.981a2 2 0 0 1 0-1.962C2.253 3.716 4.816 2 8 2s5.747 1.716 7.607 5.019a2 2 0 0 1 0 1.962C13.747 12.284 11.184 14 8 14zm0-2c2.41 0 4.338-1.29 5.864-4C12.338 5.29 10.411 4 8 4 5.59 4 3.662 5.29 2.136 8 3.662 10.71 5.589 12 8 12zm0-1a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm1-3a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_eye_slash.svg b/app/views/shared/icons/_icon_eye_slash.svg
index a16c5dcb24b..45c06b3495d 100644
--- a/app/views/shared/icons/_icon_eye_slash.svg
+++ b/app/views/shared/icons/_icon_eye_slash.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M555 1335l78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173t-208.5-245q-20-31-20-69t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5 19.5 11.5q16 10 16 27zm37 447q0 139-79 253.5t-209 164.5l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267t-419.5 95l74-132q212-18 392.5-137t301.5-307q-115-179-282-294l63-112q95 64 182.5 153t144.5 184q20 34 20 69z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13.618 2.62L1.62 14.619a1 1 0 0 1-.985-1.668l1.525-1.526C1.516 10.742.926 9.927.393 8.981a2 2 0 0 1 0-1.962C2.253 3.716 4.816 2 8 2c1.074 0 2.076.195 3.006.58l.944-.944a1 1 0 0 1 1.668.985zM8.068 11a3 3 0 0 0 2.931-2.932l-2.931 2.931zm-3.02-2.462a3 3 0 0 1 3.49-3.49l.884-.884A6.044 6.044 0 0 0 8 4C5.59 4 3.662 5.29 2.136 8c.445.79.924 1.46 1.439 2.011l1.473-1.473zm.421 5.06l1.658-1.658c.283.04.575.06.873.06 2.41 0 4.338-1.29 5.864-4a11.023 11.023 0 0 0-1.133-1.664l1.418-1.418a12.799 12.799 0 0 1 1.458 2.1 2 2 0 0 1 0 1.963C13.747 12.284 11.184 14 8 14a7.883 7.883 0 0 1-2.53-.402z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_hourglass.svg b/app/views/shared/icons/_icon_hourglass.svg
new file mode 100644
index 00000000000..fe7e497ce13
--- /dev/null
+++ b/app/views/shared/icons/_icon_hourglass.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.331 4.889A2.988 2.988 0 0 0 11 3V2H5v1c0 .362.064.709.182 1.03l5.15.859zM3 14v-1c0-1.78.93-3.342 2.33-4.228.447-.327.67-.582.67-.764 0-.19-.242-.46-.725-.815A4.996 4.996 0 0 1 3 3V2H2a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2h-1v1a4.997 4.997 0 0 1-2.39 4.266c-.407.3-.61.545-.61.734 0 .19.203.434.61.734A4.997 4.997 0 0 1 13 13v1h1a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2h1zm8 0v-1a3 3 0 0 0-6 0v1h6z"/></svg>
diff --git a/app/views/shared/icons/_icon_merged.svg b/app/views/shared/icons/_icon_merged.svg
index 43d591daefa..77d170b2491 100644
--- a/app/views/shared/icons/_icon_merged.svg
+++ b/app/views/shared/icons/_icon_merged.svg
@@ -1 +1,2 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m2 3c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m.761.85c.154 2.556 1.987 4.692 4.45 5.255.328-.655 1.01-1.105 1.789-1.105 1.105 0 2 .895 2 2 0 1.105-.895 2-2 2-.89 0-1.645-.582-1.904-1.386-1.916-.376-3.548-1.5-4.596-3.044v4.493c.863.222 1.5 1.01 1.5 1.937 0 1.105-.895 2-2 2-1.105 0-2-.895-2-2 0-.74.402-1.387 1-1.732v-8.535c-.598-.346-1-.992-1-1.732 0-1.105.895-2 2-2 1.105 0 2 .895 2 2 0 .835-.512 1.551-1.239 1.85m6.239 7.15c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m-7 4c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1" transform="translate(3)"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11 12.268V5a1 1 0 0 0-1-1v1a.5.5 0 0 1-.8.4l-2.667-2a.5.5 0 0 1 0-.8L9.2.6a.5.5 0 0 1 .8.4v1a3 3 0 0 1 3 3v7.268a2 2 0 1 1-2 0zm-6 0a2 2 0 1 1-2 0V4.732a2 2 0 1 1 2 0v7.536zM4 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm8 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_pencil.svg b/app/views/shared/icons/_icon_pencil.svg
index a3b48404f87..bcde54bd1e1 100644
--- a/app/views/shared/icons/_icon_pencil.svg
+++ b/app/views/shared/icons/_icon_pencil.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13.02 1.293l1.414 1.414a1 1 0 0 1 0 1.414L4.119 14.436a1 1 0 0 1-.704.293l-2.407.008L1 12.316a1 1 0 0 1 .293-.71L11.605 1.292a1 1 0 0 1 1.414 0zm-1.416 1.415l-.707.707L12.31 4.83l.707-.707-1.414-1.415zM3.411 13.73l1.123-1.122H3.12v-1.415L2 12.312l.005 1.422 1.406-.005z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_random.svg b/app/views/shared/icons/_icon_random.svg
index 763bd2d3dd8..74c1c903657 100644
--- a/app/views/shared/icons/_icon_random.svg
+++ b/app/views/shared/icons/_icon_random.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M666 481q-60 92-137 273-22-45-37-72.5t-40.5-63.5-51-56.5-63-35-81.5-14.5h-224q-14 0-23-9t-9-23v-192q0-14 9-23t23-9h224q250 0 410 225zm1126 799q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192q-32 0-85 .5t-81 1-73-1-71-5-64-10.5-63-18.5-58-28.5-59-40-55-53.5-56-69.5q59-93 136-273 22 45 37 72.5t40.5 63.5 51 56.5 63 35 81.5 14.5h256v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm0-896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-256q-48 0-87 15t-69 45-51 61.5-45 77.5q-32 62-78 171-29 66-49.5 111t-54 105-64 100-74 83-90 68.5-106.5 42-128 16.5h-224q-14 0-23-9t-9-23v-192q0-14 9-23t23-9h224q48 0 87-15t69-45 51-61.5 45-77.5q32-62 78-171 29-66 49.5-111t54-105 64-100 74-83 90-68.5 106.5-42 128-16.5h256v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.707 15.707C1.077 16.337 0 15.891 0 15V3a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H5.414l-3.707 3.707zM2 12.586l2.293-2.293A1 1 0 0 1 5 10h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v9.586zM5 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
+
diff --git a/app/views/shared/icons/_icon_resolve_discussion.svg b/app/views/shared/icons/_icon_resolve_discussion.svg
new file mode 100644
index 00000000000..845562e9320
--- /dev/null
+++ b/app/views/shared/icons/_icon_resolve_discussion.svg
@@ -0,0 +1 @@
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_canceled.svg b/app/views/shared/icons/_icon_status_canceled.svg
index bd5d04e1cd7..bd5d04e1cd7 100755..100644
--- a/app/views/shared/icons/_icon_status_canceled.svg
+++ b/app/views/shared/icons/_icon_status_canceled.svg
diff --git a/app/views/shared/icons/_icon_status_closed.svg b/app/views/shared/icons/_icon_status_closed.svg
index de448ee1194..dc39223e721 100644
--- a/app/views/shared/icons/_icon_status_closed.svg
+++ b/app/views/shared/icons/_icon_status_closed.svg
@@ -1 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><rect x="3.36" y="6.16" width="7.28" height="1.68" rx=".84"/></svg>
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="M7.536 8.657l2.828-2.83c.39-.39 1.024-.39 1.414 0 .39.392.39 1.025 0 1.416l-3.535 3.535c-.196.195-.452.293-.707.293-.256 0-.512-.097-.708-.292l-2.12-2.12c-.39-.392-.39-1.025 0-1.415s1.023-.39 1.413 0zM8 16c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8zm0-2c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_created.svg b/app/views/shared/icons/_icon_status_created.svg
index 326ad04e017..326ad04e017 100755..100644
--- a/app/views/shared/icons/_icon_status_created.svg
+++ b/app/views/shared/icons/_icon_status_created.svg
diff --git a/app/views/shared/icons/_icon_status_failed.svg b/app/views/shared/icons/_icon_status_failed.svg
index 64da5aa31fc..64da5aa31fc 100755..100644
--- a/app/views/shared/icons/_icon_status_failed.svg
+++ b/app/views/shared/icons/_icon_status_failed.svg
diff --git a/app/views/shared/icons/_icon_status_manual.svg b/app/views/shared/icons/_icon_status_manual.svg
index c98839f51a9..c98839f51a9 100755..100644
--- a/app/views/shared/icons/_icon_status_manual.svg
+++ b/app/views/shared/icons/_icon_status_manual.svg
diff --git a/app/views/shared/icons/_icon_status_pending.svg b/app/views/shared/icons/_icon_status_pending.svg
index 02d5da407e3..02d5da407e3 100755..100644
--- a/app/views/shared/icons/_icon_status_pending.svg
+++ b/app/views/shared/icons/_icon_status_pending.svg
diff --git a/app/views/shared/icons/_icon_status_running.svg b/app/views/shared/icons/_icon_status_running.svg
index 532f4fee33c..532f4fee33c 100755..100644
--- a/app/views/shared/icons/_icon_status_running.svg
+++ b/app/views/shared/icons/_icon_status_running.svg
diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg
index a9ba29c922c..a9ba29c922c 100755..100644
--- a/app/views/shared/icons/_icon_status_skipped.svg
+++ b/app/views/shared/icons/_icon_status_skipped.svg
diff --git a/app/views/shared/icons/_icon_status_success.svg b/app/views/shared/icons/_icon_status_success.svg
index eed5006bebe..eed5006bebe 100755..100644
--- a/app/views/shared/icons/_icon_status_success.svg
+++ b/app/views/shared/icons/_icon_status_success.svg
diff --git a/app/views/shared/icons/_icon_status_success_borderless.svg b/app/views/shared/icons/_icon_status_success_borderless.svg
deleted file mode 100644
index 8ee5be7ab78..00000000000
--- a/app/views/shared/icons/_icon_status_success_borderless.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M11.4583333,12.375 L8.70008808,12.375 C8.45889044,12.375 8.25,12.5826293 8.25,12.8387529 L8.25,14.2029137 C8.25,14.4551799 8.4515113,14.6666667 8.70008808,14.6666667 L12.9619841,14.6666667 C13.3891296,14.6666667 13.75,14.3193051 13.75,13.8908129 L13.75,13.2899463 L13.75,6.42552703 C13.75,6.16226705 13.5423707,5.95833333 13.2862471,5.95833333 L11.9220863,5.95833333 C11.6698201,5.95833333 11.4583333,6.16750307 11.4583333,6.42552703 L11.4583333,12.375 Z" id="Combined-Shape" transform="translate(11.000000, 10.312500) rotate(-315.000000) translate(-11.000000, -10.312500) "></path></svg>
diff --git a/app/views/shared/icons/_icon_status_success_solid.svg b/app/views/shared/icons/_icon_status_success_solid.svg
new file mode 100644
index 00000000000..0aac6d933e1
--- /dev/null
+++ b/app/views/shared/icons/_icon_status_success_solid.svg
@@ -0,0 +1 @@
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_icon_status_warning.svg b/app/views/shared/icons/_icon_status_warning.svg
index cb785635b7e..cb785635b7e 100755..100644
--- a/app/views/shared/icons/_icon_status_warning.svg
+++ b/app/views/shared/icons/_icon_status_warning.svg
diff --git a/app/views/shared/icons/_icon_tags.svg b/app/views/shared/icons/_icon_tags.svg
index fc5acc89c5e..d36ea022f92 100644
--- a/app/views/shared/icons/_icon_tags.svg
+++ b/app/views/shared/icons/_icon_tags.svg
@@ -1 +1,2 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M384 448q0-53-37.5-90.5t-90.5-37.5-90.5 37.5-37.5 90.5 37.5 90.5 90.5 37.5 90.5-37.5 37.5-90.5zm1067 576q0 53-37 90l-491 492q-39 37-91 37-53 0-90-37l-715-716q-38-37-64.5-101t-26.5-117v-416q0-52 38-90t90-38h416q53 0 117 26.5t102 64.5l715 714q37 39 37 91zm384 0q0 53-37 90l-491 492q-39 37-91 37-36 0-59-14t-53-45l470-470q37-37 37-90 0-52-37-91l-715-714q-38-38-102-64.5t-117-26.5h224q53 0 117 26.5t102 64.5l715 714q37 39 37 91z"/></svg>
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m7.222.222c1.657 0 3 1.343 3 3v8.327c0 .631-.298 1.226-.805 1.603l-3 2.237c-.709.529-1.682.529-2.391 0l-3-2.237c-.506-.377-.805-.972-.805-1.603v-8.327c0-1.657 1.343-3 3-3h4m-5 3v8.08c0 .158.075.306.201.401l2.5 1.864c.177.132.42.132.598 0l2.5-1.864c.127-.094.201-.243.201-.401v-8.08c0-.552-.448-1-1-1h-4c-.552 0-1 .448-1 1m2.778 7.778c-.552 0-1-.448-1-1s .448-1 1-1 1 .448 1 1-.448 1-1 1" transform="matrix(-.70711 .70711 -.70711 -.70711 17.05 9.767)"/></svg>
+
diff --git a/app/views/shared/icons/_icon_user.svg b/app/views/shared/icons/_icon_user.svg
index 9b8cd74d62b..6e7406f7eac 100644
--- a/app/views/shared/icons/_icon_user.svg
+++ b/app/views/shared/icons/_icon_user.svg
@@ -1 +1 @@
-<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1600 1405q0 120-73 189.5t-194 69.5h-874q-121 0-194-69.5t-73-189.5q0-53 3.5-103.5t14-109 26.5-108.5 43-97.5 62-81 85.5-53.5 111.5-20q9 0 42 21.5t74.5 48 108 48 133.5 21.5 133.5-21.5 108-48 74.5-48 42-21.5q61 0 111.5 20t85.5 53.5 62 81 43 97.5 26.5 108.5 14 109 3.5 103.5zm-320-893q0 159-112.5 271.5t-271.5 112.5-271.5-112.5-112.5-271.5 112.5-271.5 271.5-112.5 271.5 112.5 112.5 271.5z"/></svg>
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="M8 7C6.343 7 5 5.657 5 4s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zm0 8c-6.888 0-6.976-.78-6.976-2.52S2.144 8 8 8s6.976 2.692 6.976 4.48S14.888 15 8 15z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_issues.svg b/app/views/shared/icons/_issues.svg
new file mode 100644
index 00000000000..439023c86d0
--- /dev/null
+++ b/app/views/shared/icons/_issues.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.458 15.012l.311.055a3 3 0 0 0 3.476-2.433l1.389-7.879A3 3 0 0 0 13.2 1.28L11.23.933a3.002 3.002 0 0 0-.824-.031c.364.59.58 1.28.593 2.02l1.854.328a1 1 0 0 1 .811 1.158l-1.389 7.879a1 1 0 0 1-1.158.81l-.118-.02a3.98 3.98 0 0 1-.541 1.935zM3 0h4a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3z"/></svg>
diff --git a/app/views/shared/icons/_issues.svg.erb b/app/views/shared/icons/_issues.svg.erb
deleted file mode 100644
index fa8655b5609..00000000000
--- a/app/views/shared/icons/_issues.svg.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-<svg width="<%= size %>" height="<%= size %>" viewBox="0 0 16 16" class="gitlab-icon">
- <path fill="#7E7C7C" d="M8,0 C3.581,0 0,3.581 0,8 C0,12.419 3.581,16 8,16 C12.419,16 16,12.419 16,8 C16,3.581 12.419,0 8,0 M8,2 C11.308,2 14,4.692 14,8 C14,11.308 11.308,14 8,14 C4.692,14 2,11.308 2,8 C2,4.692 4.692,2 8,2"></path>
- <path fill="#7E7C7C" d="M7.1597,4 L8.8887,4 L8.8887,8 L7.1107,8 L7.1597,4 Z M7.1597,9.6667 L8.8887,9.6667 L8.8887,11.4447 L7.1107,11.4447 L7.1597,9.6667 Z"></path>
-</svg>
diff --git a/app/views/shared/icons/_key.svg b/app/views/shared/icons/_key.svg
new file mode 100644
index 00000000000..5ad03ed4480
--- /dev/null
+++ b/app/views/shared/icons/_key.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.574 6.689a4.002 4.002 0 0 1 6.275-4.861 4 4 0 0 1-4.86 6.275l-2.21 2.21.706.707a1 1 0 0 1-1.414 1.415l-.707-.708-.707.708.707.707a1 1 0 0 1-1.414 1.414l-.707-.707a1 1 0 0 1-1.415-1.414l5.746-5.746zm2.033-.618a2 2 0 1 0 2.828-2.829 2 2 0 0 0-2.828 2.829z"/></svg>
diff --git a/app/views/shared/icons/_labels.svg b/app/views/shared/icons/_labels.svg
new file mode 100644
index 00000000000..1ebad4bb4fa
--- /dev/null
+++ b/app/views/shared/icons/_labels.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M9.424 2.254l2.08-.905a1 1 0 0 1 1.206.326l3.013 4.12a1 1 0 0 1 .16.849l-1.947 7.264a3 3 0 0 1-3.675 2.122l-.5-.135a3.999 3.999 0 0 0 1.082-1.782 1 1 0 0 0 1.16-.722l1.823-6.802-2.258-3.087-.687.299a2 2 0 0 0-.628-.88l-.829-.667z"/><path d="M.377 3.7L4.4.498a1 1 0 0 1 1.25.003L9.627 3.7a1 1 0 0 1 .373.78V13a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V4.482A1 1 0 0 1 .377 3.7zM2 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4.958L5.02 2.561 2 4.964V13zm3-6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_lightbulb.svg b/app/views/shared/icons/_lightbulb.svg
new file mode 100644
index 00000000000..2fcc4c65f99
--- /dev/null
+++ b/app/views/shared/icons/_lightbulb.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76S3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#6B4FBB" d="M33 52h12c1.105 0 2 .895 2 2s-.895 2-2 2H33c-1.105 0-2-.895-2-2s.895-2 2-2zm1 5h10c1.105 0 2 .895 2 2s-.895 2-2 2H34c-1.105 0-2-.895-2-2s.895-2 2-2z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M45.542 46.932l.346-2.36c.198-1.348.737-2.623 1.566-3.705 3.025-3.946 4.485-7.29 4.547-9.96C52.153 24.41 46.843 20 39 20c-7.777 0-13 4.374-13 11 0 2.4 1.462 5.73 4.573 9.846.815 1.08 1.343 2.345 1.536 3.683l.353 2.456 13.08-.054zm-17.038.624L28.15 45.1c-.097-.67-.36-1.303-.768-1.842C23.794 38.51 22 34.424 22 31c0-9.39 7.61-15 17-15s17.218 5.614 17 15c-.085 3.64-1.875 7.74-5.37 12.3-.416.54-.685 1.18-.784 1.853l-.346 2.36c-.288 1.958-1.963 3.41-3.942 3.42l-13.08.053c-1.994.008-3.69-1.455-3.974-3.43z"/><path fill="#6B4FBB" d="M41 38.732c-.598-.345-1-.992-1-1.732 0-1.105.895-2 2-2s2 .895 2 2c0 .74-.402 1.387-1 1.732V42c0 .552-.448 1-1 1s-1-.448-1-1v-3.268zm-6 0c-.598-.345-1-.992-1-1.732 0-1.105.895-2 2-2s2 .895 2 2c0 .74-.402 1.387-1 1.732V42c0 .552-.448 1-1 1s-1-.448-1-1v-3.268z"/></g></svg>
diff --git a/app/views/shared/icons/_lock.svg b/app/views/shared/icons/_lock.svg
new file mode 100644
index 00000000000..703c09611a3
--- /dev/null
+++ b/app/views/shared/icons/_lock.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m8 9c-.6 0-1 .4-1 1v1c0 .6.4 1 1 1s1-.4 1-1v-1c0-.6-.4-1-1-1"/><path d="m12 5v-1c0-2.2-1.8-4-4-4s-4 1.8-4 4v1c-1.7 0-3 1.3-3 3v5c0 1.7 1.3 3 3 3h8c1.7 0 3-1.3 3-3v-5c0-1.7-1.3-3-3-3m-6-1c0-1.1.9-2 2-2s2 .9 2 2v1h-4v-1m7 9c0 .6-.4 1-1 1h-8c-.6 0-1-.4-1-1v-5c0-.6.4-1 1-1h8c.6 0 1 .4 1 1v5"/></svg>
diff --git a/app/views/shared/icons/_members.svg b/app/views/shared/icons/_members.svg
new file mode 100644
index 00000000000..68d957d6d11
--- /dev/null
+++ b/app/views/shared/icons/_members.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M10.521 8.01C15.103 8.19 16 10.755 16 12.48c0 1.533-.056 2.29-3.808 2.475.609-.54.808-1.331.808-2.475 0-1.911-.804-3.503-2.479-4.47zm-1.67-1.228A3.987 3.987 0 0 0 9.976 4a3.987 3.987 0 0 0-1.125-2.782 3 3 0 1 1 0 5.563zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8s6 2.692 6 4.48c0 1.788-.076 2.52-6 2.52z"/></svg>
diff --git a/app/views/shared/icons/_messages.svg b/app/views/shared/icons/_messages.svg
new file mode 100644
index 00000000000..9a2ea15c35d
--- /dev/null
+++ b/app/views/shared/icons/_messages.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.588 8.942l1.173 5.862A1 1 0 0 1 8.78 16H7.22a1 1 0 0 1-.98-1.196l1.172-5.862a3.014 3.014 0 0 0 1.176 0zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zM4.464 2.464L5.88 3.88a3 3 0 0 0 0 4.242L4.464 9.536a5 5 0 0 1 0-7.072zm7.072 7.072L10.12 8.12a3 3 0 0 0 0-4.242l1.415-1.415a5 5 0 0 1 0 7.072zM2.343.343l1.414 1.414a6 6 0 0 0 0 8.486l-1.414 1.414a8 8 0 0 1 0-11.314zm11.314 11.314l-1.414-1.414a6 6 0 0 0 0-8.486L13.657.343a8 8 0 0 1 0 11.314z"/></svg>
diff --git a/app/views/shared/icons/_monitoring.svg b/app/views/shared/icons/_monitoring.svg
new file mode 100644
index 00000000000..21689b0877c
--- /dev/null
+++ b/app/views/shared/icons/_monitoring.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M10 13v1h3a1 1 0 0 1 0 2H3a1 1 0 0 1 0-2h3v-1H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3h-3zM3 2a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm5.723 6.416l-2.66-1.773-1.71 1.71a.5.5 0 1 1-.707-.707l2-2a.5.5 0 0 1 .631-.062l2.66 1.773 2.71-2.71a.5.5 0 0 1 .707.707l-3 3a.5.5 0 0 1-.631.062z"/></svg>
diff --git a/app/views/shared/icons/_mr_bold.svg b/app/views/shared/icons/_mr_bold.svg
index 5468545da2e..0f5be6e2bc8 100644
--- a/app/views/shared/icons/_mr_bold.svg
+++ b/app/views/shared/icons/_mr_bold.svg
@@ -1,2 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg>
-
+<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg>
diff --git a/app/views/shared/icons/_notifications.svg b/app/views/shared/icons/_notifications.svg
new file mode 100644
index 00000000000..da55de041da
--- /dev/null
+++ b/app/views/shared/icons/_notifications.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M6 14H2.435a2 2 0 0 1-1.761-2.947c.962-1.788 1.521-3.065 1.68-3.832.322-1.566.947-5.501 4.65-6.134a1 1 0 1 1 1.994-.024c3.755.528 4.375 4.27 4.761 6.043.188.86.742 2.188 1.661 3.982A2 2 0 0 1 13.64 14H10a2 2 0 1 1-4 0zm5.805-6.468c-.325-1.492-.37-1.674-.61-2.288C10.6 3.716 9.742 3 8.07 3c-1.608 0-2.49.718-3.103 2.197-.28.676-.356.982-.654 2.428-.208 1.012-.827 2.424-1.877 4.375H13.64c-.993-1.937-1.6-3.396-1.835-4.468z"/></svg>
diff --git a/app/views/shared/icons/_overview.svg b/app/views/shared/icons/_overview.svg
new file mode 100644
index 00000000000..4791282df7f
--- /dev/null
+++ b/app/views/shared/icons/_overview.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 2v3h3V2H2zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm9 2v3h3V2h-3zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zM2 11v3h3v-3H2zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zm9 2v3h3v-3h-3zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2z"/></svg>
diff --git a/app/views/shared/icons/_pipeline.svg b/app/views/shared/icons/_pipeline.svg
new file mode 100644
index 00000000000..5bedc96a1bd
--- /dev/null
+++ b/app/views/shared/icons/_pipeline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m8 0c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8m0 14c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6"/><circle cx="12.5" cy="9.5" r=".5"/><circle cx="12.5" cy="6.5" r=".5"/><circle cx="10.5" cy="12.5" r=".5"/><circle cx="10.5" cy="3.5" r=".5"/><circle cx="5.5" cy="12.5" r=".5"/><circle cx="5.5" cy="3.5" r=".5"/><circle cx="3.5" cy="9.5" r=".5"/><circle cx="3.5" cy="6.5" r=".5"/><path d="m9 7.2c0 0 0-.1 0-.2v-1.9c0-.1 0-.1-.1-.2l-.8-.8c0 0-.1 0-.1 0l-.9.8c-.1.1-.1.1-.1.2v1.9c0 .1 0 .2 0 .2-.6.4-1 1-1 1.8 0 1.1.9 2 2 2s2-.9 2-2c0-.8-.4-1.4-1-1.8m-1 2.8c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1-.4 1-1 1"/></svg>
diff --git a/app/views/shared/icons/_plus_square.svg b/app/views/shared/icons/_plus_square.svg
new file mode 100644
index 00000000000..7263d924f1f
--- /dev/null
+++ b/app/views/shared/icons/_plus_square.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 7V4c0-.552-.448-1-1-1s-1 .448-1 1v3H4c-.552 0-1 .448-1 1s.448 1 1 1h3v3c0 .552.448 1 1 1s1-.448 1-1V9h3c.552 0 1-.448 1-1s-.448-1-1-1H9zM3 0h10c1.657 0 3 1.343 3 3v10c0 1.657-1.343 3-3 3H3c-1.657 0-3-1.343-3-3V3c0-1.657 1.343-3 3-3z"/></svg>
diff --git a/app/views/shared/icons/_preferences.svg b/app/views/shared/icons/_preferences.svg
new file mode 100644
index 00000000000..cbd7a4fe9f0
--- /dev/null
+++ b/app/views/shared/icons/_preferences.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6 2h9a1 1 0 0 1 0 2H6a1 1 0 1 1-2 0V2a1 1 0 1 1 2 0zM3 2H1a1 1 0 1 0 0 2h2V2zm10 5h2a1 1 0 0 1 0 2h-2a1 1 0 0 1-2 0V7a1 1 0 0 1 2 0zm-3 0H1a1 1 0 1 0 0 2h9V7zm-5 5h10a1 1 0 0 1 0 2H5a1 1 0 0 1-2 0v-2a1 1 0 0 1 2 0zm-3 0H1a1 1 0 0 0 0 2h1v-2z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_profile.svg b/app/views/shared/icons/_profile.svg
new file mode 100644
index 00000000000..29e360a9051
--- /dev/null
+++ b/app/views/shared/icons/_profile.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12zm-4.274-3.404C4.412 9.709 5.694 9 8 9c2.313 0 3.595.7 4.28 1.586A4.997 4.997 0 0 1 8 13a4.997 4.997 0 0 1-4.274-2.404zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
diff --git a/app/views/shared/icons/_project.svg b/app/views/shared/icons/_project.svg
new file mode 100644
index 00000000000..bbfdd939e7b
--- /dev/null
+++ b/app/views/shared/icons/_project.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.462 2.177l-.038.044a.505.505 0 0 0 .038-.044zm-.787 0a.5.5 0 0 0 .038.043l-.038-.043zM3.706 7h8.725L8.069 2.585 3.706 7zM7 13.369V12a1 1 0 0 1 2 0v1.369h3V9H4v4.369h3zM14 9v4.836c0 .833-.657 1.533-1.5 1.533h-9c-.843 0-1.5-.7-1.5-1.533V9h-.448a1.1 1.1 0 0 1-.783-1.873L6.934.887a1.5 1.5 0 0 1 2.269 0l6.165 6.24A1.1 1.1 0 0 1 14.585 9H14z"/></svg>
diff --git a/app/views/shared/icons/_project.svg.erb b/app/views/shared/icons/_project.svg.erb
deleted file mode 100644
index 2f60bb7245e..00000000000
--- a/app/views/shared/icons/_project.svg.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<svg width="<%= size %>" height="<%= size %>" viewBox="0 0 16 16">
- <path d="M6,6 L12,6 L12,5 L6,5 L6,6 Z M6,8 L12,8 L12,7 L6,7 L6,8 Z M6,10 L12,10 L12,9 L6,9 L6,10 Z M6,12 L12,12 L12,11 L6,11 L6,12 Z M4,6 L5,6 L5,5 L4,5 L4,6 Z M4,8 L5,8 L5,7 L4,7 L4,8 Z M4,10 L5,10 L5,9 L4,9 L4,10 Z M4,12 L5,12 L5,11 L4,11 L4,12 Z M13,3 L10,3 L10,4 L6,4 L6,3 L3,3 L3,13 L13,13 L13,3 Z M2,14 L14,14 L14,2 L2,2 L2,14 Z M1,0 C0.448,0 0,0.448 0,1 L0,15 C0,15.552 0.448,16 1,16 L15,16 C15.552,16 16,15.552 16,15 L16,1 C16,0.448 15.552,0 15,0 L1,0 Z" fill="#7F7E7E" fill-rule="evenodd"></path>
-</svg>
diff --git a/app/views/shared/icons/_rails.svg b/app/views/shared/icons/_rails.svg
new file mode 100644
index 00000000000..852bd183cc7
--- /dev/null
+++ b/app/views/shared/icons/_rails.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20" viewBox="0 0 32 20" class="btn-template-icon icon-rails"><g fill="none" fill-rule="evenodd"><path d="M0-6h32v32H0z"/><path fill="#c00" fill-rule="nonzero" d="M.985 19.636s.422-4.163 3.375-9.087c2.954-4.924 7.99-8.65 12.083-9.017 8.144-.816 15.46 6.485 15.46 6.485s-.24.168-.494.38C23.42 2.49 18.54 5.274 17.005 6.02c-7.033 3.925-4.91 13.616-4.91 13.616H.987zM24.137 2.32c-.45-.182-.9-.35-1.364-.505l.056-.93c.885.254 1.237.423 1.363.493l-.056.943zM22.8 5.304c.45.028.915.084 1.393.183l-.056.872c-.464-.1-.928-.155-1.392-.17l.056-.885zM17.597.913c-.407 0-.815.015-1.223.058l-.268-.83c.465-.056.915-.084 1.35-.084l.282.858h-.14zm.676 5.178c.35-.154.76-.31 1.237-.45l.31.93c-.41.125-.817.294-1.225.49l-.323-.97zm-6.386-3.7c-.366.184-.718.395-1.083.62l-.647-.985c.38-.225.745-.42 1.097-.604l.633.97zm2.883 6.33c.252-.323.548-.646.87-.942l.634.957c-.31.323-.59.647-.83 1L14.77 8.72zm-2.04 4.53c.112-.506.24-1.027.422-1.547l1.012.802c-.14.548-.24 1.097-.295 1.645l-1.14-.9zM6.57 6.57c-.34.35-.662.73-.958 1.11L4.53 6.752c.323-.352.674-.704 1.04-1.055l1 .872zm-4.25 6.286c-.224.52-.52 1.21-.702 1.688L0 13.954c.14-.38.436-1.084.703-1.69l1.618.592zm10.2 3.967l1.518.548c.084.663.21 1.28.337 1.83l-1.688-.605c-.07-.422-.14-1.027-.168-1.772z"/></g></svg>
diff --git a/app/views/shared/icons/_service_templates.svg b/app/views/shared/icons/_service_templates.svg
new file mode 100644
index 00000000000..b65cd8300b2
--- /dev/null
+++ b/app/views/shared/icons/_service_templates.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm.8 2h2.4a.8.8 0 0 1 .8.8v1.4a.8.8 0 0 1-.8.8H3.8a.8.8 0 0 1-.8-.8V4.8a.8.8 0 0 1 .8-.8zm4.7 0h4a.5.5 0 1 1 0 1h-4a.5.5 0 0 1 0-1zm0 2h4a.5.5 0 1 1 0 1h-4a.5.5 0 0 1 0-1zm-5 3h9a.5.5 0 1 1 0 1h-9a.5.5 0 0 1 0-1zm0 2h9a.5.5 0 1 1 0 1h-9a.5.5 0 1 1 0-1z"/></svg>
diff --git a/app/views/shared/icons/_settings.svg b/app/views/shared/icons/_settings.svg
new file mode 100644
index 00000000000..96c5ef8c04d
--- /dev/null
+++ b/app/views/shared/icons/_settings.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2.415 5.803L1.317 4.084A.5.5 0 0 1 1.35 3.5l.805-.994a.5.5 0 0 1 .564-.153l1.878.704a5.975 5.975 0 0 1 1.65-.797L6.885.342A.5.5 0 0 1 7.36 0h1.28a.5.5 0 0 1 .474.342l.639 1.918c.594.181 1.15.452 1.65.797l1.877-.704a.5.5 0 0 1 .565.153l.805.994a.5.5 0 0 1 .032.584l-1.097 1.719c.217.551.354 1.143.399 1.76l1.731 1.058a.5.5 0 0 1 .227.54l-.288 1.246a.5.5 0 0 1-.44.385l-2.008.19a6.026 6.026 0 0 1-1.142 1.431l.265 1.995a.5.5 0 0 1-.277.516l-1.15.56a.5.5 0 0 1-.576-.1l-1.424-1.452a6.047 6.047 0 0 1-1.804 0l-1.425 1.453a.5.5 0 0 1-.576.1l-1.15-.561a.5.5 0 0 1-.276-.516l.265-1.995a6.026 6.026 0 0 1-1.143-1.43l-2.008-.191a.5.5 0 0 1-.44-.385L.058 9.16a.5.5 0 0 1 .226-.539l1.732-1.058a5.968 5.968 0 0 1 .399-1.76zM8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/></svg>
diff --git a/app/views/shared/icons/_snippets.svg b/app/views/shared/icons/_snippets.svg
new file mode 100644
index 00000000000..1e1340187b4
--- /dev/null
+++ b/app/views/shared/icons/_snippets.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.67 9.31a3.001 3.001 0 0 1 2.062 5.546 3 3 0 0 1-3.771-4.559 1.007 1.007 0 0 1-.095-.137l-4.5-7.794a1 1 0 1 1 1.732-1l4.5 7.794c.028.05.052.1.071.15zm-3.283.35l-.289.5c-.028.05-.06.095-.095.137a3.001 3.001 0 0 1-3.77 4.56A3 3 0 0 1 5.294 9.31c.02-.051.043-.102.071-.15l.866-1.5 1.155 2zm2.31-4l-1.156-2 1.325-2.294a1 1 0 1 1 1.732 1L9.696 5.66zm-5.465 7.464a1 1 0 1 0 1-1.732 1 1 0 0 0-1 1.732zm7.5 0a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732z"/></svg>
diff --git a/app/views/shared/icons/_spam_logs.svg b/app/views/shared/icons/_spam_logs.svg
new file mode 100644
index 00000000000..80ee0eb3856
--- /dev/null
+++ b/app/views/shared/icons/_spam_logs.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.75.433l5.428 3.134a1.5 1.5 0 0 1 .75 1.299v6.268a1.5 1.5 0 0 1-.75 1.299L8.75 15.567a1.5 1.5 0 0 1-1.5 0l-5.428-3.134a1.5 1.5 0 0 1-.75-1.299V4.866a1.5 1.5 0 0 1 .75-1.299L7.25.433a1.5 1.5 0 0 1 1.5 0zM3.072 5.155v5.69L8 13.691l4.928-2.846v-5.69L8 2.309 3.072 5.155zM8 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_spring.svg b/app/views/shared/icons/_spring.svg
new file mode 100644
index 00000000000..ccf18749029
--- /dev/null
+++ b/app/views/shared/icons/_spring.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" class="btn-template-icon icon-java-spring"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"/><path fill="#70AD51" d="M5.466 27.993c.586.473 1.446.385 1.918-.202.475-.585.386-1.445-.2-1.92-.585-.474-1.444-.383-1.92.202-.45.555-.392 1.356.115 1.844l-.266-.234C1.972 24.762 0 20.597 0 15.978 0 7.168 7.168 0 15.98 0c4.48 0 8.53 1.857 11.435 4.836.66-.898 1.232-1.902 1.7-3.015 2.036 6.118 3.233 11.26 2.795 15.31-.592 8.274-7.508 14.83-15.93 14.83-3.912 0-7.496-1.416-10.276-3.757l-.238-.21zm23.58-4.982c4.01-5.336 1.775-13.965-.085-19.48-1.657 3.453-5.738 6.094-9.262 6.93-3.303.788-6.226.142-9.283 1.318-6.97 2.68-6.86 10.992-3.02 12.86.002 0 .23.124.227.12 0-.002 5.644-1.122 8.764-2.274 4.56-1.684 9.566-5.835 11.213-10.657-.877 5.015-5.182 9.84-9.507 12.056-2.302 1.182-4.092 1.445-7.88 2.756-.464.158-.828.314-.828.314.96-.16 1.917-.212 1.917-.212 5.393-.255 13.807 1.516 17.745-3.73z"/></g></svg>
diff --git a/app/views/shared/icons/_system_hooks.svg b/app/views/shared/icons/_system_hooks.svg
new file mode 100644
index 00000000000..7b95a6f29f3
--- /dev/null
+++ b/app/views/shared/icons/_system_hooks.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M10 3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1h4zm0 1H6v1a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V4zM7 8a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3v4a2 2 0 1 0 4 0h-.44a.3.3 0 0 1-.25-.466l1.44-2.16a.3.3 0 0 1 .5 0l1.44 2.16a.3.3 0 0 1-.25.466H15a4 4 0 0 1-7 2.646A4 4 0 0 1 1 12H.56a.3.3 0 0 1-.25-.466l1.44-2.16a.3.3 0 0 1 .5 0l1.44 2.16a.3.3 0 0 1-.25.466H3a2 2 0 1 0 4 0V8z"/></svg>
diff --git a/app/views/shared/icons/_todo_done.svg b/app/views/shared/icons/_todo_done.svg
new file mode 100644
index 00000000000..156dfa11df1
--- /dev/null
+++ b/app/views/shared/icons/_todo_done.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.243 7.485l4.95-4.95a1 1 0 1 1 1.414 1.415L8.95 9.607a.997.997 0 0 1-1.414 0l-2.83-2.83a1 1 0 0 1 1.415-1.413l2.123 2.12zM12 11a1 1 0 0 1 2 0v2a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h2a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-2z"/></svg>
diff --git a/app/views/shared/icons/_wiki.svg b/app/views/shared/icons/_wiki.svg
new file mode 100644
index 00000000000..b5ad38d9863
--- /dev/null
+++ b/app/views/shared/icons/_wiki.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1v4.191a.5.5 0 0 1-.724.447l-1.052-.526a.5.5 0 0 0-.448 0l-1.052.526A.5.5 0 0 1 8 6.191V2zM4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/></svg>
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index 217af7c9fac..fc86f855865 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -1,14 +1,10 @@
-- max_render = 3
-- max = [max_render, issue.assignees.length].min
+- max_render = 4
+- assignees_rendering_overflow = issue.assignees.size > max_render
+- render_count = assignees_rendering_overflow ? max_render - 1 : max_render
+- more_assignees_count = issue.assignees.size - render_count
-- issue.assignees.take(max).each do |assignee|
+- issue.assignees.take(render_count).each do |assignee|
= link_to_member(@project, assignee, name: false, title: "Assigned to :name")
-- if issue.assignees.length > max_render
- - counter = issue.assignees.length - max_render
-
- %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } }
- - if counter < 99
- = "+#{counter}"
- - else
- 99+
+- if more_assignees_count.positive?
+ %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees" } } +#{more_assignees_count}
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index 7cfdfb6e6ee..0d507cc7a6e 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -1,9 +1,9 @@
- 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" }
+%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
= form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
- .block
+ .block.issuable-sidebar-header
.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"
@@ -31,7 +31,7 @@
.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" } })
+ = 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: project_milestones_path(@project, :json), use_id: true, default_label: "Milestone" } })
.block
.title
Labels
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
new file mode 100644
index 00000000000..9ef015047c9
--- /dev/null
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -0,0 +1,14 @@
+- is_current_user = issuable_author_is_current_user(issuable)
+- display_issuable_type = issuable_display_type(issuable)
+- button_method = issuable_close_reopen_button_method(issuable)
+
+- if can_update && is_current_user
+ = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method,
+ class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
+ = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method,
+ class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
+- elsif can_update && !is_current_user
+ = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
+- else
+ = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ class: 'hidden-xs hidden-sm btn btn-grouped btn-close-color', title: 'Report abuse'
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
new file mode 100644
index 00000000000..39a5171c1d6
--- /dev/null
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -0,0 +1,49 @@
+- display_issuable_type = issuable_display_type(issuable)
+- button_action = issuable.closed? ? 'reopen' : 'close'
+- display_button_action = button_action.capitalize
+- button_responsive_class = 'hidden-xs hidden-sm'
+- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
+- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
+- button_method = issuable_close_reopen_button_method(issuable)
+
+.pull-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
+ = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable),
+ method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}"
+
+ = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
+ data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do
+ = icon('caret-down', class: 'toggle-icon icon')
+
+ %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ class: button_responsive_class, data: { dropdown: true } }
+ %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}",
+ data: { text: "Close #{display_issuable_type}", url: close_issuable_path(issuable),
+ button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong.title
+ Close
+ = display_issuable_type
+
+ %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
+ data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_path(issuable),
+ button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong.title
+ Reopen
+ = display_issuable_type
+
+ %li.divider.droplab-item-ignore
+
+ %li.report-item{ data: { text: 'Report abuse', url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong.title Report abuse
+ %p.text
+ Report
+ = display_issuable_type.pluralize
+ that are abusive, inappropriate or spam.
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 2cabbc8c560..7704c88905b 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -1,4 +1,3 @@
-- finder = controller.controller_name == 'issues' ? issues_finder : merge_requests_finder
- boards_page = controller.controller_name == 'boards'
.issues-filters
@@ -11,19 +10,19 @@
- if params[:author_id].present?
= hidden_field_tag(:author_id, params[:author_id])
= dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit",
- placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user.try(:username), current_user: true, project_id: @project.try(:id), selected: params[:author_id], field_name: "author_id", default_label: "Author" } })
+ placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } })
.filter-item.inline
- if params[:assignee_id].present?
= hidden_field_tag(:assignee_id, params[:assignee_id])
= dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
- placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
+ placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
.filter-item.inline.milestone-filter
= render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true
.filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
+ = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
- if issuable_filter_present?
.filter-item.inline.reset-filters
@@ -36,13 +35,3 @@
.row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
- if has_labels
= render 'shared/labels_row', labels: @labels
-
-:javascript
- new LabelsSelect();
- new MilestoneSelect();
- new IssueStatusSelect();
- new SubscriptionSelect();
- $('form.filter-form').on('submit', function (event) {
- event.preventDefault();
- gl.utils.visitUrl(this.action + '&' + $(this).serialize());
- });
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index c016aa2abcd..4c8f03f1498 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -29,22 +29,12 @@
= render 'shared/issuable/form/metadata', issuable: issuable, form: form
-- if issuable.can_move?(current_user)
- %hr
- .form-group
- = label_tag :move_to_project_id, 'Move', class: 'control-label'
- .col-sm-10
- .issuable-form-select-holder
- = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE }
- &nbsp;
- %span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default',
- 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.' }
- = icon('question-circle')
-
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
= render 'shared/issuable/form/merge_params', issuable: issuable
+= render 'shared/issuable/form/contribution', issuable: issuable, form: form
+
- if @merge_request_to_resolve_discussions_of
.form-group
.col-sm-10.col-sm-offset-2
@@ -77,11 +67,11 @@
%span.append-right-10
- if issuable.new_record?
- = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'btn btn-create'
+ = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'btn btn-create qa-issuable-create-button'
- else
= form.submit 'Save changes', class: 'btn btn-save'
- - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = contribution_guide_path(issuable.project))
+ - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path)
.inline.prepend-top-10
Please review the
%strong= link_to('contribution guidelines', guide_url)
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index bd66f39fa59..91aa329eb93 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -1,17 +1,18 @@
+- subject = @project || @group
.dropdown-page-two.dropdown-new-label
- = dropdown_title("Create new label", back: true)
+ = dropdown_title(create_label_title(subject), options: { back: true })
= dropdown_content do
.dropdown-labels-error.js-label-error
- %input#new_label_name.default-dropdown-input{ type: "text", placeholder: "Name new label" }
+ %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
.suggest-colors.suggest-colors-dropdown
- suggested_colors.each do |color|
= link_to '#', style: "background-color: #{color}", data: { color: color } do
&nbsp
.dropdown-label-color-input
.dropdown-label-color-preview.js-dropdown-label-color-preview
- %input#new_label_color.default-dropdown-input{ type: "text", placeholder: "Assign custom color like #FF0000" }
+ %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') }
.clearfix
%button.btn.btn-primary.pull-left.js-new-label-btn{ type: "button" }
- Create
+ = _('Create')
%button.btn.btn-default.pull-right.js-cancel-label-btn{ type: "button" }
- Cancel
+ = _('Cancel')
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index 9a8529c6cbb..2bd922bca2b 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -1,28 +1,28 @@
-- title = local_assigns.fetch(:title, 'Assign labels')
+- title = local_assigns.fetch(:title, _('Assign labels'))
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
+- subject = @project || @group
.dropdown-page-one
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
%p
- Create lists from the labels you use in your project. Issues with that
- label will automatically be added to the list.
+ = _('Create lists from labels. Issues with that label appear in that list.')
= dropdown_filter(filter_placeholder)
= dropdown_content
- - if @project && show_footer
+ - if current_board_parent && show_footer
= dropdown_footer do
%ul.dropdown-footer-list
- - if can?(current_user, :admin_label, @project)
+ - if can?(current_user, :admin_label, current_board_parent)
%li
%a.dropdown-toggle-page{ href: "#" }
- Create new label
+ = create_label_title(subject)
%li
- = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
- - if show_create && @project && can?(current_user, :admin_label, @project)
- Manage labels
+ = link_to labels_path, :"data-is-link" => true do
+ - if show_create && can?(current_user, :admin_label, current_board_parent)
+ = manage_labels_title(subject)
- else
- View labels
+ = view_labels_title(subject)
= dropdown_loading
diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml
index 6750921338a..955b8866c2c 100644
--- a/app/views/shared/issuable/_milestone_dropdown.html.haml
+++ b/app/views/shared/issuable/_milestone_dropdown.html.haml
@@ -11,10 +11,10 @@
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
%li
- = link_to new_namespace_project_milestone_path(project.namespace, project), title: "New Milestone" do
+ = link_to new_project_milestone_path(project), title: "New Milestone" do
Create new
%li
- = link_to namespace_project_milestones_path(project.namespace, project) do
+ = link_to project_milestones_path(project) do
- if can? current_user, :admin_milestone, project
Manage milestones
- else
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 3f03cc7a275..4d8109eb90c 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -1,8 +1,7 @@
- type = local_assigns.fetch(:type, :issues)
- page_context_word = type.to_s.humanize(capitalize: false)
-- issuables = @issues || @merge_requests
-%ul.nav-links.issues-state-filters
+%ul.nav-links.issues-state-filters.mobile-separator
%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.", data: { state: 'opened' } do
#{issuables_state_counter_text(type, :opened)}
@@ -20,6 +19,4 @@
= link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do
#{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}.", data: { state: 'all' } do
- #{issuables_state_counter_text(type, :all)}
+ = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all)
diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml
deleted file mode 100644
index db407363a09..00000000000
--- a/app/views/shared/issuable/_participants.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-- participants_row = 7
-- participants_size = participants.size
-- participants_extra = participants_size - participants_row
-.block.participants
- .sidebar-collapsed-icon
- = icon('users')
- %span
- = participants.count
- .title.hide-collapsed
- = pluralize participants.count, "participant"
- .hide-collapsed.participants-list
- - participants.each do |participant|
- .participants-author.js-participants-author
- = link_to_member(@project, participant, name: false, size: 24)
- - if participants_extra > 0
- .hide-collapsed.participants-more
- %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
- + #{participants_extra} more
-:javascript
- IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row};
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index d3d290692a2..fc6f71ef60f 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -1,5 +1,6 @@
- type = local_assigns.fetch(:type)
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
+- full_path = @project.present? ? @project.full_path : @group.full_path
.issues-filters
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
@@ -18,13 +19,12 @@
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content",
title: "Recent searches" }) do
- .js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } }
- .filtered-search-box-input-container
+ .js-filtered-search-history-dropdown{ data: { full_path: full_path } }
+ .filtered-search-box-input-container.droplab-dropdown
.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')
+ %input.form-control.filtered-search{ search_filter_input_options(type) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
@@ -56,7 +56,7 @@
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
No Assignee
- %li.divider
+ %li.divider.droplab-item-ignore
- if current_user
= render 'shared/issuable/user_dropdown_item',
user: current_user
@@ -75,7 +75,7 @@
%li.filter-dropdown-item{ 'data-value' => 'started' }
%button.btn.btn-link
Started
- %li.divider
+ %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
@@ -85,35 +85,34 @@
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
No Label
- %li.divider
+ %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
+ #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link
+ %gl-emoji
+ %span.js-data-value.prepend-left-10
+ {{name}}
%button.clear-search.hidden{ type: 'button' }
= icon('times')
.filter-dropdown-container
- if type == :boards
- - if can?(current_user, :admin_list, @project)
+ - if can?(current_user, :admin_list, board.parent)
.dropdown.prepend-left-10#js-add-list
- %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } }
+ %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- - if can?(current_user, :admin_label, @project)
+ - if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
- #js-add-issues-btn.prepend-left-10
+ - if @project
+ #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
-
-- unless type === :boards_modal
- :javascript
- $(document).off('page:restore').on('page:restore', function (event) {
- if (gl.FilteredSearchManager) {
- const filteredSearchManager = new gl.FilteredSearchManager();
- filteredSearchManager.setup();
- }
- });
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 745f1ee62da..adaddda13eb 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,15 +1,12 @@
- todo = issuable_todo(issuable)
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('sidebar')
-%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix", signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, 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
- if current_user
%span.issuable-header-text.hide-collapsed.pull-left
- Todo
+ = _('Todo')
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
= sidebar_gutter_toggle_icon
- if current_user
@@ -24,31 +21,32 @@
.block.milestone
.sidebar-collapsed-icon
= icon('clock-o', 'aria-hidden': 'true')
- %span
+ %span.milestone-title
- if issuable.milestone
- %span.has-tooltip{ title: milestone_remaining_days(issuable.milestone), data: { container: 'body', html: 1, placement: 'left' } }
+ %span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } }
= issuable.milestone.title
- else
- None
+ = _('None')
.title.hide-collapsed
- Milestone
+ = _('Milestone')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if issuable.milestone
- = link_to issuable.milestone.title, namespace_project_milestone_path(@project.namespace, @project, issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 }
+ = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 }
- else
- %span.no-value None
+ %span.no-value
+ = _('None')
.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, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: 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: project_milestones_path(@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
.title.hide-collapsed
- Time tracking
+ = _('Time tracking')
= icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date)
.block.due_date
@@ -57,30 +55,32 @@
%span.js-due-date-sidebar-value
= issuable.due_date.try(:to_s, :medium) || 'None'
.title.hide-collapsed
- Due date
+ = _('Due date')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if issuable.due_date
%span.bold= issuable.due_date.to_s(:medium)
- else
- %span.no-value No due date
+ %span.no-value
+ = _('No due date')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
%span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) }
\-
%a.js-remove-due-date{ href: "#", role: "button" }
- remove due date
+ = _('remove due date')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.selectbox.hide-collapsed
= f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd')
.dropdown
%button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable) } }
- %span.dropdown-toggle-text Due date
+ %span.dropdown-toggle-text
+ = _('Due date')
= icon('chevron-down', 'aria-hidden': 'true')
.dropdown-menu.dropdown-menu-due-date
- = dropdown_title('Due date')
+ = dropdown_title(_('Due date'))
= dropdown_content do
.js-due-date-calendar
@@ -92,21 +92,22 @@
%span
= selected_labels.size
.title.hide-collapsed
- Labels
+ = _('Labels')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- selected_labels.each do |label|
= link_to_label(label, subject: issuable.project, type: issuable.to_ability_name)
- else
- %span.no-value None
+ %span.no-value
+ = _('None')
.selectbox.hide-collapsed
- selected_labels.each do |label|
= hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } }
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (project_labels_path(@project, :json) if @project) } }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
@@ -115,40 +116,48 @@
- if can? current_user, :admin_label, @project and @project
= render partial: "shared/issuable/label_page_create"
- = render "shared/issuable/participants", participants: issuable.participants(current_user)
+ - if issuable.has_attribute?(:confidential)
+ -# haml-lint:disable InlineJavaScript
+ %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe
+ #js-confidential-entry-point
+
+ - if issuable.has_attribute?(:discussion_locked)
+ -# haml-lint:disable InlineJavaScript
+ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe
+ #js-lock-entry-point
+
+ .js-sidebar-participants-entry-point
+
- if current_user
- - subscribed = issuable.subscribed?(current_user, @project)
- .block.light.subscription{ data: { url: toggle_subscription_path(issuable) } }
- .sidebar-collapsed-icon
- = icon('rss', 'aria-hidden': 'true')
- %span.issuable-header-text.hide-collapsed.pull-left
- Notifications
- - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed'
- %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
- %span= subscribed ? 'Unsubscribe' : 'Subscribe'
+ .js-sidebar-subscriptions-entry-point
- project_ref = cross_project_reference(@project, issuable)
.block.project-reference
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left")
.cross-project-reference.hide-collapsed
%span
- Reference:
+ = _('Reference:')
%cite{ title: project_ref }
= project_ref
- = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
-
- :javascript
- gl.sidebarOptions = {
- endpoint: "#{issuable_json_path(issuable)}?basic=true",
- editable: #{can_edit_issuable ? true : false},
- currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)},
- rootPath: "#{root_path}"
- };
+ = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left")
+ - if current_user && issuable.can_move?(current_user)
+ .block.js-sidebar-move-issue-block
+ .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: _('Move issue') }
+ = custom_icon('icon_arrow_right')
+ .dropdown.sidebar-move-issue-dropdown.hide-collapsed
+ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
+ data: { toggle: 'dropdown' } }
+ = _('Move issue')
+ .dropdown-menu.dropdown-menu-selectable
+ = dropdown_title(_('Move issue'))
+ = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
+ = dropdown_content
+ = dropdown_loading
+ = dropdown_footer add_content_class: true do
+ %button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
+ = _('Move')
+ = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
- new MilestoneSelect('{"full_path":"#{@project.full_path}"}');
- new LabelsSelect();
- new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
- gl.Subscription.bindAll('.subscription');
- new gl.DueDateSelectors();
- window.sidebar = new Sidebar();
+ -# haml-lint:disable InlineJavaScript
+ %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 2ea5eb960c0..304df38a096 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,7 +1,7 @@
- if issuable.is_a?(Issue)
#js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } }
.title.hide-collapsed
- Assignee
+ = _('Assignee')
= icon('spinner spin')
- else
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
@@ -10,46 +10,47 @@
- else
= icon('user', 'aria-hidden': 'true')
.title.hide-collapsed
- Assignee
+ = _('Assignee')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
- if !signed_in
- %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
+ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') }
= sidebar_gutter_toggle_icon
.value.hide-collapsed
- if issuable.assignee
= link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
- if !issuable.can_be_merged_by?(issuable.assignee)
- %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
+ %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') }
= icon('exclamation-triangle', 'aria-hidden': 'true')
%span.username
= issuable.assignee.to_reference
- else
%span.assign-yourself.no-value
- No assignee
+ = _('No assignee')
- if can_edit_issuable
\-
%a.js-assign-yourself{ href: '#' }
- assign yourself
+ = _('assign yourself')
.selectbox.hide-collapsed
- issuable.assignees.each do |assignee|
= 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 } }
-
- - title = 'Select assignee'
+ - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, 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 } }
+ - title = _('Select assignee')
- if issuable.is_a?(Issue)
- unless issuable.assignees.any?
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil
+ - dropdown_options = issue_assignees_dropdown_options
+ - title = dropdown_options[:title]
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" }
- data[:multi_select] = true
- data['dropdown-title'] = title
- - data['dropdown-header'] = 'Assignee'
- - data['max-select'] = 1
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select']
- options[:data].merge!(data)
= dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml
index 574e2958ae8..b77e104c072 100644
--- a/app/views/shared/issuable/_sidebar_todo.html.haml
+++ b/app/views/shared/issuable/_sidebar_todo.html.haml
@@ -1,11 +1,11 @@
- is_collapsed = local_assigns.fetch(:is_collapsed, false)
-- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : 'Mark done'
-- todo_content = is_collapsed ? icon('plus-square') : 'Add todo'
+- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done')
+- todo_content = is_collapsed ? icon('plus-square') : _('Add todo')
%button.issuable-todo-btn.js-issuable-todo{ type: 'button',
class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'),
- title: (todo.nil? ? 'Add todo' : 'Mark done'),
- 'aria-label' => (todo.nil? ? 'Add todo' : 'Mark done'),
+ title: (todo.nil? ? _('Add todo') : _('Mark done')),
+ 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')),
data: issuable_todo_button_data(issuable, todo, is_collapsed) }
%span.issuable-todo-inner.js-issuable-todo-inner<
- if todo
diff --git a/app/views/shared/issuable/_user_dropdown_item.html.haml b/app/views/shared/issuable/_user_dropdown_item.html.haml
index a82c01c6dc2..4a3547e9e70 100644
--- a/app/views/shared/issuable/_user_dropdown_item.html.haml
+++ b/app/views/shared/issuable/_user_dropdown_item.html.haml
@@ -3,7 +3,8 @@
%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)
+ .avatar-container.s40
+ = user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 40, has_tooltip: false)
.dropdown-user-details
%span
= user.name
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 203d2adc8db..9a589387255 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -15,11 +15,10 @@
= 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 },
+ = form.hidden_field(:target_branch,
{ class: 'target_branch js-target-branch-select ref-name',
disabled: issuable.new_record?,
- data: { placeholder: "Select branch" }})
+ data: { placeholder: "Select branch", endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }})
- if issuable.new_record?
&nbsp;
= link_to 'Change branches', mr_change_branches_path(issuable)
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
new file mode 100644
index 00000000000..de508278d7c
--- /dev/null
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -0,0 +1,20 @@
+- issuable = local_assigns.fetch(:issuable)
+- form = local_assigns.fetch(:form)
+
+- return unless issuable.is_a?(MergeRequest)
+- return unless issuable.for_fork?
+- return unless can?(current_user, :push_code, issuable.source_project)
+
+%hr
+
+.form-group
+ .control-label
+ = _('Contribution')
+ .col-sm-10
+ .checkbox
+ = form.label :allow_maintainer_to_push do
+ = form.check_box :allow_maintainer_to_push, disabled: !issuable.can_allow_maintainer_to_push?(current_user)
+ = _('Allow edits from maintainers.')
+ = link_to 'About this feature', help_page_path('user/project/merge_requests/maintainer_access')
+ .help-block
+ = allow_maintainer_push_unavailable_reason(issuable)
diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml
index 66091d95a91..9b2b6e572e7 100644
--- a/app/views/shared/issuable/form/_issue_assignee.html.haml
+++ b/app/views/shared/issuable/form/_issue_assignee.html.haml
@@ -11,7 +11,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if assignees.any?
- assignees.each do |assignee|
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index bfa91629e1e..8f6509a8ce8 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -11,8 +11,7 @@
.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
+ = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?
Remove source branch when merge request is accepted.
diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
index 18011d528a0..bf8613b0f0d 100644
--- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml
+++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
@@ -9,7 +9,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
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 77175c839a6..567cde764e2 100644
--- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
@@ -7,5 +7,5 @@
- if issuable.assignees.length === 0
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
- = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false))
+ = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_assignees_dropdown_options)
= link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 64826d41d60..e81639f35ea 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -6,7 +6,7 @@
%div{ class: div_class }
= form.text_field :title, required: true, maxlength: 255, autofocus: true,
- autocomplete: 'off', class: 'form-control pad'
+ autocomplete: 'off', class: 'form-control pad qa-issuable-form-title'
- if issuable.respond_to?(:work_in_progress?)
%p.help-block
diff --git a/app/views/shared/issuable/nav_links/_all.html.haml b/app/views/shared/issuable/nav_links/_all.html.haml
new file mode 100644
index 00000000000..d7ad7090a45
--- /dev/null
+++ b/app/views/shared/issuable/nav_links/_all.html.haml
@@ -0,0 +1,6 @@
+- page_context_word = local_assigns.fetch(:page_context_word)
+- counter = local_assigns.fetch(:counter)
+
+%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}.", data: { state: 'all' } do
+ #{counter}
diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml
index d97fdf179d7..40224cec9e8 100644
--- a/app/views/shared/members/_access_request_buttons.html.haml
+++ b/app/views/shared/members/_access_request_buttons.html.haml
@@ -1,18 +1,20 @@
- model_name = source.model_name.to_s.downcase
-.project-action-button.inline
- - if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id))
+- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id))
+ .project-action-button.inline
- 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)
+- elsif requester = source.requesters.find_by(user_id: current_user.id)
+ .project-action-button.inline
= 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)
+- elsif source.request_access_enabled && can?(current_user, :request_access, source)
+ .project-action-button.inline
= link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
method: :post,
class: 'btn'
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 1d5a61cffce..5868c52566d 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -4,7 +4,7 @@
- dom_id = "group_member_#{group_link.id}"
%li.member.group_member{ id: dom_id }
%span.list-item-name
- = image_tag group_icon(group), class: "avatar s40", alt: ''
+ = group_icon(group, class: "avatar s40", alt: '')
%strong
= link_to group.full_name, group_path(group)
.cgray
@@ -14,7 +14,7 @@
%span{ class: ('text-warning' if group_link.expires_soon?) }
Expires in #{distance_of_time_in_words_to_now(group_link.expires_at)}
.controls.member-controls
- = form_tag namespace_project_group_link_path(@project.namespace, @project, group_link), method: :put, remote: true, class: 'form-horizontal js-edit-member-form' do
+ = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'form-horizontal js-edit-member-form' do
= hidden_field_tag "group_link[group_access]", group_link.group_access
.member-form-control.dropdown.append-right-5
%button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
@@ -36,7 +36,7 @@
= text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: 'Expiration date', id: "member_expires_at_#{group.id}", disabled: !can_admin_member
%i.clear-icon.js-clear-input
- if can_admin_member
- = link_to namespace_project_group_link_path(@project.namespace, @project, group_link),
+ = link_to project_group_link_path(@project, group_link),
method: :delete,
data: { confirm: "Are you sure you want to remove #{group.name}?" },
class: 'btn btn-remove prepend-left-10' do
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index a5aa768b1b2..ba57d922c6d 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -1,58 +1,66 @@
- show_roles = local_assigns.fetch(:show_roles, true)
- show_controls = local_assigns.fetch(:show_controls, true)
+- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
+- member = local_assigns.fetch(:member)
- user = local_assigns.fetch(:user, member.user)
- source = member.source
-- can_admin_member = can?(current_user, action_member_permission(:update, member), member)
%li.member{ class: dom_class(member), id: dom_id(member) }
%span.list-item-name
- if user
- = image_tag avatar_icon(user, 40), class: "avatar s40", alt: ''
- %strong
- = link_to user.name, user_path(user)
- %span.cgray= user.to_reference
+ = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: ''
+ .user-info
+ = link_to user.name, user_path(user), class: 'member'
+ %span.cgray= user.to_reference
- - if user == current_user
- %span.label.label-success.prepend-left-5 It's you
+ - if user == current_user
+ %span.label.label-success.prepend-left-5 It's you
- - if user.blocked?
- %label.label.label-danger
- %strong Blocked
+ - if user.blocked?
+ %label.label.label-danger
+ %strong Blocked
- - if source.instance_of?(Group) && source != @group
- &middot;
- = link_to source.full_name, source, class: "member-group-link"
+ - if source.instance_of?(Group) && source != @group
+ &middot;
+ = link_to source.full_name, source, class: "member-group-link"
- .hidden-xs.cgray
- - if member.request?
- Requested
- = time_ago_with_tooltip(member.requested_at)
- - else
- Joined #{time_ago_with_tooltip(member.created_at)}
- - if member.expires?
- ·
- %span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) }
- Expires in #{distance_of_time_in_words_to_now(member.expires_at)}
+ .cgray
+ - if member.request?
+ Requested
+ = time_ago_with_tooltip(member.requested_at)
+ - else
+ Joined #{time_ago_with_tooltip(member.created_at)}
+ - if member.expires?
+ ·
+ %span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) }
+ Expires in #{distance_of_time_in_words_to_now(member.expires_at)}
- else
- = image_tag avatar_icon(member.invite_email, 40), class: "avatar s40", alt: ''
- %strong= member.invite_email
- .cgray
- Invited
- - if member.created_by
- by
- = link_to member.created_by.name, user_path(member.created_by)
- = time_ago_with_tooltip(member.created_at)
+ = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40", alt: ''
+ .user-info
+ .member= member.invite_email
+ .cgray
+ Invited
+ - if member.created_by
+ by
+ = link_to member.created_by.name, user_path(member.created_by)
+ = time_ago_with_tooltip(member.created_at)
- if show_roles
- current_resource = @project || @group
.controls.member-controls
- if show_controls && member.source == current_resource
- - if user != current_user && can_admin_member
+
+ - if member.can_resend_invite?
+ = link_to icon('paper-plane'), polymorphic_path([:resend_invite, member]),
+ method: :post,
+ class: 'btn btn-default prepend-left-10 hidden-xs',
+ title: 'Resend invite'
+
+ - if user != current_user && member.can_update?
= form_for member, remote: true, html: { class: 'form-horizontal js-edit-member-form' } do |f|
= f.hidden_field :access_level
.member-form-control.dropdown.append-right-5
%button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
- disabled: !can_admin_member,
data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]" } }
%span.dropdown-toggle-text
= member.human_access
@@ -61,29 +69,32 @@
= dropdown_title("Change permissions")
.dropdown-content
%ul
- - member.class.access_level_roles.each do |role, role_id|
+ - member.access_level_roles.each do |role, role_id|
%li
= link_to role, "javascript:void(0)",
class: ("is-active" if member.access_level == role_id),
data: { id: role_id, el_id: dom_id(member) }
.prepend-left-5.clearable-input.member-form-control
- = f.text_field :expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: 'Expiration date', id: "member_expires_at_#{member.id}", disabled: !can_admin_member, data: { el_id: dom_id(member) }
+ = f.text_field :expires_at,
+ class: 'form-control js-access-expiration-date js-member-update-control',
+ placeholder: 'Expiration date',
+ id: "member_expires_at_#{member.id}",
+ data: { el_id: dom_id(member) }
%i.clear-icon.js-clear-input
- else
%span.member-access-text= member.human_access
- - if member.invite? && can?(current_user, action_member_permission(:admin, member), member.source)
- = link_to 'Resend invite', polymorphic_path([:resend_invite, member]),
- method: :post,
- class: 'btn btn-default prepend-left-10'
-
- - elsif member.request? && can_admin_member
- = link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]),
+ - if member.can_approve?
+ = link_to polymorphic_path([:approve_access_request, member]),
method: :post,
class: 'btn btn-success prepend-left-10',
- title: 'Grant access'
+ title: 'Grant access' do
+ %span{ class: ('visible-xs-block' unless force_mobile_view) }
+ Grant access
+ - unless force_mobile_view
+ = icon('check inverse', class: 'hidden-xs')
- - if can?(current_user, action_member_permission(:destroy, member), member)
+ - if member.can_remove?
- if current_user == user
= link_to icon('sign-out', text: 'Leave'), polymorphic_path([:leave, member.source, :members]),
method: :delete,
@@ -91,13 +102,13 @@
class: 'btn btn-remove prepend-left-10'
- else
= link_to member,
- remote: true,
method: :delete,
data: { confirm: remove_member_message(member) },
class: 'btn btn-remove prepend-left-10',
title: remove_member_title(member) do
- %span.visible-xs-block
+ %span{ class: ('visible-xs-block' unless force_mobile_view) }
Delete
- = icon('trash', class: 'hidden-xs')
+ - unless force_mobile_view
+ = icon('trash', class: 'hidden-xs')
- else
%span.member-access-text= member.human_access
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 92f6e7428ae..1fbd6bcc4cb 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -1,8 +1,13 @@
-- if requesters.any?
- .panel.panel-default.prepend-top-default
- .panel-heading
- Users requesting access to
- %strong= membership_source.name
- %span.badge= requesters.size
- %ul.content-list
- = render partial: 'shared/members/member', collection: requesters, as: :member
+- membership_source = local_assigns.fetch(:membership_source)
+- requesters = local_assigns.fetch(:requesters)
+- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
+
+- return if requesters.empty?
+
+.panel.panel-default.prepend-top-default{ class: ('panel-mobile' if force_mobile_view ) }
+ .panel-heading
+ Users requesting access to
+ %strong= membership_source.name
+ %span.badge= requesters.size
+ %ul.content-list.members-list
+ = render partial: 'shared/members/member', collection: requesters, as: :member, locals: { force_mobile_view: force_mobile_view }
diff --git a/app/views/shared/members/update.js.haml b/app/views/shared/members/update.js.haml
new file mode 100644
index 00000000000..55050bd8a15
--- /dev/null
+++ b/app/views/shared/members/update.js.haml
@@ -0,0 +1,6 @@
+- member = local_assigns.fetch(:member)
+
+:plain
+ var $listItem = $('#{escape_javascript(render('shared/members/member', member: member))}');
+ $("##{dom_id(member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
+ gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(member)}"));
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index a7c67ac9980..eba64daaadc 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -1,32 +1,31 @@
-# @project is present when viewing Project's milestone
- project = @project || issuable.project
- namespace = @project_namespace || project.namespace.becomes(Namespace)
+- labels = issuable.labels
- assignees = issuable.assignees
-- issuable_type = issuable.class.table_name
- base_url_args = [namespace, project]
-- issuable_type_args = base_url_args + [issuable_type]
+- issuable_type_args = base_url_args + [issuable.class.table_name]
- issuable_url_args = base_url_args + [issuable]
-- can_update = can?(current_user, :"update_#{issuable.to_ability_name}", issuable)
-%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable_url_args) }
+%li.issuable-row
%span
- if show_project_name
%strong #{project.name} &middot;
- elsif show_full_project_name
- %strong #{project.name_with_namespace} &middot;
+ %strong #{project.full_name} &middot;
- if issuable.is_a?(Issue)
= confidential_icon(issuable)
= link_to issuable.title, issuable_url_args, title: issuable.title
.issuable-detail
- = link_to [project.namespace.becomes(Namespace), project, issuable] do
+ = link_to [namespace, project, issuable], class: 'issue-link' do
%span.issuable-number= issuable.to_reference
- - issuable.labels.each do |label|
+ - labels.each do |label|
= link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do
- render_colored_label(label)
%span.assignee-icon
- assignees.each do |assignee|
- = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
+ = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do
- - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '')
+ - image_tag(avatar_icon_for_user(assignee, 16), class: "avatar s16", alt: '')
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 680e1f3a4ea..5926867e2d7 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -1,10 +1,15 @@
- dashboard = local_assigns[:dashboard]
-- custom_dom_id = dom_id(@project ? milestone : milestone.milestones.first)
+- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone)
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row
.col-sm-6
%strong= link_to truncate(milestone.title, length: 100), milestone_path
+ - if milestone.group_milestone?
+ %span - Group Milestone
+ - else
+ %span - Project Milestone
+
.col-sm-6
.pull-right.light #{milestone.percent_complete(current_user)}% complete
.row
@@ -13,31 +18,58 @@
&middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone)
- - if milestone.is_a?(GlobalMilestone)
+ - if milestone.is_a?(GlobalMilestone) || milestone.group_milestone?
.row
.col-sm-6
- .expiration= render('shared/milestone_expired', milestone: milestone)
- .projects
- - milestone.milestones.each do |milestone|
- = link_to milestone_path(milestone) do
- %span.label.label-gray
- = dashboard ? milestone.project.name_with_namespace : milestone.project.name
+ - if milestone.legacy_group_milestone?
+ .expiration= render('shared/milestone_expired', milestone: milestone)
+ .projects
+ - milestone.milestones.each do |milestone|
+ = link_to milestone_path(milestone) do
+ %span.label.label-gray
+ = dashboard ? milestone.project.full_name : milestone.project.name
- if @group
- .col-sm-6
+ .col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group)
+ - if milestone.group_milestone?
+ = link_to edit_group_milestone_path(@group, milestone), class: "btn btn-xs btn-grouped" do
+ Edit
+ \
- if milestone.closed?
- = link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
+ = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
- else
- = link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-close"
+ = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-xs btn-grouped btn-close"
- if @project
.row
- .col-sm-6= render('shared/milestone_expired', milestone: milestone)
+ .col-sm-6
+ = render('shared/milestone_expired', milestone: milestone)
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestone, milestone.project) and milestone.active?
- = link_to edit_namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), class: "btn btn-xs btn-grouped" do
+ = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do
Edit
\
- = link_to 'Close Milestone', namespace_project_milestone_path(@project.namespace, @project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped"
- = link_to namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
- Delete
+
+ - if @project.group
+ %button.js-promote-project-milestone-button.btn.btn-xs.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'),
+ disabled: true,
+ type: 'button',
+ data: { url: promote_project_milestone_path(milestone.project, milestone),
+ milestone_title: milestone.title,
+ target: '#promote-milestone-modal',
+ container: 'body',
+ toggle: 'modal' } }
+ = _('Promote')
+
+ = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped"
+
+ %button.js-delete-milestone-button.btn.btn-xs.btn-grouped.btn-danger{ data: { toggle: 'modal',
+ target: '#delete-milestone-modal',
+ milestone_id: milestone.id,
+ milestone_title: markdown_field(milestone, :title),
+ milestone_url: project_milestone_path(milestone.project, milestone),
+ milestone_issue_count: milestone.issues.count,
+ milestone_merge_request_count: milestone.merge_requests.count },
+ disabled: true }
+ = _('Delete')
+ = icon('spin spinner', class: 'js-loading-icon hidden' )
diff --git a/app/views/shared/milestones/_participants_tab.html.haml b/app/views/shared/milestones/_participants_tab.html.haml
index 549d2e2f61e..fe83040c168 100644
--- a/app/views/shared/milestones/_participants_tab.html.haml
+++ b/app/views/shared/milestones/_participants_tab.html.haml
@@ -2,7 +2,7 @@
- users.each do |user|
%li
= link_to user, title: user.name, class: "darken" do
- = image_tag avatar_icon(user, 32), class: "avatar s32"
+ = image_tag avatar_icon_for_user(user, 32), class: "avatar s32"
%strong= truncate(user.name, length: 40)
- %br
- %small.cgray= user.username
+ %div
+ %small.cgray= user.username
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 9bb87640319..a942ebc328b 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -1,7 +1,7 @@
- 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' }
+%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
@@ -21,7 +21,7 @@
.title
Start date
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: 'edit-link pull-right'
+ = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value
%span.value-content
- if milestone.start_date
@@ -51,7 +51,7 @@
.title.hide-collapsed
Due date
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: 'edit-link pull-right'
+ = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if milestone.due_date
@@ -67,13 +67,13 @@
.block.issues
.sidebar-collapsed-icon
%strong
- = icon('hashtag', 'aria-hidden': 'true')
+ = custom_icon('issues')
%span= milestone.issues_visible_to_user(current_user).count
.title.hide-collapsed
Issues
%span.badge= milestone.issues_visible_to_user(current_user).count
- if project && can?(current_user, :create_issue, project)
- = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "pull-right", title: "New Issue" do
+ = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "pull-right", title: "New Issue" do
New issue
.value.hide-collapsed.bold
%span.milestone-stat
@@ -85,10 +85,17 @@
Closed:
= milestone.issues_visible_to_user(current_user).closed.count
+ .block
+ #issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate, time_spent: @milestone.total_issue_time_spent, human_time_estimate: @milestone.human_total_issue_time_estimate, human_time_spent: @milestone.human_total_issue_time_spent } }
+ // Fallback while content is loading
+ .title.hide-collapsed
+ = _('Time tracking')
+ = icon('spinner spin')
+
.block.merge-requests
.sidebar-collapsed-icon
%strong
- = icon('exclamation', 'aria-hidden': 'true')
+ = custom_icon('mr_bold')
%span= milestone.merge_requests.count
.title.hide-collapsed
Merge requests
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 4de8a6cb15f..b95a4ea674d 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,8 +1,10 @@
+- issues_accessible = milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%ul.nav-links.scrolling-tabs.js-milestone-tabs
- - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
+ - if issues_accessible
%li.active
= link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do
Issues
@@ -25,13 +27,14 @@
Labels
%span.badge= milestone.labels.count
+- issues = milestone.sorted_issues(current_user)
- show_project_name = local_assigns.fetch(:show_project_name, false)
- show_full_project_name = local_assigns.fetch(:show_full_project_name, false)
.tab-content.milestone-content
- - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
- .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } }
- = render 'shared/milestones/issues_tab', issues: milestone.sorted_issues(current_user), show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ - if issues_accessible
+ .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } }
+ = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name
.tab-pane#tab-merge-requests
-# loaded async
= render "shared/milestones/tab_loading"
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 2562f085338..6006ab8b43f 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -22,39 +22,55 @@
- if group
.pull-right
- if can?(current_user, :admin_milestones, group)
+ - if milestone.group_milestone?
+ = link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do
+ Edit
- if milestone.active?
- = link_to 'Close Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
+ = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
- else
- = link_to 'Reopen Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
+ = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
.detail-page-description.milestone-detail
%h2.title
= markdown_field(milestone, :title)
+ - if @milestone.group_milestone? && @milestone.description.present?
+ %div
+ .description
+ .wiki
+ = markdown_field(@milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg}
-.table-holder
- %table.table
- %thead
- %tr
- %th Project
- %th Open issues
- %th State
- %th Due date
- - milestone.milestones.each do |ms|
- %tr
- %td
- - project_name = group ? ms.project.name : ms.project.name_with_namespace
- = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms)
- %td
- = ms.issues_visible_to_user(current_user).opened.count
- %td
- - if ms.closed?
- Closed
- - else
- Open
- %td
- = ms.expires_at
+- if @milestone.legacy_group_milestone? || @milestone.dashboard_milestone?
+ .table-holder
+ %table.table
+ %thead
+ %tr
+ %th Project
+ %th Open issues
+ %th State
+ %th Due date
+ - milestone.milestones.each do |ms|
+ %tr
+ %td
+ - project_name = group ? ms.project.name : ms.project.full_name
+ = link_to project_name, project_milestone_path(ms.project, ms)
+ %td
+ = ms.issues_visible_to_user(current_user).opened.count
+ %td
+ - if ms.closed?
+ Closed
+ - else
+ Open
+ %td
+ = ms.expires_at
+- elsif @milestone.group_milestone?
+ %br
+ View
+ = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title)
+ or
+ = link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title)
+ in this milestone
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 29cf5825292..4b9af78bc1a 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,16 +1,16 @@
- noteable_name = @note.noteable.human_class_name
-.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown
+.pull-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
%input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' }
- if @note.can_be_discussion_note?
= button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do
= icon('caret-down', class: 'toggle-icon')
- %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } }
+ %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } }
%li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } }
- %a{ href: '#' }
- = icon('check')
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
.description
%strong Comment
%p
@@ -19,8 +19,8 @@
%li.divider.droplab-item-ignore
%li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
- %a{ href: '#' }
- = icon('check')
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
.description
%strong Start discussion
%p
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index c6b5dcc3647..71c0d740bc8 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -10,6 +10,7 @@
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
= hidden_field_tag :in_reply_to_discussion_id
+ = hidden_field_tag :note_project_id
= note_target_fields(@note)
= f.hidden_field :noteable_type
@@ -23,20 +24,21 @@
-# DiffNote
= f.hidden_field :position
- = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
- = render 'projects/zen', f: f,
- attr: :note,
- classes: 'note-textarea js-note-text',
- placeholder: "Write a comment or drag your files here...",
- supports_quick_actions: supports_quick_actions,
- supports_autocomplete: supports_autocomplete
- = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
- .error-alert
+ .discussion-form-container
+ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
+ = render 'projects/zen', f: f,
+ attr: :note,
+ classes: 'note-textarea js-note-text',
+ placeholder: "Write a comment or drag your files here...",
+ supports_quick_actions: supports_quick_actions,
+ supports_autocomplete: supports_autocomplete
+ = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
+ .error-alert
- .note-form-actions.clearfix
- = render partial: 'shared/notes/comment_button'
+ .note-form-actions.clearfix
+ = render partial: 'shared/notes/comment_button'
- = yield(:note_actions)
+ = yield(:note_actions)
- %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } }
- Discard draft
+ %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } }
+ Discard draft
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 1e34b7c1e76..bf359774ead 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -1,7 +1,10 @@
- return unless note.author
- return if note.cross_reference_not_visible_for?(current_user)
+- show_image_comment_badge = local_assigns.fetch(:show_image_comment_badge, false)
- note_editable = note_editable?(note)
+- note_counter = local_assigns.fetch(:note_counter, 0)
+
%li.timeline-entry{ id: dom_id(note),
class: ["note", "note-row-#{note.id}", ('system-note' if note.system)],
data: { author_id: note.author.id,
@@ -12,14 +15,23 @@
- if note.system
= icon_for_system_note(note)
- else
- %a{ href: user_path(note.author) }
- = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
+ %a.image-diff-avatar-link{ href: user_path(note.author) }
+ = image_tag avatar_icon_for_user(note.author), alt: '', class: 'avatar s40'
+ - if note.is_a?(DiffNote) && note.on_image?
+ - if show_image_comment_badge && note_counter == 0
+ -# Only show this for the first comment in the discussion
+ %span.image-comment-badge
+ = sprite_icon('image-comment-dark')
+ - elsif note_counter == 0
+ - counter = badge_counter if local_assigns[:badge_counter]
+ - badge_class = "hidden" if @fresh_discussion || counter.nil?
+ %span.badge{ class: badge_class }
+ = counter
.timeline-content
.note-header
.note-header-info
%a{ href: user_path(note.author) }
- %span.note-header-author-name
- = sanitize(note.author.name)
+ %span.note-header-author-name= sanitize(note.author.name)
%span.note-headline-light
= note.author.to_reference
%span.note-headline-light
@@ -28,7 +40,7 @@
commented
- if note.system
%span.system-note-message
- = note.redacted_note_html
+ = markdown_field(note, :note)
%a{ href: "##{dom_id(note)}" }
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- unless note.system?
@@ -39,7 +51,7 @@
= render 'projects/notes/actions', note: note, note_editable: note_editable
.note-body{ class: note_editable ? 'js-task-list-container' : '' }
.note-text.md
- = note.redacted_note_html
+ = markdown_field(note, :note)
= edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
.original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
#{note.note}
@@ -60,6 +72,6 @@
= link_to note.attachment.url, target: '_blank' do
= icon('paperclip')
= note.attachment_identifier
- = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note),
+ = link_to delete_attachment_project_note_path(note.project, note),
title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
= icon('trash-o', class: 'cred')
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index f0fcc414756..1db7c4e67cf 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -1,26 +1,39 @@
-%ul#notes-list.notes.main-notes-list.timeline
- = render "shared/notes/notes"
+- issuable = @issue || @merge_request
+- discussion_locked = issuable&.discussion_locked?
+
+- unless has_vue_discussions_cookie?
+ %ul#notes-list.notes.main-notes-list.timeline
+ = render "shared/notes/notes"
= render 'shared/notes/edit_form', project: @project
- if can_create_note?
- %ul.notes.notes-form.timeline
+ %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
%li.timeline-entry
.timeline-entry-inner
.flash-container.timeline-content
.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'
+ = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40'
.timeline-content.timeline-content-form
= render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete
- elsif !current_user
.disabled-comment.text-center.prepend-top-default
Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link'
or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
to comment
-
-:javascript
- var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", #{autocomplete})
+- elsif discussion_locked
+ .disabled-comment.text-center.prepend-top-default
+ %span.issuable-note-warning
+ = sprite_icon('lock', size: 16, css_class: 'icon')
+ %span
+ This
+ = issuable.class.to_s.titleize.downcase
+ is locked. Only
+ %b project members
+ can comment.
+-# haml-lint:disable InlineJavaScript
+%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml
new file mode 100644
index 00000000000..fc643c3ecc2
--- /dev/null
+++ b/app/views/shared/plugins/_index.html.haml
@@ -0,0 +1,23 @@
+- plugins = Gitlab::Plugin.files
+
+.row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ Plugins
+ %p
+ #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to
+ system hooks but are executed as files instead of sending data to a URL.
+
+ .col-lg-8.append-bottom-default
+ - if plugins.any?
+ .panel.panel-default
+ .panel-heading
+ Plugins (#{plugins.count})
+ %ul.content-list
+ - plugins.each do |file|
+ %li
+ .monospace
+ = File.basename(file)
+ - else
+ %p.light-well.text-center
+ No plugins found.
diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 8939aeb6c3a..3d917346f6b 100644
--- a/app/views/shared/projects/_dropdown.html.haml
+++ b/app/views/shared/projects/_dropdown.html.haml
@@ -1,5 +1,5 @@
- @sort ||= sort_value_latest_activity
-.dropdown
+.dropdown.js-project-filter-dropdown-wrap
- toggle_text = projects_sort_options_hash[@sort]
= dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
@@ -15,8 +15,11 @@
= link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
Hide archived projects
%li
- = link_to filter_projects_path(archived: true), class: ("is-active" if params[:archived].present?) do
+ = link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
Show archived projects
+ %li
+ = link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ Show archived projects only
- if current_user
%li.divider
%li
diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml
new file mode 100644
index 00000000000..ec9dc8f62c2
--- /dev/null
+++ b/app/views/shared/projects/_edit_information.html.haml
@@ -0,0 +1,6 @@
+- unless can?(current_user, :push_code, @project)
+ .inline.prepend-left-10
+ - if @project.branch_allows_maintainer_push?(current_user, selected_branch)
+ = commit_in_single_accessible_branch
+ - else
+ = commit_in_fork_help
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index aaffc0927eb..e1da05d8f08 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -5,24 +5,26 @@
- forks = false unless local_assigns[:forks] == true
- ci = false unless local_assigns[:ci] == true
- skip_namespace = false unless local_assigns[:skip_namespace] == true
+- user = local_assigns[:user]
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true
- remote = false unless local_assigns[:remote] == true
-- load_pipeline_status(projects)
.js-projects-list-holder
- - if projects.any?
+ - if any_projects?(projects)
+ - load_pipeline_status(projects)
+
%ul.projects-list
- projects.each_with_index do |project, i|
- - css_class = (i >= projects_limit) ? 'hide' : nil
+ - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar,
- forks: forks, show_last_commit_as_description: show_last_commit_as_description
+ forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user
- if @private_forks_count && @private_forks_count > 0
%li.project-row.private-forks-notice
= icon('lock fw', base: 'circle', class: 'fa-lg private-fork-icon')
%strong= pluralize(@private_forks_count, 'private fork')
- %span you have no access to.
- = paginate(projects, remote: remote, theme: "gitlab") if projects.respond_to? :total_pages
+ %span &nbsp;you have no access to.
+ = paginate_collection(projects, remote: remote)
- else
.nothing-here-block No projects found
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index fbc335f6176..0687f6d961d 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -3,36 +3,42 @@
- forks = false unless local_assigns[:forks] == true
- ci = false unless local_assigns[:ci] == true
- skip_namespace = false unless local_assigns[:skip_namespace] == true
+- user = local_assigns[:user]
+- access = user&.max_member_access_for_project(project.id) unless user.nil?
- css_class = '' unless local_assigns[:css_class]
-- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
+- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- 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.last_activity_at)
+- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
%li.project-row{ class: css_class }
= cache(cache_key) do
- if avatar
.avatar-container.s40
= link_to project_path(project), class: dom_class(project) do
- - if use_creator_avatar
- = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:''
+ - if project.creator && use_creator_avatar
+ = image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
.project-details
%h3.prepend-top-0.append-bottom-0
- = link_to project_path(project), class: dom_class(project) do
- %span.project-full-name
+ = link_to project_path(project), class: 'text-plain' do
+ %span.project-full-name><
%span.namespace-name
- if project.namespace && !skip_namespace
= project.namespace.human_name
\/
- %span.project-name
+ %span.project-name<
= project.name
+ - if access&.nonzero?
+ -# haml-lint:disable UnnecessaryStringOutput
+ = ' ' # prevent haml from eating the space between elements
+ %span.user-access-role= Gitlab::Access.human_access(access)
+
- if show_last_commit_as_description
.description.prepend-top-5
- = link_to_gfm project.commit.title, namespace_project_commit_path(project.namespace, project, project.commit),
- class: "commit-row-message"
+ = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- elsif project.description.present?
.description.prepend-top-5
= markdown_field(project, :description)
@@ -41,12 +47,12 @@
.prepend-top-0
- if project.archived
%span.prepend-left-10.label.label-warning archived
- - if project.pipeline_status.has_status?
+ - if can?(current_user, :read_cross_project) && project.pipeline_status.has_status?
%span.prepend-left-10
= render_project_pipeline_status(project.pipeline_status)
- if forks
%span.prepend-left-10
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
= number_with_delimiter(project.forks_count)
- if stars
%span.prepend-left-10
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 8549cb91b03..c75c882a693 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,6 +1,5 @@
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
- = 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 common-note-form" } do |f|
@@ -36,6 +35,6 @@
= f.submit 'Save changes', class: "btn-save btn"
- if @snippet.project_id
- = link_to "Cancel", namespace_project_snippets_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
- else
= link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 17b34c5eeb3..12df79a28c7 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -1,16 +1,15 @@
-.detail-page-header.clearfix
- .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
- %span.sr-only
- = visibility_level_label(@snippet.visibility_level)
- = visibility_level_icon(@snippet.visibility_level, fw: false)
- %strong.item-title
- Snippet #{@snippet.to_reference}
- %span.creator
- authored
- = time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago')
- by #{link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title", avatar_class: "hidden-xs")}
+.detail-page-header
+ .detail-page-header-body
+ .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
+ %span.sr-only
+ = visibility_level_label(@snippet.visibility_level)
+ = visibility_level_icon(@snippet.visibility_level, fw: false)
+ %span.creator
+ Authored
+ = time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago')
+ by #{link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title", avatar_class: "hidden-xs")}
- .snippet-actions
+ .detail-page-header-actions
- if @snippet.project_id?
= render "projects/snippets/actions"
- else
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 5d2d2317f22..3acec88c2e3 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,7 +1,7 @@
- link_project = local_assigns.fetch(:link_project, false)
%li.snippet-row
- = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: ''
+ = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 hidden-xs", alt: ''
.title
= link_to reliable_snippet_path(snippet) do
@@ -30,8 +30,8 @@
- if link_project && snippet.project_id?
%span.hidden-xs
in
- = link_to namespace_project_path(snippet.project.namespace, snippet.project) do
- = snippet.project.name_with_namespace
+ = link_to project_path(snippet.project) do
+ = snippet.project.full_name
.pull-right.snippet-updated-at
%span updated #{time_ago_with_tooltip(snippet.updated_at, placement: 'bottom')}
diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml
index 8bbaf431536..ae437dd16d6 100644
--- a/app/views/shared/tokens/_scopes_form.html.haml
+++ b/app/views/shared/tokens/_scopes_form.html.haml
@@ -7,3 +7,4 @@
= check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}"
= label_tag ("#{prefix}_scopes_#{scope}"), scope
%span= t(scope, scope: [:doorkeeper, :scopes])
+ .scope-description= t scope, scope: [:doorkeeper, :scope_desc]
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 1f0e7629fb4..ad4d39b4aa1 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -50,7 +50,7 @@
= form.check_box :merge_requests_events, class: 'pull-left'
.prepend-left-20
= form.label :merge_requests_events, class: 'list-label' do
- %strong Merge Request events
+ %strong Merge request events
%p.light
This URL will be triggered when a merge request is created/updated/merged
%li
diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml
new file mode 100644
index 00000000000..cf1d5e061c6
--- /dev/null
+++ b/app/views/shared/web_hooks/_test_button.html.haml
@@ -0,0 +1,12 @@
+- triggers = local_assigns.fetch(:triggers)
+- button_class = local_assigns.fetch(:button_class, '')
+- hook = local_assigns.fetch(:hook)
+
+.hook-test-button.dropdown.inline
+ %button.btn{ 'data-toggle' => 'dropdown', class: button_class }
+ Test
+ = icon('caret-down')
+ %ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
+ - triggers.each_value do |event|
+ %li
+ = link_to_test_hook(hook, event)
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index ac3701233ad..dfea8b40bd8 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,4 +1,3 @@
-- remote = local_assigns.fetch(:remote, false)
- link_project = local_assigns.fetch(:link_project, false)
.snippets-list-holder
@@ -8,7 +7,4 @@
%li
.nothing-here-block Nothing here.
- = paginate @snippets, theme: 'gitlab', remote: remote
-
-:javascript
- gl.SnippetsList();
+ = paginate @snippets, theme: 'gitlab'
diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml
index 8b6a98a054a..65aa4fbc757 100644
--- a/app/views/snippets/_snippets_scope_menu.html.haml
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -1,7 +1,7 @@
- subject = local_assigns.fetch(:subject, current_user)
- include_private = local_assigns.fetch(:include_private, false)
-.nav-links.snippet-scope-menu
+.nav-links.snippet-scope-menu.mobile-separator
%li{ class: active_when(params[:scope].nil?) }
= link_to subject_snippets_path(subject) do
All
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index ca8afb4bb6a..f01915107e3 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -1,3 +1,5 @@
+- @hide_top_links = true
+- breadcrumb_title "Snippets"
- page_title "New Snippet"
%h3.page-title
New Snippet
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 098a88c48c5..3a50324770d 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -1,10 +1,17 @@
- if current_user
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
- = 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')
+ .note-actions-item
+ = 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
+ .note-actions-item
+ = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ %span.link-highlight
+ = custom_icon('icon_pencil')
= 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 8818590362d..578327883e5 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -1,4 +1,7 @@
+- @hide_top_links = true
- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
+- add_to_breadcrumbs "Snippets", dashboard_snippets_path
+- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
= render 'shared/snippets/header'
diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml
index f878bece2fa..7eb221620ad 100644
--- a/app/views/u2f/_authenticate.html.haml
+++ b/app/views/u2f/_authenticate.html.haml
@@ -1,6 +1,7 @@
#js-authenticate-u2f
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' } Sign in via 2FA code
+-# haml-lint:disable InlineJavaScript
%script#js-authenticate-u2f-not-supported{ type: "text/template" }
%p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index 00788e77b6b..cc0e93c0755 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -1,20 +1,21 @@
#js-register-u2f
+-# haml-lint:disable InlineJavaScript
%script#js-register-u2f-not-supported{ type: "text/template" }
%p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).
%script#js-register-u2f-setup{ type: "text/template" }
- if current_user.two_factor_otp_enabled?
.row.append-bottom-10
- .col-md-3
- %button#js-setup-u2f-device.btn.btn-info Setup new U2F device
- .col-md-9
+ .col-md-4
+ %button#js-setup-u2f-device.btn.btn-info.btn-block Setup new U2F device
+ .col-md-8
%p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left.
- else
.row.append-bottom-10
- .col-md-3
- %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup new U2F device
- .col-md-9
+ .col-md-4
+ %button#js-setup-u2f-device.btn.btn-info.btn-block{ disabled: true } Setup new U2F device
+ .col-md-8
%p.text-warning You need to register a two-factor authentication app before you can set up a U2F device.
%script#js-register-u2f-in-progress{ type: "text/template" }
@@ -37,7 +38,3 @@
.col-md-3
= hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag "Register U2F device", class: "btn btn-success"
-
-:javascript
- var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
- u2fRegister.start();
diff --git a/app/views/users/_groups.html.haml b/app/views/users/_groups.html.haml
index eff6c80d144..55799e10a46 100644
--- a/app/views/users/_groups.html.haml
+++ b/app/views/users/_groups.html.haml
@@ -2,4 +2,4 @@
- groups.each do |group|
= link_to group, class: 'profile-groups-avatars inline', title: group.name do
.avatar-container.s40
- = image_tag group_icon(group), class: 'avatar group-avatar s40'
+ = group_icon(group, class: 'avatar group-avatar s40')
diff --git a/app/views/users/calendar.html.haml b/app/views/users/calendar.html.haml
deleted file mode 100644
index 57b8845c55d..00000000000
--- a/app/views/users/calendar.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-.clearfix.calendar
- .js-contrib-calendar
- .calendar-hint
- Summary of issues, merge requests, push events, and comments
-:javascript
- new Calendar(
- #{@activity_dates.to_json},
- '#{user_calendar_activities_path}'
- );
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index d1e88274878..6b1d75c6e72 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -1,6 +1,6 @@
%h4.prepend-top-20
Contributions for
- %strong= @calendar_date.to_s(:short)
+ %strong= @calendar_date.to_s(:medium)
- if @events.any?
%ul.bordered-list
@@ -8,11 +8,11 @@
%li
%span.light
%i.fa.fa-clock-o
- = event.created_at.to_s(:time)
+ = event.created_at.strftime('%-I:%M%P')
- if event.push?
#{event.action_name} #{event.ref_type}
%strong
- - commits_path = namespace_project_commits_path(event.project.namespace, event.project, event.ref_name)
+ - commits_path = project_commits_path(event.project, event.ref_name)
= link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path
- else
= event_action_name(event)
@@ -30,4 +30,4 @@
= event.project_name
- else
%p
- No contributions found for #{@calendar_date.to_s(:short)}
+ No contributions found for #{@calendar_date.to_s(:medium)}
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index f246bd7a586..4bf01ecb48c 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -1,8 +1,7 @@
+- @hide_top_links = true
+- @hide_breadcrumbs = true
- page_title @user.name
- page_description @user.bio
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('users')
- header_title @user.name, user_path(@user)
- @no_container = true
@@ -10,7 +9,7 @@
= auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
.user-profile
- .cover-block.user-cover-block.layout-nav
+ .cover-block.user-cover-block.top-area
.cover-controls
- if @user == current_user
= link_to profile_path, class: 'btn btn-gray has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do
@@ -33,8 +32,8 @@
.profile-header
.avatar-holder
- = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = image_tag avatar_icon(@user, 90), class: "avatar s90", alt: ''
+ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
+ = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
.user-info
.cover-title
@@ -49,22 +48,22 @@
.cover-desc
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider
- = link_to @user.public_email, "mailto:#{@user.public_email}"
+ = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link'
- unless @user.skype.blank?
.profile-link-holder.middle-dot-divider
= link_to "skype:#{@user.skype}", title: "Skype" do
= icon('skype')
- unless @user.linkedin.blank?
.profile-link-holder.middle-dot-divider
- = link_to linkedin_url(@user), title: "LinkedIn" do
+ = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
= icon('linkedin-square')
- unless @user.twitter.blank?
.profile-link-holder.middle-dot-divider
- = link_to twitter_url(@user), title: "Twitter" do
+ = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
= icon('twitter-square')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider
- = link_to @user.short_website_url, @user.full_website_url
+ = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'noopener noreferrer nofollow'
- unless @user.location.blank?
.profile-link-holder.middle-dot-divider
= icon('map-marker')
@@ -83,56 +82,58 @@
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%ul.nav-links.user-profile-nav.scrolling-tabs
- %li.js-activity-tab
- = link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
- Activity
- %li.js-groups-tab
- = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
- Groups
- %li.js-contributed-tab
- = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
- Contributed projects
- %li.js-projects-tab
- = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
- Personal projects
- %li.js-snippets-tab
- = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
- Snippets
+ - if profile_tab?(:activity)
+ %li.js-activity-tab
+ = link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
+ Activity
+ - if profile_tab?(:groups)
+ %li.js-groups-tab
+ = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
+ Groups
+ - if profile_tab?(:contributed)
+ %li.js-contributed-tab
+ = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
+ Contributed projects
+ - if profile_tab?(:projects)
+ %li.js-projects-tab
+ = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
+ Personal projects
+ - if profile_tab?(:snippets)
+ %li.js-snippets-tab
+ = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
+ Snippets
%div{ class: container_class }
- - if @user == current_user && show_callout?('user_callout_dismissed')
- = render 'shared/user_callout'
.tab-content
- #activity.tab-pane
- .row-content-block.calender-block.white.second-block.hidden-xs
- .user-calendar{ data: { href: user_calendar_path } }
- %h4.center.light
- %i.fa.fa-spinner.fa-spin
- .user-calendar-activities
-
- %h4.prepend-top-20
- Most Recent Activity
- .content_list{ data: { href: user_path } }
- = spinner
-
- #groups.tab-pane
- -# This tab is always loaded via AJAX
-
- #contributed.tab-pane
- -# This tab is always loaded via AJAX
-
- #projects.tab-pane
- -# This tab is always loaded via AJAX
-
- #snippets.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:activity)
+ #activity.tab-pane
+ .row-content-block.calender-block.white.second-block.hidden-xs
+ .user-calendar{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
+ %h4.center.light
+ %i.fa.fa-spinner.fa-spin
+ .user-calendar-activities
+
+ - if can?(current_user, :read_cross_project)
+ %h4.prepend-top-20
+ Most Recent Activity
+ .content_list{ data: { href: user_path } }
+ = spinner
+
+ - if profile_tab?(:groups)
+ #groups.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:contributed)
+ #contributed.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:projects)
+ #projects.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:snippets)
+ #snippets.tab-pane
+ -# This tab is always loaded via AJAX
.loading-status
= spinner
-
-:javascript
- var userProfile;
-
- userProfile = new gl.User({
- action: "#{controller.action_name}"
- });
diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb
index c2dc955b27c..bec0a003a1c 100644
--- a/app/workers/admin_email_worker.rb
+++ b/app/workers/admin_email_worker.rb
@@ -1,5 +1,5 @@
class AdminEmailWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
new file mode 100644
index 00000000000..f65e8385ac8
--- /dev/null
+++ b/app/workers/all_queues.yml
@@ -0,0 +1,106 @@
+---
+- cronjob:admin_email
+- cronjob:expire_build_artifacts
+- cronjob:gitlab_usage_ping
+- cronjob:import_export_project_cleanup
+- cronjob:pages_domain_verification_cron
+- cronjob:pipeline_schedule
+- cronjob:prune_old_events
+- cronjob:remove_expired_group_links
+- cronjob:remove_expired_members
+- cronjob:remove_old_web_hook_logs
+- cronjob:remove_unreferenced_lfs_objects
+- cronjob:repository_archive_cache
+- cronjob:repository_check_batch
+- cronjob:requests_profiles
+- cronjob:schedule_update_user_activity
+- cronjob:stuck_ci_jobs
+- cronjob:stuck_import_jobs
+- cronjob:stuck_merge_jobs
+- cronjob:trending_projects
+
+- gcp_cluster:cluster_install_app
+- gcp_cluster:cluster_provision
+- gcp_cluster:cluster_wait_for_app_installation
+- gcp_cluster:wait_for_cluster_creation
+- gcp_cluster:check_gcp_project_billing
+- gcp_cluster:cluster_wait_for_ingress_ip_address
+
+- github_import_advance_stage
+- github_importer:github_import_import_diff_note
+- github_importer:github_import_import_issue
+- github_importer:github_import_import_note
+- github_importer:github_import_import_pull_request
+- github_importer:github_import_refresh_import_jid
+- github_importer:github_import_stage_finish_import
+- github_importer:github_import_stage_import_base_data
+- github_importer:github_import_stage_import_issues_and_diff_notes
+- github_importer:github_import_stage_import_notes
+- github_importer:github_import_stage_import_pull_requests
+- github_importer:github_import_stage_import_repository
+
+- pipeline_cache:expire_job_cache
+- pipeline_cache:expire_pipeline_cache
+- pipeline_creation:create_pipeline
+- pipeline_creation:run_pipeline_schedule
+- pipeline_background:archive_trace
+- pipeline_default:build_coverage
+- pipeline_default:build_trace_sections
+- pipeline_default:pipeline_metrics
+- pipeline_default:pipeline_notification
+- pipeline_hooks:build_hooks
+- pipeline_hooks:pipeline_hooks
+- pipeline_processing:build_finished
+- pipeline_processing:build_queue
+- pipeline_processing:build_success
+- pipeline_processing:pipeline_process
+- pipeline_processing:pipeline_success
+- pipeline_processing:pipeline_update
+- pipeline_processing:stage_update
+- pipeline_processing:update_head_pipeline_for_merge_request
+
+- repository_check:repository_check_clear
+- repository_check:repository_check_single_repository
+
+- default
+- mailers # ActionMailer::DeliveryJob.queue_name
+
+- authorized_projects
+- background_migration
+- create_gpg_signature
+- delete_merged_branches
+- delete_user
+- email_receiver
+- emails_on_push
+- expire_build_instance_artifacts
+- git_garbage_collect
+- gitlab_shell
+- group_destroy
+- invalid_gpg_signature_update
+- irker
+- merge
+- namespaceless_project_destroy
+- new_issue
+- new_merge_request
+- new_note
+- pages
+- pages_domain_verification
+- plugin
+- post_receive
+- process_commit
+- project_cache
+- project_destroy
+- project_export
+- project_migrate_hashed_storage
+- project_service
+- propagate_service_template
+- reactive_caching
+- rebase
+- repository_fork
+- repository_import
+- storage_migrator
+- system_hook_push
+- update_merge_requests
+- update_user_activity
+- upload_checksum
+- web_hook
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
new file mode 100644
index 00000000000..dea7425ad88
--- /dev/null
+++ b/app/workers/archive_trace_worker.rb
@@ -0,0 +1,10 @@
+class ArchiveTraceWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ def perform(job_id)
+ Ci::Build.find_by(id: job_id).try do |job|
+ job.trace.archive!
+ end
+ end
+end
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index 13207a8bc71..d7e24491516 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -1,17 +1,6 @@
class AuthorizedProjectsWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
-
- # Schedules multiple jobs and waits for them to be completed.
- def self.bulk_perform_and_wait(args_list)
- job_ids = bulk_perform_async(args_list)
-
- Gitlab::JobWaiter.new(job_ids).wait
- end
-
- def self.bulk_perform_async(args_list)
- Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list)
- end
+ include ApplicationWorker
+ prepend WaitableWorker
def perform(user_id)
user = User.find_by(id: user_id)
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index e85e221d353..376703f6319 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -1,23 +1,53 @@
class BackgroundMigrationWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
- # Schedules a number of jobs in bulk
+ # The minimum amount of time between processing two jobs of the same migration
+ # class.
#
- # The `jobs` argument should be an Array of Arrays, each sub-array must be in
- # the form:
- #
- # [migration-class, [arg1, arg2, ...]]
- def self.perform_bulk(*jobs)
- Sidekiq::Client.push_bulk('class' => self,
- 'queue' => sidekiq_options['queue'],
- 'args' => jobs)
- end
+ # This interval is set to 5 minutes so autovacuuming and other maintenance
+ # related tasks have plenty of time to clean up after a migration has been
+ # performed.
+ MIN_INTERVAL = 5.minutes.to_i
# Performs the background migration.
#
# See Gitlab::BackgroundMigration.perform for more information.
+ #
+ # class_name - The class name of the background migration to run.
+ # arguments - The arguments to pass to the migration class.
def perform(class_name, arguments = [])
- Gitlab::BackgroundMigration.perform(class_name, arguments)
+ should_perform, ttl = perform_and_ttl(class_name)
+
+ if should_perform
+ Gitlab::BackgroundMigration.perform(class_name, arguments)
+ else
+ # If the lease could not be obtained this means either another process is
+ # running a migration of this class or we ran one recently. In this case
+ # we'll reschedule the job in such a way that it is picked up again around
+ # the time the lease expires.
+ self.class.perform_in(ttl || MIN_INTERVAL, class_name, arguments)
+ end
+ end
+
+ def perform_and_ttl(class_name)
+ if always_perform?
+ # In test environments `perform_in` will run right away. This can then
+ # lead to stack level errors in the above `#perform`. To work around this
+ # we'll just perform the migration right away in the test environment.
+ [true, nil]
+ else
+ lease = lease_for(class_name)
+
+ [lease.try_obtain, lease.ttl]
+ end
+ end
+
+ def lease_for(class_name)
+ Gitlab::ExclusiveLease
+ .new("#{self.class.name}:#{class_name}", timeout: MIN_INTERVAL)
+ end
+
+ def always_perform?
+ Rails.env.test?
end
end
diff --git a/app/workers/build_coverage_worker.rb b/app/workers/build_coverage_worker.rb
index f7ae996bb17..62b212c79be 100644
--- a/app/workers/build_coverage_worker.rb
+++ b/app/workers/build_coverage_worker.rb
@@ -1,6 +1,6 @@
class BuildCoverageWorker
- include Sidekiq::Worker
- include BuildQueue
+ include ApplicationWorker
+ include PipelineQueue
def perform(build_id)
Ci::Build.find_by(id: build_id)&.update_coverage
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index 466410bf08c..46f1ac09915 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -1,11 +1,18 @@
class BuildFinishedWorker
- include Sidekiq::Worker
- include BuildQueue
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
+ # We execute that in sync as this access the files in order to access local file, and reduce IO
+ BuildTraceSectionsWorker.new.perform(build.id)
BuildCoverageWorker.new.perform(build.id)
- BuildHooksWorker.new.perform(build.id)
+
+ # We execute that async as this are two indepentent operations that can be executed after TraceSections and Coverage
+ BuildHooksWorker.perform_async(build.id)
+ ArchiveTraceWorker.perform_async(build.id)
end
end
end
diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb
index 9965af935d4..cbfca8c342c 100644
--- a/app/workers/build_hooks_worker.rb
+++ b/app/workers/build_hooks_worker.rb
@@ -1,6 +1,8 @@
class BuildHooksWorker
- include Sidekiq::Worker
- include BuildQueue
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_hooks
def perform(build_id)
Ci::Build.find_by(id: build_id)
diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb
index fa9e097e40a..e4f4e6c1d9e 100644
--- a/app/workers/build_queue_worker.rb
+++ b/app/workers/build_queue_worker.rb
@@ -1,6 +1,8 @@
class BuildQueueWorker
- include Sidekiq::Worker
- include BuildQueue
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index bf009dfab0f..4b9097bc5e4 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -1,6 +1,8 @@
class BuildSuccessWorker
- include Sidekiq::Worker
- include BuildQueue
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
diff --git a/app/workers/build_trace_sections_worker.rb b/app/workers/build_trace_sections_worker.rb
new file mode 100644
index 00000000000..c0f5c144e10
--- /dev/null
+++ b/app/workers/build_trace_sections_worker.rb
@@ -0,0 +1,8 @@
+class BuildTraceSectionsWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ def perform(build_id)
+ Ci::Build.find_by(id: build_id)&.parse_trace_sections!
+ end
+end
diff --git a/app/workers/check_gcp_project_billing_worker.rb b/app/workers/check_gcp_project_billing_worker.rb
new file mode 100644
index 00000000000..363f81590ab
--- /dev/null
+++ b/app/workers/check_gcp_project_billing_worker.rb
@@ -0,0 +1,92 @@
+require 'securerandom'
+
+class CheckGcpProjectBillingWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ LEASE_TIMEOUT = 3.seconds.to_i
+ SESSION_KEY_TIMEOUT = 5.minutes
+ BILLING_TIMEOUT = 1.hour
+ BILLING_CHANGED_LABELS = { state_transition: nil }.freeze
+
+ def self.get_session_token(token_key)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.get(get_redis_session_key(token_key))
+ end
+ end
+
+ def self.store_session_token(token)
+ generate_token_key.tap do |token_key|
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(get_redis_session_key(token_key), token, ex: SESSION_KEY_TIMEOUT)
+ end
+ end
+ end
+
+ def self.get_billing_state(token)
+ Gitlab::Redis::SharedState.with do |redis|
+ value = redis.get(redis_shared_state_key_for(token))
+ ActiveRecord::Type::Boolean.new.type_cast_from_user(value)
+ end
+ end
+
+ def perform(token_key)
+ return unless token_key
+
+ token = self.class.get_session_token(token_key)
+ return unless token
+ return unless try_obtain_lease_for(token)
+
+ billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty?
+ update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state)
+ self.class.set_billing_state(token, billing_enabled_state)
+ end
+
+ private
+
+ def self.generate_token_key
+ SecureRandom.uuid
+ end
+
+ def self.get_redis_session_key(token_key)
+ "gitlab:gcp:session:#{token_key}"
+ end
+
+ def self.redis_shared_state_key_for(token)
+ "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled"
+ end
+
+ def self.set_billing_state(token, value)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT)
+ end
+ end
+
+ def try_obtain_lease_for(token)
+ Gitlab::ExclusiveLease
+ .new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT)
+ .try_obtain
+ end
+
+ def billing_changed_counter
+ @billing_changed_counter ||= Gitlab::Metrics.counter(
+ :gcp_billing_change_count,
+ "Counts the number of times a GCP project changed billing_enabled state from false to true",
+ BILLING_CHANGED_LABELS
+ )
+ end
+
+ def state_transition(previous_state, current_state)
+ if previous_state.nil? && !current_state
+ 'no_billing'
+ elsif previous_state.nil? && current_state
+ 'with_billing'
+ elsif !previous_state && current_state
+ 'billing_configured'
+ end
+ end
+
+ def update_billing_change_counter(previous_state, current_state)
+ billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state))
+ end
+end
diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb
new file mode 100644
index 00000000000..f771cb4939f
--- /dev/null
+++ b/app/workers/cluster_install_app_worker.rb
@@ -0,0 +1,11 @@
+class ClusterInstallAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::InstallService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb
new file mode 100644
index 00000000000..1ab4de3b647
--- /dev/null
+++ b/app/workers/cluster_provision_worker.rb
@@ -0,0 +1,12 @@
+class ClusterProvisionWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ def perform(cluster_id)
+ Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
+ cluster.provider.try do |provider|
+ Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp?
+ end
+ end
+ end
+end
diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb
new file mode 100644
index 00000000000..d564d5e48bf
--- /dev/null
+++ b/app/workers/cluster_wait_for_app_installation_worker.rb
@@ -0,0 +1,14 @@
+class ClusterWaitForAppInstallationWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ INTERVAL = 10.seconds
+ TIMEOUT = 20.minutes
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::CheckInstallationProgressService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
new file mode 100644
index 00000000000..8ba5951750c
--- /dev/null
+++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
@@ -0,0 +1,11 @@
+class ClusterWaitForIngressIpAddressWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::CheckIngressIpAddressService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
new file mode 100644
index 00000000000..37586e161c9
--- /dev/null
+++ b/app/workers/concerns/application_worker.rb
@@ -0,0 +1,60 @@
+Sidekiq::Worker.extend ActiveSupport::Concern
+
+module ApplicationWorker
+ extend ActiveSupport::Concern
+
+ include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker
+
+ included do
+ set_queue
+ end
+
+ module ClassMethods
+ def inherited(subclass)
+ subclass.set_queue
+ end
+
+ def set_queue
+ queue_name = [queue_namespace, base_queue_name].compact.join(':')
+
+ sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue
+ end
+
+ def base_queue_name
+ name
+ .sub(/\AGitlab::/, '')
+ .sub(/Worker\z/, '')
+ .underscore
+ .tr('/', '_')
+ end
+
+ def queue_namespace(new_namespace = nil)
+ if new_namespace
+ sidekiq_options queue_namespace: new_namespace
+
+ set_queue
+ else
+ get_sidekiq_options['queue_namespace']&.to_s
+ end
+ end
+
+ def queue
+ get_sidekiq_options['queue'].to_s
+ end
+
+ def bulk_perform_async(args_list)
+ Sidekiq::Client.push_bulk('class' => self, 'args' => args_list)
+ end
+
+ def bulk_perform_in(delay, args_list)
+ now = Time.now.to_i
+ schedule = now + delay.to_i
+
+ if schedule <= now
+ raise ArgumentError, 'The schedule time must be in the future!'
+ end
+
+ Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
+ end
+ end
+end
diff --git a/app/workers/concerns/build_queue.rb b/app/workers/concerns/build_queue.rb
deleted file mode 100644
index cf0ead40a8b..00000000000
--- a/app/workers/concerns/build_queue.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# Concern for setting Sidekiq settings for the various CI build workers.
-module BuildQueue
- extend ActiveSupport::Concern
-
- included do
- sidekiq_options queue: :build
- end
-end
diff --git a/app/workers/concerns/cluster_applications.rb b/app/workers/concerns/cluster_applications.rb
new file mode 100644
index 00000000000..24ecaa0b52f
--- /dev/null
+++ b/app/workers/concerns/cluster_applications.rb
@@ -0,0 +1,9 @@
+module ClusterApplications
+ extend ActiveSupport::Concern
+
+ included do
+ def find_application(app_name, id, &blk)
+ Clusters::Cluster::APPLICATIONS[app_name].find(id).try(&blk)
+ end
+ end
+end
diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb
new file mode 100644
index 00000000000..24b9f145220
--- /dev/null
+++ b/app/workers/concerns/cluster_queue.rb
@@ -0,0 +1,10 @@
+##
+# Concern for setting Sidekiq settings for the various Gcp clusters workers.
+#
+module ClusterQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :gcp_cluster
+ end
+end
diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb
index e918bb011e0..b6581779f6a 100644
--- a/app/workers/concerns/cronjob_queue.rb
+++ b/app/workers/concerns/cronjob_queue.rb
@@ -4,6 +4,7 @@ module CronjobQueue
extend ActiveSupport::Concern
included do
- sidekiq_options queue: :cronjob, retry: false
+ queue_namespace :cronjob
+ sidekiq_options retry: false
end
end
diff --git a/app/workers/concerns/dedicated_sidekiq_queue.rb b/app/workers/concerns/dedicated_sidekiq_queue.rb
deleted file mode 100644
index 132bae6022b..00000000000
--- a/app/workers/concerns/dedicated_sidekiq_queue.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# Concern that sets the queue of a Sidekiq worker based on the worker's class
-# name/namespace.
-module DedicatedSidekiqQueue
- extend ActiveSupport::Concern
-
- included do
- sidekiq_options queue: name.sub(/Worker\z/, '').underscore.tr('/', '_')
- end
-end
diff --git a/app/workers/concerns/exception_backtrace.rb b/app/workers/concerns/exception_backtrace.rb
new file mode 100644
index 00000000000..ea0f1f8d19b
--- /dev/null
+++ b/app/workers/concerns/exception_backtrace.rb
@@ -0,0 +1,8 @@
+# Concern for enabling a few lines of exception backtraces in Sidekiq
+module ExceptionBacktrace
+ extend ActiveSupport::Concern
+
+ included do
+ sidekiq_options backtrace: 5
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/notify_upon_death.rb b/app/workers/concerns/gitlab/github_import/notify_upon_death.rb
new file mode 100644
index 00000000000..3d7120665b6
--- /dev/null
+++ b/app/workers/concerns/gitlab/github_import/notify_upon_death.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ # NotifyUponDeath can be included into a GitHub worker class if it should
+ # notify any JobWaiter instances upon being moved to the Sidekiq dead queue.
+ #
+ # Note that this will only notify the waiter upon graceful termination, a
+ # SIGKILL will still result in the waiter _not_ being notified.
+ #
+ # Workers including this module must have jobs passed where the last
+ # argument is the key to notify, as a String.
+ module NotifyUponDeath
+ extend ActiveSupport::Concern
+
+ included do
+ # If a job is being exhausted we still want to notify the
+ # AdvanceStageWorker. This prevents the entire import from getting stuck
+ # just because 1 job threw too many errors.
+ sidekiq_retries_exhausted do |job|
+ args = job['args']
+ jid = job['jid']
+
+ if args.length == 3 && (key = args.last) && key.is_a?(String)
+ JobWaiter.notify(key, jid)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
new file mode 100644
index 00000000000..100d86e38c8
--- /dev/null
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ # ObjectImporter defines the base behaviour for every Sidekiq worker that
+ # imports a single resource such as a note or pull request.
+ module ObjectImporter
+ extend ActiveSupport::Concern
+
+ included do
+ include ApplicationWorker
+ include GithubImport::Queue
+ include ReschedulingMethods
+ include NotifyUponDeath
+ end
+
+ # project - An instance of `Project` to import the data into.
+ # client - An instance of `Gitlab::GithubImport::Client`
+ # hash - A Hash containing the details of the object to import.
+ def import(project, client, hash)
+ object = representation_class.from_json_hash(hash)
+
+ importer_class.new(object, project, client).execute
+
+ counter.increment(project: project.full_path)
+ end
+
+ def counter
+ @counter ||= Gitlab::Metrics.counter(counter_name, counter_description)
+ end
+
+ # Returns the representation class to use for the object. This class must
+ # define the class method `from_json_hash`.
+ def representation_class
+ raise NotImplementedError
+ end
+
+ # Returns the class to use for importing the object.
+ def importer_class
+ raise NotImplementedError
+ end
+
+ # Returns the name (as a Symbol) of the Prometheus counter.
+ def counter_name
+ raise NotImplementedError
+ end
+
+ # Returns the description (as a String) of the Prometheus counter.
+ def counter_description
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb
new file mode 100644
index 00000000000..22c2ce458e8
--- /dev/null
+++ b/app/workers/concerns/gitlab/github_import/queue.rb
@@ -0,0 +1,18 @@
+module Gitlab
+ module GithubImport
+ module Queue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :github_importer
+
+ # If a job produces an error it may block a stage from advancing
+ # forever. To prevent this from happening we prevent jobs from going to
+ # the dead queue. This does mean some resources may not be imported, but
+ # this is better than a project being stuck in the "import" state
+ # forever.
+ sidekiq_options dead: false, retry: 5
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
new file mode 100644
index 00000000000..692ca6b7f42
--- /dev/null
+++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ # Module that provides methods shared by the various workers used for
+ # importing GitHub projects.
+ module ReschedulingMethods
+ # project_id - The ID of the GitLab project to import the note into.
+ # hash - A Hash containing the details of the GitHub object to imoprt.
+ # notify_key - The Redis key to notify upon completion, if any.
+ def perform(project_id, hash, notify_key = nil)
+ project = Project.find_by(id: project_id)
+
+ return notify_waiter(notify_key) unless project
+
+ client = GithubImport.new_client_for(project, parallel: true)
+
+ if try_import(project, client, hash)
+ notify_waiter(notify_key)
+ else
+ # In the event of hitting the rate limit we want to reschedule the job
+ # so its retried after our rate limit has been reset.
+ self.class
+ .perform_in(client.rate_limit_resets_in, project.id, hash, notify_key)
+ end
+ end
+
+ def try_import(*args)
+ import(*args)
+ true
+ rescue RateLimitError
+ false
+ end
+
+ def notify_waiter(key = nil)
+ JobWaiter.notify(key, jid) if key
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb
new file mode 100644
index 00000000000..147c8c8d683
--- /dev/null
+++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module StageMethods
+ # project_id - The ID of the GitLab project to import the data into.
+ def perform(project_id)
+ return unless (project = find_project(project_id))
+
+ client = GithubImport.new_client_for(project)
+
+ try_import(client, project)
+ end
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def try_import(client, project)
+ import(client, project)
+ rescue RateLimitError
+ self.class.perform_in(client.rate_limit_resets_in, project.id)
+ end
+
+ def find_project(id)
+ # If the project has been marked as failed we want to bail out
+ # automatically.
+ Project.import_started.find_by(id: id)
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/new_issuable.rb b/app/workers/concerns/new_issuable.rb
new file mode 100644
index 00000000000..526ed0bad07
--- /dev/null
+++ b/app/workers/concerns/new_issuable.rb
@@ -0,0 +1,26 @@
+module NewIssuable
+ attr_reader :issuable, :user
+
+ def objects_found?(issuable_id, user_id)
+ set_user(user_id)
+ set_issuable(issuable_id)
+
+ user && issuable
+ end
+
+ def set_user(user_id)
+ @user = User.find_by(id: user_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ log_error(User, user_id) unless @user # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def set_issuable(issuable_id)
+ @issuable = issuable_class.find_by(id: issuable_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ log_error(issuable_class, issuable_id) unless @issuable # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def log_error(record_class, record_id)
+ Rails.logger.error("#{self.class}: couldn't find #{record_class} with ID=#{record_id}, skipping job")
+ end
+end
diff --git a/app/workers/concerns/pipeline_background_queue.rb b/app/workers/concerns/pipeline_background_queue.rb
new file mode 100644
index 00000000000..8bf43de6b26
--- /dev/null
+++ b/app/workers/concerns/pipeline_background_queue.rb
@@ -0,0 +1,10 @@
+##
+# Concern for setting Sidekiq settings for the low priority CI pipeline workers.
+#
+module PipelineBackgroundQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :pipeline_background
+ end
+end
diff --git a/app/workers/concerns/pipeline_queue.rb b/app/workers/concerns/pipeline_queue.rb
index ca3860e1d38..e77093a6902 100644
--- a/app/workers/concerns/pipeline_queue.rb
+++ b/app/workers/concerns/pipeline_queue.rb
@@ -1,8 +1,10 @@
+##
# Concern for setting Sidekiq settings for the various CI pipeline workers.
+#
module PipelineQueue
extend ActiveSupport::Concern
included do
- sidekiq_options queue: :pipeline
+ queue_namespace :pipeline_default
end
end
diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb
new file mode 100644
index 00000000000..ef23990ad97
--- /dev/null
+++ b/app/workers/concerns/project_import_options.rb
@@ -0,0 +1,23 @@
+module ProjectImportOptions
+ extend ActiveSupport::Concern
+
+ IMPORT_RETRY_COUNT = 5
+
+ included do
+ sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
+
+ # We only want to mark the project as failed once we exhausted all retries
+ sidekiq_retries_exhausted do |job|
+ project = Project.find(job['args'].first)
+
+ action = if project.forked?
+ "fork"
+ else
+ "import"
+ end
+
+ project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.")
+ Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}"
+ end
+ end
+end
diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb
new file mode 100644
index 00000000000..4e55a1ee3d6
--- /dev/null
+++ b/app/workers/concerns/project_start_import.rb
@@ -0,0 +1,10 @@
+# Used in EE by mirroring
+module ProjectStartImport
+ def start(project)
+ if project.import_started? && project.import_jid == self.jid
+ return true
+ end
+
+ project.import_start
+ end
+end
diff --git a/app/workers/concerns/repository_check_queue.rb b/app/workers/concerns/repository_check_queue.rb
index a597321ccf4..43fb66c31b0 100644
--- a/app/workers/concerns/repository_check_queue.rb
+++ b/app/workers/concerns/repository_check_queue.rb
@@ -3,6 +3,8 @@ module RepositoryCheckQueue
extend ActiveSupport::Concern
included do
- sidekiq_options queue: :repository_check, retry: false
+ queue_namespace :repository_check
+
+ sidekiq_options retry: false
end
end
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
new file mode 100644
index 00000000000..48ebe862248
--- /dev/null
+++ b/app/workers/concerns/waitable_worker.rb
@@ -0,0 +1,44 @@
+module WaitableWorker
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ # Schedules multiple jobs and waits for them to be completed.
+ def bulk_perform_and_wait(args_list, timeout: 10)
+ # Short-circuit: it's more efficient to do small numbers of jobs inline
+ return bulk_perform_inline(args_list) if args_list.size <= 3
+
+ waiter = Gitlab::JobWaiter.new(args_list.size)
+
+ # Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]]
+ # into [[1, "key"], [2, "key"], [3, "key"]]
+ waiting_args_list = args_list.map { |args| [*args, waiter.key] }
+ bulk_perform_async(waiting_args_list)
+
+ waiter.wait(timeout)
+ end
+
+ # Performs multiple jobs directly. Failed jobs will be put into sidekiq so
+ # they can benefit from retries
+ def bulk_perform_inline(args_list)
+ failed = []
+
+ args_list.each do |args|
+ begin
+ new.perform(*args)
+ rescue
+ failed << args
+ end
+ end
+
+ bulk_perform_async(failed) if failed.present?
+ end
+ end
+
+ def perform(*args)
+ notify_key = args.pop if Gitlab::JobWaiter.key?(args.last)
+
+ super(*args)
+ ensure
+ Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
+ end
+end
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb
new file mode 100644
index 00000000000..f371731f68c
--- /dev/null
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -0,0 +1,15 @@
+class CreateGpgSignatureWorker
+ include ApplicationWorker
+
+ def perform(commit_sha, project_id)
+ project = Project.find_by(id: project_id)
+ return unless project
+
+ commit = project.commit(commit_sha)
+
+ return unless commit
+
+ # This calculates and caches the signature in the database
+ Gitlab::Gpg::Commit.new(commit).signature
+ end
+end
diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb
new file mode 100644
index 00000000000..c3ac35e54f5
--- /dev/null
+++ b/app/workers/create_pipeline_worker.rb
@@ -0,0 +1,16 @@
+class CreatePipelineWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_creation
+
+ def perform(project_id, user_id, ref, source, params = {})
+ project = Project.find(project_id)
+ user = User.find(user_id)
+ params = params.deep_symbolize_keys
+
+ Ci::CreatePipelineService
+ .new(project, user, ref: ref)
+ .execute(source, **params)
+ end
+end
diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb
index f870da4ecfd..07cd1f02fb5 100644
--- a/app/workers/delete_merged_branches_worker.rb
+++ b/app/workers/delete_merged_branches_worker.rb
@@ -1,6 +1,5 @@
class DeleteMergedBranchesWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(project_id, user_id)
begin
diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb
index 3340a7be4fe..6c431b02979 100644
--- a/app/workers/delete_user_worker.rb
+++ b/app/workers/delete_user_worker.rb
@@ -1,6 +1,5 @@
class DeleteUserWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(current_user_id, delete_user_id, options = {})
delete_user = User.find(delete_user_id)
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index d3f7e479a8d..dd8a6cbbef1 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -1,6 +1,5 @@
class EmailReceiverWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(raw)
return unless Gitlab::IncomingEmail.enabled?
@@ -31,8 +30,6 @@ class EmailReceiverWorker
when Gitlab::Email::EmptyEmailError
can_retry = true
"It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies."
- when Gitlab::Email::AutoGeneratedEmailError
- "The email was marked as 'auto generated', which we can't accept. Please create your comment through the web interface."
when Gitlab::Email::UserNotFoundError
"We couldn't figure out what user corresponds to the email. Please create your comment through the web interface."
when Gitlab::Email::UserBlockedError
@@ -41,8 +38,7 @@ class EmailReceiverWorker
"You are not allowed to perform this action. If you believe this is in error, contact a staff member."
when Gitlab::Email::NoteableNotFoundError
"The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member."
- when Gitlab::Email::InvalidNoteError,
- Gitlab::Email::InvalidIssueError
+ when Gitlab::Email::InvalidRecordError
can_retry = true
e.message
end
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index f5ccc84c160..2a4d65b5cb3 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -1,6 +1,5 @@
class EmailsOnPushWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
attr_reader :email, :skip_premailer
@@ -67,7 +66,7 @@ class EmailsOnPushWorker
# These are input errors and won't be corrected even if Sidekiq retries
rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e
- logger.info("Failed to send e-mail for project '#{project.name_with_namespace}' to #{recipient}: #{e}")
+ logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}")
end
end
ensure
diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb
index a27585fd389..87e5dca01fd 100644
--- a/app/workers/expire_build_artifacts_worker.rb
+++ b/app/workers/expire_build_artifacts_worker.rb
@@ -1,5 +1,5 @@
class ExpireBuildArtifactsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
@@ -8,6 +8,6 @@ class ExpireBuildArtifactsWorker
build_ids = Ci::Build.with_expired_artifacts.pluck(:id)
build_ids = build_ids.map { |build_id| [build_id] }
- Sidekiq::Client.push_bulk('class' => ExpireBuildInstanceArtifactsWorker, 'args' => build_ids )
+ ExpireBuildInstanceArtifactsWorker.bulk_perform_async(build_ids)
end
end
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index 7b59e976492..234b4357cf7 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -1,6 +1,5 @@
class ExpireBuildInstanceArtifactsWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(build_id)
build = Ci::Build
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index 08e281e7350..7217364a9f2 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -1,6 +1,8 @@
class ExpireJobCacheWorker
- include Sidekiq::Worker
- include BuildQueue
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_cache
def perform(job_id)
job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id)
@@ -18,18 +20,10 @@ class ExpireJobCacheWorker
private
def project_pipeline_path(project, pipeline)
- Gitlab::Routing.url_helpers.namespace_project_pipeline_path(
- project.namespace,
- project,
- pipeline,
- format: :json)
+ Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
end
def project_job_path(project, job)
- Gitlab::Routing.url_helpers.namespace_project_build_path(
- project.namespace,
- project,
- job.id,
- format: :json)
+ Gitlab::Routing.url_helpers.project_build_path(project, job.id, format: :json)
end
end
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index d760f5b140f..db73d37868a 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -1,7 +1,9 @@
class ExpirePipelineCacheWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
+ queue_namespace :pipeline_cache
+
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
return unless pipeline
@@ -11,7 +13,7 @@ class ExpirePipelineCacheWorker
store.touch(project_pipelines_path(project))
store.touch(project_pipeline_path(project, pipeline))
- store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit
+ store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
store.touch(new_merge_request_pipelines_path(project))
each_pipelines_merge_request_path(project, pipeline) do |path|
store.touch(path)
@@ -23,42 +25,24 @@ class ExpirePipelineCacheWorker
private
def project_pipelines_path(project)
- Gitlab::Routing.url_helpers.namespace_project_pipelines_path(
- project.namespace,
- project,
- format: :json)
+ Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
end
def project_pipeline_path(project, pipeline)
- Gitlab::Routing.url_helpers.namespace_project_pipeline_path(
- project.namespace,
- project,
- pipeline,
- format: :json)
+ Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
end
def commit_pipelines_path(project, commit)
- Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path(
- project.namespace,
- project,
- commit.id,
- format: :json)
+ Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
end
def new_merge_request_pipelines_path(project)
- Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path(
- project.namespace,
- project,
- format: :json)
+ Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
end
def each_pipelines_merge_request_path(project, pipeline)
pipeline.all_merge_requests.each do |merge_request|
- path = Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path(
- project.namespace,
- project,
- merge_request,
- format: :json)
+ path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json)
yield(path)
end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index d369b639ae9..55fb817ca6e 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -1,31 +1,88 @@
class GitGarbageCollectWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
- include Gitlab::CurrentSettings
+ include ApplicationWorker
sidekiq_options retry: false
+ # Timeout set to 24h
+ LEASE_TIMEOUT = 86400
+
+ GITALY_MIGRATED_TASKS = {
+ gc: :garbage_collect,
+ full_repack: :repack_full,
+ incremental_repack: :repack_incremental
+ }.freeze
+
def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil)
project = Project.find(project_id)
- task = task.to_sym
+ active_uuid = get_lease_uuid(lease_key)
+
+ if active_uuid
+ return unless active_uuid == lease_uuid
+
+ renew_lease(lease_key, active_uuid)
+ else
+ lease_uuid = try_obtain_lease(lease_key)
+
+ return unless lease_uuid
+ end
+ task = task.to_sym
cmd = command(task)
repo_path = project.repository.path_to_repo
description = "'#{cmd.join(' ')}' in #{repo_path}"
Gitlab::GitLogger.info(description)
- output, status = Gitlab::Popen.popen(cmd, repo_path)
- Gitlab::GitLogger.error("#{description} failed:\n#{output}") unless status.zero?
+ gitaly_migrate(GITALY_MIGRATED_TASKS[task]) do |is_enabled|
+ if is_enabled
+ gitaly_call(task, project.repository.raw_repository)
+ else
+ output, status = Gitlab::Popen.popen(cmd, repo_path)
+ Gitlab::GitLogger.error("#{description} failed:\n#{output}") unless status.zero?
+ end
+ end
# Refresh the branch cache in case garbage collection caused a ref lookup to fail
flush_ref_caches(project) if task == :gc
+
+ # In case pack files are deleted, release libgit2 cache and open file
+ # descriptors ASAP instead of waiting for Ruby garbage collection
+ project.cleanup
ensure
- Gitlab::ExclusiveLease.cancel(lease_key, lease_uuid) if lease_key.present? && lease_uuid.present?
+ cancel_lease(lease_key, lease_uuid) if lease_key.present? && lease_uuid.present?
end
private
+ def try_obtain_lease(key)
+ ::Gitlab::ExclusiveLease.new(key, timeout: LEASE_TIMEOUT).try_obtain
+ end
+
+ def renew_lease(key, uuid)
+ ::Gitlab::ExclusiveLease.new(key, uuid: uuid, timeout: LEASE_TIMEOUT).renew
+ end
+
+ def cancel_lease(key, uuid)
+ ::Gitlab::ExclusiveLease.cancel(key, uuid)
+ end
+
+ def get_lease_uuid(key)
+ ::Gitlab::ExclusiveLease.get_uuid(key)
+ end
+
+ ## `repository` has to be a Gitlab::Git::Repository
+ def gitaly_call(task, repository)
+ client = Gitlab::GitalyClient::RepositoryService.new(repository)
+ case task
+ when :gc
+ client.garbage_collect(bitmaps_enabled?)
+ when :full_repack
+ client.repack_full(bitmaps_enabled?)
+ when :incremental_repack
+ client.repack_incremental
+ end
+ end
+
def command(task)
case task
when :gc
@@ -48,11 +105,21 @@ class GitGarbageCollectWorker
end
def bitmaps_enabled?
- current_application_settings.housekeeping_bitmaps_enabled
+ Gitlab::CurrentSettings.housekeeping_bitmaps_enabled
end
def git(write_bitmaps:)
config_value = write_bitmaps ? 'true' : 'false'
%W[git -c repack.writeBitmaps=#{config_value}]
end
+
+ def gitaly_migrate(method, &block)
+ Gitlab::GitalyClient.migrate(method, &block)
+ rescue GRPC::NotFound => e
+ Gitlab::GitLogger.error("#{method} failed:\nRepository not found")
+ raise Gitlab::Git::Repository::NoRepository.new(e)
+ rescue GRPC::BadStatus => e
+ Gitlab::GitLogger.error("#{method} failed:\n#{e}")
+ raise Gitlab::Git::CommandError.new(e)
+ end
end
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
new file mode 100644
index 00000000000..f7f498af840
--- /dev/null
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ # AdvanceStageWorker is a worker used by the GitHub importer to wait for a
+ # number of jobs to complete, without blocking a thread. Once all jobs have
+ # been completed this worker will advance the import process to the next
+ # stage.
+ class AdvanceStageWorker
+ include ApplicationWorker
+
+ sidekiq_options dead: false
+
+ INTERVAL = 30.seconds.to_i
+
+ # The number of seconds to wait (while blocking the thread) before
+ # continueing to the next waiter.
+ BLOCKING_WAIT_TIME = 5
+
+ # The known importer stages and their corresponding Sidekiq workers.
+ STAGES = {
+ issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker,
+ notes: Stage::ImportNotesWorker,
+ finish: Stage::FinishImportWorker
+ }.freeze
+
+ # project_id - The ID of the project being imported.
+ # waiters - A Hash mapping Gitlab::JobWaiter keys to the number of
+ # remaining jobs.
+ # next_stage - The name of the next stage to start when all jobs have been
+ # completed.
+ def perform(project_id, waiters, next_stage)
+ return unless (project = find_project(project_id))
+
+ new_waiters = wait_for_jobs(waiters)
+
+ if new_waiters.empty?
+ # We refresh the import JID here so workers importing individual
+ # resources (e.g. notes) don't have to do this all the time, reducing
+ # the pressure on Redis. We _only_ do this once all jobs are done so
+ # we don't get stuck forever if one or more jobs failed to notify the
+ # JobWaiter.
+ project.refresh_import_jid_expiration
+
+ STAGES.fetch(next_stage.to_sym).perform_async(project_id)
+ else
+ self.class.perform_in(INTERVAL, project_id, new_waiters, next_stage)
+ end
+ end
+
+ def wait_for_jobs(waiters)
+ waiters.each_with_object({}) do |(key, remaining), new_waiters|
+ waiter = JobWaiter.new(remaining, key)
+
+ # We wait for a brief moment of time so we don't reschedule if we can
+ # complete the work fast enough.
+ waiter.wait(BLOCKING_WAIT_TIME)
+
+ next unless waiter.jobs_remaining.positive?
+
+ new_waiters[waiter.key] = waiter.jobs_remaining
+ end
+ end
+
+ def find_project(id)
+ # We only care about the import JID so we can refresh it. We also only
+ # want the project if it hasn't been marked as failed yet. It's possible
+ # the import gets marked as stuck when jobs of the current stage failed
+ # somehow.
+ Project.select(:import_jid).import_started.find_by(id: id)
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_diff_note_worker.rb b/app/workers/gitlab/github_import/import_diff_note_worker.rb
new file mode 100644
index 00000000000..ef2a74c51c5
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_diff_note_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportDiffNoteWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::DiffNote
+ end
+
+ def importer_class
+ Importer::DiffNoteImporter
+ end
+
+ def counter_name
+ :github_importer_imported_diff_notes
+ end
+
+ def counter_description
+ 'The number of imported GitHub pull request review comments'
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_issue_worker.rb b/app/workers/gitlab/github_import/import_issue_worker.rb
new file mode 100644
index 00000000000..1b081ae5966
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_issue_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportIssueWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::Issue
+ end
+
+ def importer_class
+ Importer::IssueAndLabelLinksImporter
+ end
+
+ def counter_name
+ :github_importer_imported_issues
+ end
+
+ def counter_description
+ 'The number of imported GitHub issues'
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_note_worker.rb b/app/workers/gitlab/github_import/import_note_worker.rb
new file mode 100644
index 00000000000..d2b4c36a5b9
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_note_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportNoteWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::Note
+ end
+
+ def importer_class
+ Importer::NoteImporter
+ end
+
+ def counter_name
+ :github_importer_imported_notes
+ end
+
+ def counter_description
+ 'The number of imported GitHub comments'
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_pull_request_worker.rb b/app/workers/gitlab/github_import/import_pull_request_worker.rb
new file mode 100644
index 00000000000..62a6da152a3
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_pull_request_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportPullRequestWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::PullRequest
+ end
+
+ def importer_class
+ Importer::PullRequestImporter
+ end
+
+ def counter_name
+ :github_importer_imported_pull_requests
+ end
+
+ def counter_description
+ 'The number of imported GitHub pull requests'
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb
new file mode 100644
index 00000000000..7108b531bc2
--- /dev/null
+++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class RefreshImportJidWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+
+ # The interval to schedule new instances of this job at.
+ INTERVAL = 1.minute.to_i
+
+ def self.perform_in_the_future(*args)
+ perform_in(INTERVAL, *args)
+ end
+
+ # project_id - The ID of the project that is being imported.
+ # check_job_id - The ID of the job for which to check the status.
+ def perform(project_id, check_job_id)
+ return unless (project = find_project(project_id))
+
+ if SidekiqStatus.running?(check_job_id)
+ # As long as the repository is being cloned we want to keep refreshing
+ # the import JID status.
+ project.refresh_import_jid_expiration
+ self.class.perform_in_the_future(project_id, check_job_id)
+ end
+
+ # If the job is no longer running there's nothing else we need to do. If
+ # the clone job completed successfully it will have scheduled the next
+ # stage, if it died there's nothing we can do anyway.
+ end
+
+ def find_project(id)
+ Project.select(:import_jid).import_started.find_by(id: id)
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
new file mode 100644
index 00000000000..a779e631516
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class FinishImportWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # project - An instance of Project.
+ def import(_, project)
+ project.after_import
+ report_import_time(project)
+ end
+
+ def report_import_time(project)
+ duration = Time.zone.now - project.created_at
+ path = project.full_path
+
+ histogram.observe({ project: path }, duration)
+ counter.increment
+
+ logger.info("GitHub importer finished for #{path} in #{duration.round(2)} seconds")
+ end
+
+ def histogram
+ @histogram ||= Gitlab::Metrics.histogram(
+ :github_importer_total_duration_seconds,
+ 'Total time spent importing GitHub projects, in seconds'
+ )
+ end
+
+ def counter
+ @counter ||= Gitlab::Metrics.counter(
+ :github_importer_imported_projects,
+ 'The number of imported GitHub projects'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
new file mode 100644
index 00000000000..5726fbb573d
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportBaseDataWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # These importers are fast enough that we can just run them in the same
+ # thread.
+ IMPORTERS = [
+ Importer::LabelsImporter,
+ Importer::MilestonesImporter,
+ Importer::ReleasesImporter
+ ].freeze
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ IMPORTERS.each do |klass|
+ klass.new(project, client).execute
+ end
+
+ project.refresh_import_jid_expiration
+
+ ImportPullRequestsWorker.perform_async(project.id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
new file mode 100644
index 00000000000..7007754ff2e
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportIssuesAndDiffNotesWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # The importers to run in this stage. Issues can't be imported earlier
+ # on as we also use these to enrich pull requests with assigned labels.
+ IMPORTERS = [
+ Importer::IssuesImporter,
+ Importer::DiffNotesImporter
+ ].freeze
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ waiters = IMPORTERS.each_with_object({}) do |klass, hash|
+ waiter = klass.new(project, client).execute
+ hash[waiter.key] = waiter.jobs_remaining
+ end
+
+ AdvanceStageWorker.perform_async(project.id, waiters, :notes)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
new file mode 100644
index 00000000000..5f4678a595f
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportNotesWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ waiter = Importer::NotesImporter
+ .new(project, client)
+ .execute
+
+ AdvanceStageWorker.perform_async(
+ project.id,
+ { waiter.key => waiter.jobs_remaining },
+ :finish
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
new file mode 100644
index 00000000000..1c5a7139802
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportPullRequestsWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ waiter = Importer::PullRequestsImporter
+ .new(project, client)
+ .execute
+
+ project.refresh_import_jid_expiration
+
+ AdvanceStageWorker.perform_async(
+ project.id,
+ { waiter.key => waiter.jobs_remaining },
+ :issues_and_diff_notes
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
new file mode 100644
index 00000000000..4d16cef1130
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportRepositoryWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ # In extreme cases it's possible for a clone to take more than the
+ # import job expiration time. To work around this we schedule a
+ # separate job that will periodically run and refresh the import
+ # expiration time.
+ RefreshImportJidWorker.perform_in_the_future(project.id, jid)
+
+ importer = Importer::RepositoryImporter.new(project, client)
+
+ return unless importer.execute
+
+ counter.increment
+
+ ImportBaseDataWorker.perform_async(project.id)
+ end
+
+ def counter
+ Gitlab::Metrics.counter(
+ :github_importer_imported_repositories,
+ 'The number of imported GitHub repositories'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb
index 964287a1793..a0028e41332 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -1,9 +1,8 @@
class GitlabShellWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include Gitlab::ShellAdapter
- include DedicatedSidekiqQueue
def perform(action, *arg)
- gitlab_shell.send(action, *arg)
+ gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index 0a55aab63fd..6dd281b1147 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -1,7 +1,7 @@
class GitlabUsagePingWorker
LEASE_TIMEOUT = 86400
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index 07e82767b06..509bd09dc2e 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -1,10 +1,10 @@
class GroupDestroyWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
+ include ExceptionBacktrace
def perform(group_id, user_id)
begin
- group = Group.with_deleted.find(group_id)
+ group = Group.find(group_id)
rescue ActiveRecord::RecordNotFound
return
end
diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb
index 7957ed807ab..9788c8df3a3 100644
--- a/app/workers/import_export_project_cleanup_worker.rb
+++ b/app/workers/import_export_project_cleanup_worker.rb
@@ -1,5 +1,5 @@
class ImportExportProjectCleanupWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb
new file mode 100644
index 00000000000..6774ab307c6
--- /dev/null
+++ b/app/workers/invalid_gpg_signature_update_worker.rb
@@ -0,0 +1,11 @@
+class InvalidGpgSignatureUpdateWorker
+ include ApplicationWorker
+
+ def perform(gpg_key_id)
+ gpg_key = GpgKey.find_by(id: gpg_key_id)
+
+ return unless gpg_key
+
+ Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run
+ end
+end
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 22f67fa9e9f..9ae5456be4c 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -2,8 +2,7 @@ require 'json'
require 'socket'
class IrkerWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(project_id, chans, colors, push_data, settings)
project = Project.find(project_id)
@@ -66,7 +65,7 @@ class IrkerWorker
end
def send_new_branch(project, repo_name, committer, branch)
- repo_path = project.path_with_namespace
+ repo_path = project.full_path
newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches"
newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors
@@ -104,12 +103,13 @@ class IrkerWorker
parents = commit.parents
# Return old value if there's no new one
return push_data['before'] if parents.empty?
+
# Or return the first parent-commit
parents[0].id
end
def send_commits_count(data, project, repo, committer, branch)
- url = compare_url data, project.path_with_namespace
+ url = compare_url data, project.full_path
commits = colorize_commits data['total_commits_count']
new_commits = 'new commit'
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index 48e2da338f6..ba832fe30c6 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -1,6 +1,5 @@
class MergeWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(merge_request_id, current_user_id, params)
params = params.with_indifferent_access
diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb
index bfae0c77700..adb25c2a170 100644
--- a/app/workers/namespaceless_project_destroy_worker.rb
+++ b/app/workers/namespaceless_project_destroy_worker.rb
@@ -5,12 +5,8 @@
# 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
+ include ApplicationWorker
+ include ExceptionBacktrace
def perform(project_id)
begin
@@ -18,16 +14,13 @@ class NamespacelessProjectDestroyWorker
rescue ActiveRecord::RecordNotFound
return
end
- return unless project.namespace_id.nil? # Reject doing anything for projects that *do* have a namespace
+
+ return if project.namespace # 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
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
new file mode 100644
index 00000000000..3bc030f9c62
--- /dev/null
+++ b/app/workers/new_issue_worker.rb
@@ -0,0 +1,16 @@
+class NewIssueWorker
+ include ApplicationWorker
+ include NewIssuable
+
+ def perform(issue_id, user_id)
+ return unless objects_found?(issue_id, user_id)
+
+ EventCreateService.new.open_issue(issuable, user)
+ NotificationService.new.new_issue(issuable, user)
+ issuable.create_cross_references!(user)
+ end
+
+ def issuable_class
+ Issue
+ end
+end
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
new file mode 100644
index 00000000000..bda2a0ab59d
--- /dev/null
+++ b/app/workers/new_merge_request_worker.rb
@@ -0,0 +1,16 @@
+class NewMergeRequestWorker
+ include ApplicationWorker
+ include NewIssuable
+
+ def perform(merge_request_id, user_id)
+ return unless objects_found?(merge_request_id, user_id)
+
+ EventCreateService.new.open_mr(issuable, user)
+ NotificationService.new.new_merge_request(issuable, user)
+ issuable.create_cross_references!(user)
+ end
+
+ def issuable_class
+ MergeRequest
+ end
+end
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 926162b8c53..67c54fbf10e 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -1,6 +1,5 @@
class NewNoteWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
# Keep extra parameter to preserve backwards compatibility with
# old `NewNoteWorker` jobs (can remove later)
diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb
new file mode 100644
index 00000000000..a3ff4bd2101
--- /dev/null
+++ b/app/workers/pages_domain_verification_cron_worker.rb
@@ -0,0 +1,10 @@
+class PagesDomainVerificationCronWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ PagesDomain.needs_verification.find_each do |domain|
+ PagesDomainVerificationWorker.perform_async(domain.id)
+ end
+ end
+end
diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb
new file mode 100644
index 00000000000..2e93489113c
--- /dev/null
+++ b/app/workers/pages_domain_verification_worker.rb
@@ -0,0 +1,11 @@
+class PagesDomainVerificationWorker
+ include ApplicationWorker
+
+ def perform(domain_id)
+ domain = PagesDomain.find_by(id: domain_id)
+
+ return unless domain
+
+ VerifyPagesDomainService.new(domain).execute
+ end
+end
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index 4eeb9666bb0..66a0ff83bef 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -1,10 +1,10 @@
class PagesWorker
- include Sidekiq::Worker
+ include ApplicationWorker
- sidekiq_options queue: :pages, retry: false
+ sidekiq_options retry: 3
def perform(action, *arg)
- send(action, *arg)
+ send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
end
def deploy(build_id)
@@ -13,6 +13,7 @@ class PagesWorker
if result[:status] == :success
result = Projects::UpdatePagesConfigurationService.new(build.project).execute
end
+
result
end
diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb
index 7e36eacebf8..c94918ff4ee 100644
--- a/app/workers/pipeline_hooks_worker.rb
+++ b/app/workers/pipeline_hooks_worker.rb
@@ -1,7 +1,9 @@
class PipelineHooksWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
+ queue_namespace :pipeline_hooks
+
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id)
.try(:execute_hooks)
diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb
index 070943f1ecc..d46d1f122fc 100644
--- a/app/workers/pipeline_metrics_worker.rb
+++ b/app/workers/pipeline_metrics_worker.rb
@@ -1,5 +1,5 @@
class PipelineMetricsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
def perform(pipeline_id)
diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb
index cdb860b6675..a9a1168a6e3 100644
--- a/app/workers/pipeline_notification_worker.rb
+++ b/app/workers/pipeline_notification_worker.rb
@@ -1,5 +1,5 @@
class PipelineNotificationWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
def perform(pipeline_id, recipients = nil)
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index 357e4a9a1c3..24424b3f472 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -1,7 +1,9 @@
class PipelineProcessWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
+ queue_namespace :pipeline_processing
+
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id)
.try(:process!)
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index 7b485b3363c..c49758878a4 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -1,20 +1,17 @@
class PipelineScheduleWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
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
+ pipeline = Ci::CreatePipelineService.new(schedule.project,
+ schedule.owner,
+ ref: schedule.ref)
+ .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
- Ci::CreatePipelineService.new(schedule.project,
- schedule.owner,
- ref: schedule.ref)
- .execute(:schedule, save_on_errors: false, schedule: schedule)
+ schedule.deactivate! unless pipeline.persisted?
rescue => e
Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}"
ensure
diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb
index cc0eb708cf9..2ab0739a17f 100644
--- a/app/workers/pipeline_success_worker.rb
+++ b/app/workers/pipeline_success_worker.rb
@@ -1,7 +1,9 @@
class PipelineSuccessWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
+ queue_namespace :pipeline_processing
+
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline|
MergeRequests::MergeWhenPipelineSucceedsService
diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb
index 96c4152c674..fc9da2d45b1 100644
--- a/app/workers/pipeline_update_worker.rb
+++ b/app/workers/pipeline_update_worker.rb
@@ -1,7 +1,9 @@
class PipelineUpdateWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
+ queue_namespace :pipeline_processing
+
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id)
.try(:update_status)
diff --git a/app/workers/plugin_worker.rb b/app/workers/plugin_worker.rb
new file mode 100644
index 00000000000..bfcc683d99a
--- /dev/null
+++ b/app/workers/plugin_worker.rb
@@ -0,0 +1,15 @@
+class PluginWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: false
+
+ def perform(file_name, data)
+ success, message = Gitlab::Plugin.execute(file_name, data)
+
+ unless success
+ Gitlab::PluginLogger.error("Plugin Error => #{file_name}: #{message}")
+ end
+
+ true
+ end
+end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 89286595ca6..3909dbf7d7f 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -1,12 +1,11 @@
class PostReceive
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
- def perform(project_identifier, identifier, changes)
- project, is_wiki = parse_project_identifier(project_identifier)
+ def perform(gl_repository, identifier, changes)
+ project, is_wiki = Gitlab::GlRepository.parse(gl_repository)
if project.nil?
- log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"")
+ log("Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"")
return false
end
@@ -56,22 +55,7 @@ class PostReceive
end
def process_wiki_changes(post_received)
- # Nothing defined here yet.
- end
-
- # To maintain backwards compatibility, we accept both gl_repository or
- # repository paths as project identifiers. Our plan is to migrate to
- # gl_repository only with the following plan:
- # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths
- # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present
- # 9.4 (or patch release): Make GitLab Shell always pass gl_repository
- # 9.5 (or patch release): Handle only gl_repository as project identifier on this method
- def parse_project_identifier(project_identifier)
- if project_identifier.start_with?('/')
- Gitlab::RepoPath.parse(project_identifier)
- else
- Gitlab::GlRepository.parse(project_identifier)
- end
+ post_received.project.touch(:last_activity_at, :last_repository_updated_at)
end
def log(message)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index c0c03848a40..201e7f332b4 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -5,8 +5,7 @@
# Consider using an extra worker if you need to add any extra (and potentially
# slow) processing of commits.
class ProcessCommitWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.
@@ -24,27 +23,24 @@ class ProcessCommitWorker
return unless user
commit = build_commit(project, commit_hash)
-
author = commit.author || user
process_commit_message(project, commit, user, author, default)
-
update_issue_metrics(commit, author)
end
def process_commit_message(project, commit, user, author, default = false)
- closed_issues = default ? commit.closes_issues(user) : []
-
- unless closed_issues.empty?
- close_issues(project, user, author, commit, closed_issues)
- end
+ # Ignore closing references from GitLab-generated commit messages.
+ find_closing_issues = default && !commit.merged_merge_request?(user)
+ closed_issues = find_closing_issues ? commit.closes_issues(user) : []
+ close_issues(project, user, author, commit, closed_issues) if closed_issues.any?
commit.create_cross_references!(author, closed_issues)
end
def close_issues(project, user, author, commit, issues)
# We don't want to run permission related queries for every single issue,
- # therefor we use IssueCollection here and skip the authorization check in
+ # therefore we use IssueCollection here and skip the authorization check in
# Issues::CloseService#execute.
IssueCollection.new(issues).updatable_by_user(user).each do |issue|
Issues::CloseService.new(project, author)
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 505ff9e086e..a993b4b2680 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -1,7 +1,6 @@
# Worker for updating any project specific caches.
class ProjectCacheWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
LEASE_TIMEOUT = 15.minutes.to_i
@@ -19,6 +18,8 @@ class ProjectCacheWorker
update_statistics(project, statistics.map(&:to_sym))
project.repository.refresh_method_caches(files.map(&:to_sym))
+
+ project.cleanup
end
def update_statistics(project, statistics = [])
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index b462327490e..1ba854ca4cb 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -1,16 +1,13 @@
class ProjectDestroyWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
+ include ExceptionBacktrace
def perform(project_id, user_id, params)
- begin
- project = Project.unscoped.find(project_id)
- rescue ActiveRecord::RecordNotFound
- return
- end
-
+ project = Project.find(project_id)
user = User.find(user_id)
::Projects::DestroyService.new(project, user, params.symbolize_keys).execute
+ rescue ActiveRecord::RecordNotFound => error
+ logger.error("Failed to delete project (#{project_id}): #{error.message}")
end
end
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index 6009aa1b191..0b502143e5d 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -1,13 +1,14 @@
class ProjectExportWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
+ include ExceptionBacktrace
sidekiq_options retry: 3
- def perform(current_user_id, project_id)
+ def perform(current_user_id, project_id, params = {})
+ params = params.with_indifferent_access
current_user = User.find(current_user_id)
project = Project.find(project_id)
- ::Projects::ImportExport::ExportService.new(project, current_user).execute
+ ::Projects::ImportExport::ExportService.new(project, current_user, params).execute
end
end
diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb
new file mode 100644
index 00000000000..d01eb744e5d
--- /dev/null
+++ b/app/workers/project_migrate_hashed_storage_worker.rb
@@ -0,0 +1,34 @@
+class ProjectMigrateHashedStorageWorker
+ include ApplicationWorker
+
+ LEASE_TIMEOUT = 30.seconds.to_i
+
+ def perform(project_id)
+ project = Project.find_by(id: project_id)
+ return if project.nil? || project.pending_delete?
+
+ uuid = lease_for(project_id).try_obtain
+ if uuid
+ ::Projects::HashedStorageMigrationService.new(project, logger).execute
+ else
+ false
+ end
+ rescue => ex
+ cancel_lease_for(project_id, uuid) if uuid
+ raise ex
+ end
+
+ def lease_for(project_id)
+ Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT)
+ end
+
+ private
+
+ def lease_key(project_id)
+ "project_migrate_hashed_storage_worker:#{project_id}"
+ end
+
+ def cancel_lease_for(project_id, uuid)
+ Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid)
+ end
+end
diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb
index fdfdeab7b41..75c4b8b3663 100644
--- a/app/workers/project_service_worker.rb
+++ b/app/workers/project_service_worker.rb
@@ -1,6 +1,7 @@
class ProjectServiceWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
+
+ sidekiq_options dead: false
def perform(hook_id, data)
data = data.with_indifferent_access
diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb
index 6b607451c7a..635a97c99af 100644
--- a/app/workers/propagate_service_template_worker.rb
+++ b/app/workers/propagate_service_template_worker.rb
@@ -1,7 +1,6 @@
# Worker for updating any project specific caches.
class PropagateServiceTemplateWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
LEASE_TIMEOUT = 4.hours.to_i
diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb
index 2b43bb19ad1..5ff62ab1369 100644
--- a/app/workers/prune_old_events_worker.rb
+++ b/app/workers/prune_old_events_worker.rb
@@ -1,5 +1,5 @@
class PruneOldEventsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index 18b8daf4e1e..ef3ddb9024b 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -1,6 +1,5 @@
class ReactiveCachingWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(class_name, id, *args)
klass = begin
diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb
new file mode 100644
index 00000000000..090987778a2
--- /dev/null
+++ b/app/workers/rebase_worker.rb
@@ -0,0 +1,12 @@
+class RebaseWorker
+ include ApplicationWorker
+
+ def perform(merge_request_id, current_user_id)
+ current_user = User.find(current_user_id)
+ merge_request = MergeRequest.find(merge_request_id)
+
+ MergeRequests::RebaseService
+ .new(merge_request.source_project, current_user)
+ .execute(merge_request)
+ end
+end
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
index 2a619f83410..7e64c3070a8 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -1,5 +1,5 @@
class RemoveExpiredGroupLinksWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index 31f652e5f9b..68960f72bf6 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -1,11 +1,11 @@
class RemoveExpiredMembersWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
Member.expired.find_each do |member|
begin
- Members::AuthorizedDestroyService.new(member).execute
+ Members::DestroyService.new.execute(member, skip_authorization: true)
rescue => ex
logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
end
diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb
index 555e1bb8691..87fed42d7ce 100644
--- a/app/workers/remove_old_web_hook_logs_worker.rb
+++ b/app/workers/remove_old_web_hook_logs_worker.rb
@@ -1,5 +1,5 @@
class RemoveOldWebHookLogsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
WEB_HOOK_LOG_LIFETIME = 2.days
diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb
index b80f131d5f7..8daf079fc31 100644
--- a/app/workers/remove_unreferenced_lfs_objects_worker.rb
+++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb
@@ -1,5 +1,5 @@
class RemoveUnreferencedLfsObjectsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb
index e47069df189..86a258cf94f 100644
--- a/app/workers/repository_archive_cache_worker.rb
+++ b/app/workers/repository_archive_cache_worker.rb
@@ -1,5 +1,5 @@
class RepositoryArchiveCacheWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb
index b94d83bd709..76688cf51c1 100644
--- a/app/workers/repository_check/batch_worker.rb
+++ b/app/workers/repository_check/batch_worker.rb
@@ -1,6 +1,6 @@
module RepositoryCheck
class BatchWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
RUN_TIME = 3600
diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb
index 85bc9103538..97b89dc3db5 100644
--- a/app/workers/repository_check/clear_worker.rb
+++ b/app/workers/repository_check/clear_worker.rb
@@ -1,6 +1,6 @@
module RepositoryCheck
class ClearWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include RepositoryCheckQueue
def perform
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index 164586cf0b7..116bc185b38 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -1,6 +1,6 @@
module RepositoryCheck
class SingleRepositoryWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include RepositoryCheckQueue
def perform(project_id)
@@ -20,10 +20,7 @@ module RepositoryCheck
# Historically some projects never had their wiki repos initialized;
# this happens on project creation now. Let's initialize an empty repo
# if it is not already there.
- begin
- project.create_wiki
- rescue Rugged::RepositoryError
- end
+ project.create_wiki
git_fsck(project.wiki.repository)
else
@@ -32,16 +29,14 @@ module RepositoryCheck
end
def git_fsck(repository)
- path = repository.path_to_repo
- cmd = %W(nice git --git-dir=#{path} fsck)
- output, status = Gitlab::Popen.popen(cmd)
+ return false unless repository.exists?
- if status.zero?
- true
- else
- Gitlab::RepositoryCheckLogger.error("command failed: #{cmd.join(' ')}\n#{output}")
- false
- end
+ repository.raw_repository.fsck
+
+ true
+ rescue Gitlab::Git::Repository::GitError => e
+ Gitlab::RepositoryCheckLogger.error(e.message)
+ false
end
def has_pushes?(project)
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index a338523dc6b..07584fab7c8 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -1,40 +1,31 @@
class RepositoryForkWorker
- ForkError = Class.new(StandardError)
-
- include Sidekiq::Worker
+ include ApplicationWorker
include Gitlab::ShellAdapter
- include DedicatedSidekiqQueue
-
- def perform(project_id, forked_from_repository_storage_path, source_path, target_path)
- Gitlab::Metrics.add_event(:fork_repository,
- source_path: source_path,
- target_path: target_path)
+ include ProjectStartImport
+ include ProjectImportOptions
+ def perform(project_id, forked_from_repository_storage_path, source_disk_path)
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)
- raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result
+ return unless start_fork(project)
- project.repository.after_import
- raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
+ Gitlab::Metrics.add_event(:fork_repository,
+ source_path: source_disk_path,
+ target_path: project.disk_path)
- project.import_finish
- rescue ForkError => ex
- fail_fork(project, ex.message)
- raise
- rescue => ex
- return unless project
+ result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path,
+ project.repository_storage_path, project.disk_path)
+ raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result
- fail_fork(project, ex.message)
- raise ForkError, "#{ex.class} #{ex.message}"
+ project.after_import
end
private
- def fail_fork(project, message)
- Rails.logger.error(message)
- project.mark_import_as_failed(message)
+ def start_fork(project)
+ return true if start(project)
+
+ Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.")
+ false
end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 625476b7e01..d79b5ee5346 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -1,42 +1,43 @@
class RepositoryImportWorker
- ImportError = Class.new(StandardError)
-
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
-
- sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_EXPIRATION
-
- attr_accessor :project, :current_user
+ include ApplicationWorker
+ include ExceptionBacktrace
+ include ProjectStartImport
+ include ProjectImportOptions
def perform(project_id)
- @project = Project.find(project_id)
- @current_user = @project.creator
+ project = Project.find(project_id)
- project.import_start
+ return unless start_import(project)
Gitlab::Metrics.add_event(:import_repository,
- import_url: @project.import_url,
- path: @project.path_with_namespace)
+ import_url: project.import_url,
+ path: project.full_path)
+
+ service = Projects::ImportService.new(project, project.creator)
+ result = service.execute
- project.update_columns(import_jid: self.jid, import_error: nil)
+ # Some importers may perform their work asynchronously. In this case it's up
+ # to those importers to mark the import process as complete.
+ return if service.async?
- result = Projects::ImportService.new(project, current_user).execute
- raise ImportError, result[:message] if result[:status] == :error
+ if result[:status] == :error
+ fail_import(project, result[:message]) if project.gitlab_project_import?
- project.repository.after_import
- project.import_finish
- rescue ImportError => ex
- fail_import(project, ex.message)
- raise
- rescue => ex
- return unless project
+ raise result[:message]
+ end
- fail_import(project, ex.message)
- raise ImportError, "#{ex.class} #{ex.message}"
+ project.after_import
end
private
+ def start_import(project)
+ return true if start(project)
+
+ Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.")
+ false
+ end
+
def fail_import(project, message)
project.mark_import_as_failed(message)
end
diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb
index 703b025d76e..55c236e9e9d 100644
--- a/app/workers/requests_profiles_worker.rb
+++ b/app/workers/requests_profiles_worker.rb
@@ -1,5 +1,5 @@
class RequestsProfilesWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
new file mode 100644
index 00000000000..8f5138fc873
--- /dev/null
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -0,0 +1,22 @@
+class RunPipelineScheduleWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_creation
+
+ def perform(schedule_id, user_id)
+ schedule = Ci::PipelineSchedule.find_by(id: schedule_id)
+ user = User.find_by(id: user_id)
+
+ return unless schedule && user
+
+ run_pipeline_schedule(schedule, user)
+ end
+
+ def run_pipeline_schedule(schedule, user)
+ Ci::CreatePipelineService.new(schedule.project,
+ user,
+ ref: schedule.ref)
+ .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
+ end
+end
diff --git a/app/workers/schedule_update_user_activity_worker.rb b/app/workers/schedule_update_user_activity_worker.rb
index 6c2c3e437f3..d9376577597 100644
--- a/app/workers/schedule_update_user_activity_worker.rb
+++ b/app/workers/schedule_update_user_activity_worker.rb
@@ -1,5 +1,5 @@
class ScheduleUpdateUserActivityWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform(batch_size = 500)
diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb
new file mode 100644
index 00000000000..e4b683fca33
--- /dev/null
+++ b/app/workers/stage_update_worker.rb
@@ -0,0 +1,12 @@
+class StageUpdateWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+
+ def perform(stage_id)
+ Ci::Stage.find_by(id: stage_id).try do |stage|
+ stage.update_status
+ end
+ end
+end
diff --git a/app/workers/storage_migrator_worker.rb b/app/workers/storage_migrator_worker.rb
new file mode 100644
index 00000000000..f92421a667d
--- /dev/null
+++ b/app/workers/storage_migrator_worker.rb
@@ -0,0 +1,29 @@
+class StorageMigratorWorker
+ include ApplicationWorker
+
+ BATCH_SIZE = 100
+
+ def perform(start, finish)
+ projects = build_relation(start, finish)
+
+ projects.with_route.find_each(batch_size: BATCH_SIZE) do |project|
+ Rails.logger.info "Starting storage migration of #{project.full_path} (ID=#{project.id})..."
+
+ begin
+ project.migrate_to_hashed_storage!
+ rescue => err
+ Rails.logger.error("#{err.message} migrating storage of #{project.full_path} (ID=#{project.id}), trace - #{err.backtrace}")
+ end
+ end
+ end
+
+ def build_relation(start, finish)
+ relation = Project
+ table = Project.arel_table
+
+ relation = relation.where(table[:id].gteq(start)) if start
+ relation = relation.where(table[:id].lteq(finish)) if finish
+
+ relation
+ end
+end
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index ae8c980c9e4..fb26fa4c515 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -1,5 +1,5 @@
class StuckCiJobsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease'.freeze
@@ -39,21 +39,30 @@ class StuckCiJobsWorker
def drop_stuck(status, timeout)
search(status, timeout) do |build|
return unless build.stuck?
+
drop_build :stuck, build, status, timeout
end
end
def search(status, timeout)
- builds = Ci::Build.where(status: status).where('ci_builds.updated_at < ?', timeout.ago)
- builds.joins(:project).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build|
- yield(build)
+ loop do
+ jobs = Ci::Build.where(status: status)
+ .where('ci_builds.updated_at < ?', timeout.ago)
+ .includes(:tags, :runner, project: :namespace)
+ .limit(100)
+ .to_a
+ break if jobs.empty?
+
+ jobs.each do |job|
+ yield(job)
+ end
end
end
def drop_build(type, build, status, timeout)
Rails.logger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout})"
Gitlab::OptimisticLocking.retry_lock(build, 3) do |b|
- b.drop
+ b.drop(:stuck_or_timeout_failure)
end
end
end
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index bfc5e667bb6..fbb14efc525 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -1,37 +1,59 @@
class StuckImportJobsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
- IMPORT_EXPIRATION = 15.hours.to_i
+ IMPORT_JOBS_EXPIRATION = 15.hours.to_i
def perform
- stuck_projects.find_in_batches(batch_size: 500) do |group|
- jids = group.map(&:import_jid)
+ projects_without_jid_count = mark_projects_without_jid_as_failed!
+ projects_with_jid_count = mark_projects_with_jid_as_failed!
- # Find the jobs that aren't currently running or that exceeded the threshold.
- completed_jids = Gitlab::SidekiqStatus.completed_jids(jids)
+ Gitlab::Metrics.add_event(:stuck_import_jobs,
+ projects_without_jid_count: projects_without_jid_count,
+ projects_with_jid_count: projects_with_jid_count)
+ end
- if completed_jids.any?
- completed_ids = group.select { |project| completed_jids.include?(project.import_jid) }.map(&:id)
+ private
- fail_batch!(completed_jids, completed_ids)
- end
- end
+ def mark_projects_without_jid_as_failed!
+ enqueued_projects_without_jid.each do |project|
+ project.mark_import_as_failed(error_message)
+ end.count
end
- private
+ def mark_projects_with_jid_as_failed!
+ jids_and_ids = enqueued_projects_with_jid.pluck(:import_jid, :id).to_h
+
+ # Find the jobs that aren't currently running or that exceeded the threshold.
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
+ return unless completed_jids.any?
+
+ completed_project_ids = jids_and_ids.values_at(*completed_jids)
- def stuck_projects
- Project.select('id, import_jid').with_import_status(:started).where.not(import_jid: nil)
+ # We select the projects again, because they may have transitioned from
+ # scheduled/started to finished/failed while we were looking up their Sidekiq status.
+ completed_projects = enqueued_projects_with_jid.where(id: completed_project_ids)
+
+ Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_projects.map(&:import_jid).join(', ')}")
+
+ completed_projects.each do |project|
+ project.mark_import_as_failed(error_message)
+ end.count
end
- def fail_batch!(completed_jids, completed_ids)
- Project.where(id: completed_ids).update_all(import_status: 'failed', import_error: error_message)
+ def enqueued_projects
+ Project.with_import_status(:scheduled, :started)
+ end
+
+ def enqueued_projects_with_jid
+ enqueued_projects.where.not(import_jid: nil)
+ end
- Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_jids.join(', ')}")
+ def enqueued_projects_without_jid
+ enqueued_projects.where(import_jid: nil)
end
def error_message
- "Import timed out. Import took longer than #{IMPORT_EXPIRATION} seconds"
+ "Import timed out. Import took longer than #{IMPORT_JOBS_EXPIRATION} seconds"
end
end
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
new file mode 100644
index 00000000000..16394293c79
--- /dev/null
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -0,0 +1,39 @@
+class StuckMergeJobsWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ stuck_merge_requests.find_in_batches(batch_size: 100) do |group|
+ jids = group.map(&:merge_jid)
+
+ # Find the jobs that aren't currently running or that exceeded the threshold.
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(jids)
+
+ if completed_jids.any?
+ completed_ids = group.select { |merge_request| completed_jids.include?(merge_request.merge_jid) }.map(&:id)
+
+ apply_current_state!(completed_jids, completed_ids)
+ end
+ end
+ end
+
+ private
+
+ def apply_current_state!(completed_jids, completed_ids)
+ merge_requests = MergeRequest.where(id: completed_ids)
+
+ merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged)
+
+ merge_requests_to_reopen = merge_requests.where(merge_commit_sha: nil)
+
+ # Do not reopen merge requests using direct queries.
+ # We rely on state machine callbacks to update head_pipeline_id
+ merge_requests_to_reopen.each(&:unlock_mr)
+
+ Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}")
+ end
+
+ def stuck_merge_requests
+ MergeRequest.select('id, merge_jid').with_state(:locked).where.not(merge_jid: nil).reorder(nil)
+ end
+end
diff --git a/app/workers/system_hook_push_worker.rb b/app/workers/system_hook_push_worker.rb
index e43bbe35de9..ceeaaf8d189 100644
--- a/app/workers/system_hook_push_worker.rb
+++ b/app/workers/system_hook_push_worker.rb
@@ -1,6 +1,5 @@
class SystemHookPushWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(push_data, hook_id)
SystemHooksService.new.execute_hooks(push_data, hook_id)
diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb
index 0531630d13a..7eb65452a7d 100644
--- a/app/workers/trending_projects_worker.rb
+++ b/app/workers/trending_projects_worker.rb
@@ -1,5 +1,5 @@
class TrendingProjectsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb
new file mode 100644
index 00000000000..76f84ff920f
--- /dev/null
+++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb
@@ -0,0 +1,27 @@
+class UpdateHeadPipelineForMergeRequestWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+
+ def perform(merge_request_id)
+ merge_request = MergeRequest.find(merge_request_id)
+ pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last
+
+ return unless pipeline && pipeline.latest?
+
+ if merge_request.diff_head_sha != pipeline.sha
+ log_error_message_for(merge_request)
+
+ return
+ end
+
+ merge_request.update_attribute(:head_pipeline_id, pipeline.id)
+ end
+
+ def log_error_message_for(merge_request)
+ Rails.logger.error(
+ "Outdated head pipeline for active merge request: id=#{merge_request.id}, source_branch=#{merge_request.source_branch}, diff_head_sha=#{merge_request.diff_head_sha}"
+ )
+ end
+end
diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb
index 89ae17cef37..74bb9993275 100644
--- a/app/workers/update_merge_requests_worker.rb
+++ b/app/workers/update_merge_requests_worker.rb
@@ -1,6 +1,7 @@
class UpdateMergeRequestsWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
+
+ LOG_TIME_THRESHOLD = 90 # seconds
def perform(project_id, user_id, oldrev, newrev, ref)
project = Project.find_by(id: project_id)
@@ -9,6 +10,20 @@ class UpdateMergeRequestsWorker
user = User.find_by(id: user_id)
return unless user
- MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref)
+ # TODO: remove this benchmarking when we have rich logging
+ time = Benchmark.measure do
+ MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref)
+ end
+
+ args_log = [
+ "elapsed=#{time.real}",
+ "project_id=#{project_id}",
+ "user_id=#{user_id}",
+ "oldrev=#{oldrev}",
+ "newrev=#{newrev}",
+ "ref=#{ref}"
+ ].join(',')
+
+ Rails.logger.info("UpdateMergeRequestsWorker#perform #{args_log}") if time.real > LOG_TIME_THRESHOLD
end
end
diff --git a/app/workers/update_user_activity_worker.rb b/app/workers/update_user_activity_worker.rb
index 31bbdb69edb..27ec5cd33fb 100644
--- a/app/workers/update_user_activity_worker.rb
+++ b/app/workers/update_user_activity_worker.rb
@@ -1,6 +1,5 @@
class UpdateUserActivityWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(pairs)
pairs = cast_data(pairs)
diff --git a/app/workers/upload_checksum_worker.rb b/app/workers/upload_checksum_worker.rb
index 78931f1258f..65d40336f18 100644
--- a/app/workers/upload_checksum_worker.rb
+++ b/app/workers/upload_checksum_worker.rb
@@ -1,10 +1,9 @@
class UploadChecksumWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(upload_id)
upload = Upload.find(upload_id)
- upload.calculate_checksum
+ upload.calculate_checksum!
upload.save!
rescue ActiveRecord::RecordNotFound
Rails.logger.error("UploadChecksumWorker: couldn't find upload #{upload_id}, skipping")
diff --git a/app/workers/use_key_worker.rb b/app/workers/use_key_worker.rb
deleted file mode 100644
index c9d382cc5d6..00000000000
--- a/app/workers/use_key_worker.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-class UseKeyWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
-
- def perform(key_id)
- key = Key.find(key_id)
- key.touch(:last_used_at)
- rescue ActiveRecord::RecordNotFound
- Rails.logger.error("UseKeyWorker: couldn't find key with ID=#{key_id}, skipping job")
-
- false
- end
-end
diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb
new file mode 100644
index 00000000000..19cdb279aaa
--- /dev/null
+++ b/app/workers/wait_for_cluster_creation_worker.rb
@@ -0,0 +1,12 @@
+class WaitForClusterCreationWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ def perform(cluster_id)
+ Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
+ cluster.provider.try do |provider|
+ Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) if cluster.gcp?
+ end
+ end
+ end
+end
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index ad5ddf02a12..dfc3f33ad9d 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -1,8 +1,7 @@
class WebHookWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
- sidekiq_options retry: 4
+ sidekiq_options retry: 4, dead: false
def perform(hook_id, data, hook_name)
hook = WebHook.find(hook_id)